testing: initial api side of test coverage

For https://github.com/microsoft/vscode/issues/123713
This commit is contained in:
Connor Peet
2021-06-25 12:58:04 -07:00
parent cf9635a547
commit 45eea1f87b
10 changed files with 596 additions and 19 deletions

View File

@@ -11,7 +11,9 @@ import { URI } from 'vs/base/common/uri';
import { Range } from 'vs/editor/common/core/range';
import { extHostNamedCustomer } from 'vs/workbench/api/common/extHostCustomers';
import { TestResultState } from 'vs/workbench/api/common/extHostTypes';
import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue';
import { ExtensionRunTestsRequest, ITestItem, ITestMessage, ITestRunTask, RunTestsRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult';
import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService';
import { ITestRootProvider, ITestService } from 'vs/workbench/contrib/testing/common/testService';
@@ -78,6 +80,23 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh
this.withLiveRun(runId, r => r.addTestChainToRun(controllerId, tests));
}
/**
* @inheritdoc
*/
$signalCoverageAvailable(runId: string, taskId: string): void {
this.withLiveRun(runId, run => {
const task = run.tasks.find(t => t.id === taskId);
if (!task) {
return;
}
(task.coverage as MutableObservableValue<TestCoverage>).value = new TestCoverage({
provideFileCoverage: token => this.proxy.$provideFileCoverage(runId, taskId, token),
resolveFileCoverage: (i, token) => this.proxy.$resolveFileCoverage(runId, taskId, i, token),
});
});
}
/**
* @inheritdoc
*/

View File

@@ -1272,6 +1272,11 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I
TestMessage: extHostTypes.TestMessage,
TextSearchCompleteMessageType: TextSearchCompleteMessageType,
TestMessageSeverity: extHostTypes.TestMessageSeverity,
CoveredCount: extHostTypes.CoveredCount,
FileCoverage: extHostTypes.FileCoverage,
StatementCoverage: extHostTypes.StatementCoverage,
BranchCoverage: extHostTypes.BranchCoverage,
FunctionCoverage: extHostTypes.FunctionCoverage,
WorkspaceTrustState: extHostTypes.WorkspaceTrustState
};
};

View File

@@ -56,7 +56,7 @@ import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange';
import { InputValidationType } from 'vs/workbench/contrib/scm/common/scm';
import { ITextQueryBuilderOptions } from 'vs/workbench/contrib/search/common/queryBuilder';
import { ISerializableEnvironmentVariableCollection } from 'vs/workbench/contrib/terminal/common/environmentVariable';
import { ExtensionRunTestsRequest, ISerializedTestResults, ITestItem, ITestMessage, ITestRunTask, RunTestForControllerRequest, RunTestsRequest, ITestIdWithSrc, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { ExtensionRunTestsRequest, ISerializedTestResults, ITestItem, ITestMessage, ITestRunTask, RunTestForControllerRequest, RunTestsRequest, ITestIdWithSrc, TestsDiff, IFileCoverage, CoverageDetails } from 'vs/workbench/contrib/testing/common/testCollection';
import { InternalTimelineOptions, Timeline, TimelineChangeEvent, TimelineOptions, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline';
import { ActivationKind, ExtensionHostKind, MissingExtensionDependency } from 'vs/workbench/services/extensions/common/extensions';
import { createExtHostContextProxyIdentifier as createExtId, createMainContextProxyIdentifier as createMainId, IRPCProtocol } from 'vs/workbench/services/extensions/common/proxyIdentifier';
@@ -2064,14 +2064,19 @@ export const enum ExtHostTestingResource {
export interface ExtHostTestingShape {
$runControllerTests(req: RunTestForControllerRequest, token: CancellationToken): Promise<void>;
$cancelExtensionTestRun(runId: string | undefined): void;
/** Handles a diff of tests, as a result of a subscribeToDiffs() call */
$acceptDiff(diff: TestsDiff): void;
/** Publishes that a test run finished. */
$publishTestResults(results: ISerializedTestResults[]): void;
/** Expands a test item's children, by the given number of levels. */
$expandTest(src: ITestIdWithSrc, levels: number): Promise<void>;
/** Requests file coverage for a test run. Errors if not available. */
$provideFileCoverage(runId: string, taskId: string, token: CancellationToken): Promise<IFileCoverage[]>;
/**
* Requests coverage details for the file index in coverage data for the run.
* Requires file coverage to have been previously requested via $provideFileCoverage.
*/
$resolveFileCoverage(runId: string, taskId: string, fileIndex: number, token: CancellationToken): Promise<CoverageDetails[]>;
}
export interface MainThreadTestingShape {
@@ -2101,6 +2106,8 @@ export interface MainThreadTestingShape {
$appendTestMessageInRun(runId: string, taskId: string, testId: string, message: ITestMessage): void;
/** Appends raw output to the test run.. */
$appendOutputToRun(runId: string, taskId: string, output: VSBuffer): void;
/** Triggered when coverage is added to test results. */
$signalCoverageAvailable(runId: string, taskId: string): void;
/** Signals a task in a test run started. */
$startedTestRunTask(runId: string, task: ITestRunTask): void;
/** Signals a task in a test run ended. */

View File

@@ -21,7 +21,7 @@ import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService';
import * as Convert from 'vs/workbench/api/common/extHostTypeConverters';
import { TestItemImpl } from 'vs/workbench/api/common/extHostTypes';
import { SingleUseTestCollection, TestPosition } from 'vs/workbench/contrib/testing/common/ownedTestCollection';
import { AbstractIncrementalTestCollection, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, ITestIdWithSrc, ITestItem, RunTestForControllerRequest, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import { AbstractIncrementalTestCollection, CoverageDetails, IFileCoverage, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, ITestIdWithSrc, ITestItem, RunTestForControllerRequest, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
import type * as vscode from 'vscode';
export class ExtHostTesting implements ExtHostTestingShape {
@@ -121,6 +121,20 @@ export class ExtHostTesting implements ExtHostTestingShape {
}, token);
}
/**
* @inheritdoc
*/
$provideFileCoverage(runId: string, taskId: string, token: CancellationToken): Promise<IFileCoverage[]> {
return Iterable.find(this.runTracker.trackers, t => t.id === runId)?.getCoverage(taskId)?.provideFileCoverage(token) ?? Promise.resolve([]);
}
/**
* @inheritdoc
*/
$resolveFileCoverage(runId: string, taskId: string, fileIndex: number, token: CancellationToken): Promise<CoverageDetails[]> {
return Iterable.find(this.runTracker.trackers, t => t.id === runId)?.getCoverage(taskId)?.resolveFileCoverage(fileIndex, token) ?? Promise.resolve([]);
}
/**
* Updates test results shown to extensions.
* @override
@@ -224,7 +238,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
}
class TestRunTracker extends Disposable {
private readonly task = new Set<TestRunImpl>();
private readonly tasks = new Map</* task ID */string, { run: TestRunImpl, coverage: TestRunCoverageBearer }>();
private readonly sharedTestIds = new Set<string>();
private readonly cts: CancellationTokenSource;
private readonly endEmitter = this._register(new Emitter<void>());
@@ -239,7 +253,7 @@ class TestRunTracker extends Disposable {
* Gets whether there are any tests running.
*/
public get isRunning() {
return this.task.size > 0;
return this.tasks.size > 0;
}
/**
@@ -253,21 +267,27 @@ class TestRunTracker extends Disposable {
super();
this.cts = this._register(new CancellationTokenSource(parentToken));
this._register(this.cts.token.onCancellationRequested(() => {
for (const task of this.task) {
task.end();
for (const { run } of this.tasks.values()) {
run.end();
}
}));
}
public getCoverage(taskId: string) {
return this.tasks.get(taskId)?.coverage;
}
public createRun(name: string | undefined) {
const run = new TestRunImpl(name, this.cts.token, this.dto, this.sharedTestIds, this.proxy, () => {
this.task.delete(run);
const taskId = generateUuid();
const coverage = new TestRunCoverageBearer(this.proxy, this.dto.id, taskId);
const run = new TestRunImpl(name, this.cts.token, taskId, coverage, this.dto, this.sharedTestIds, this.proxy, () => {
this.tasks.delete(run.taskId);
if (!this.isRunning) {
this.dispose();
}
});
this.task.add(run);
this.tasks.set(run.taskId, { run, coverage });
return run;
}
@@ -397,17 +417,88 @@ export class TestRunDto {
}
}
class TestRunCoverageBearer {
private _coverageProvider?: vscode.TestCoverageProvider;
private fileCoverage?: Promise<vscode.FileCoverage[] | null | undefined>;
public set coverageProvider(provider: vscode.TestCoverageProvider | undefined) {
if (this._coverageProvider) {
throw new Error('The TestCoverageProvider cannot be replaced after being provided');
}
if (!provider) {
return;
}
this._coverageProvider = provider;
this.proxy.$signalCoverageAvailable(this.runId, this.taskId);
}
public get coverageProvider() {
return this._coverageProvider;
}
constructor(
private readonly proxy: MainThreadTestingShape,
private readonly runId: string,
private readonly taskId: string,
) {
}
public async provideFileCoverage(token: CancellationToken): Promise<IFileCoverage[]> {
if (!this._coverageProvider) {
return [];
}
if (!this.fileCoverage) {
this.fileCoverage = (async () => this._coverageProvider!.provideFileCoverage(token))();
}
try {
const coverage = await this.fileCoverage;
return coverage?.map(Convert.TestCoverage.fromFile) ?? [];
} catch (e) {
this.fileCoverage = undefined;
throw e;
}
}
public async resolveFileCoverage(index: number, token: CancellationToken): Promise<CoverageDetails[]> {
const fileCoverage = await this.fileCoverage;
let file = fileCoverage?.[index];
if (!this._coverageProvider || !fileCoverage || !file) {
return [];
}
if (!file.detailedCoverage) {
file = fileCoverage[index] = await this._coverageProvider.resolveFileCoverage?.(file, token) ?? file;
}
return file.detailedCoverage?.map(Convert.TestCoverage.fromDetailed) ?? [];
}
}
class TestRunImpl implements vscode.TestRun {
readonly #proxy: MainThreadTestingShape;
readonly #req: TestRunDto;
readonly #sharedIds: Set<string>;
readonly #onEnd: () => void;
readonly #coverage: TestRunCoverageBearer;
#ended = false;
public readonly taskId = generateUuid();
public set coverageProvider(provider: vscode.TestCoverageProvider | undefined) {
this.#coverage.coverageProvider = provider;
}
public get coverageProvider() {
return this.#coverage.coverageProvider;
}
constructor(
public readonly name: string | undefined,
public readonly token: CancellationToken,
public readonly taskId: string,
coverage: TestRunCoverageBearer,
dto: TestRunDto,
sharedTestIds: Set<string>,
proxy: MainThreadTestingShape,
@@ -416,6 +507,7 @@ class TestRunImpl implements vscode.TestRun {
this.#onEnd = onEnd;
this.#proxy = proxy;
this.#req = dto;
this.#coverage = coverage;
this.#sharedIds = sharedTestIds;
proxy.$startedTestRunTask(dto.id, { id: this.taskId, name, running: true });
}

View File

@@ -30,7 +30,7 @@ import { EditorGroupColumn, SaveReason } from 'vs/workbench/common/editor';
import * as notebooks from 'vs/workbench/contrib/notebook/common/notebookCommon';
import { ICellRange } from 'vs/workbench/contrib/notebook/common/notebookRange';
import * as search from 'vs/workbench/contrib/search/common/search';
import { ISerializedTestResults, ITestItem, ITestItemContext, ITestMessage, SerializedTestResultItem } from 'vs/workbench/contrib/testing/common/testCollection';
import { CoverageDetails, DetailType, ICoveredCount, IFileCoverage, ISerializedTestResults, ITestItem, ITestItemContext, ITestMessage, SerializedTestResultItem } from 'vs/workbench/contrib/testing/common/testCollection';
import { ACTIVE_GROUP, SIDE_GROUP } from 'vs/workbench/services/editor/common/editorService';
import type * as vscode from 'vscode';
import * as types from './extHostTypes';
@@ -1728,6 +1728,45 @@ export namespace TestResults {
}
}
export namespace TestCoverage {
function fromCoveredCount(count: vscode.CoveredCount): ICoveredCount {
return { covered: count.covered, total: count.covered };
}
function fromLocation(location: vscode.Range | vscode.Position) {
return 'line' in location ? Position.from(location) : Range.from(location);
}
export function fromDetailed(coverage: vscode.DetailedCoverage): CoverageDetails {
if ('branches' in coverage) {
return {
count: coverage.executionCount,
location: fromLocation(coverage.location),
type: DetailType.Statement,
branches: coverage.branches.length
? coverage.branches.map(b => ({ count: b.executionCount, location: b.location && fromLocation(b.location) }))
: undefined,
};
} else {
return {
type: DetailType.Function,
count: coverage.executionCount,
location: fromLocation(coverage.location),
};
}
}
export function fromFile(coverage: vscode.FileCoverage): IFileCoverage {
return {
uri: coverage.uri,
statement: fromCoveredCount(coverage.statementCoverage),
branch: coverage.branchCoverage && fromCoveredCount(coverage.branchCoverage),
function: coverage.functionCoverage && fromCoveredCount(coverage.functionCoverage),
details: coverage.detailedCoverage?.map(fromDetailed),
};
}
}
export namespace CodeActionTriggerKind {
export function to(value: modes.CodeActionTriggerType): types.CodeActionTriggerKind {

View File

@@ -3419,7 +3419,7 @@ export class TestItemImpl implements vscode.TestItem {
}
}
@es5ClassCompat
export class TestMessage implements vscode.TestMessage {
public severity = TestMessageSeverity.Error;
public expectedOutput?: string;
@@ -3437,6 +3437,82 @@ export class TestMessage implements vscode.TestMessage {
//#endregion
//#region Test Coverage
@es5ClassCompat
export class CoveredCount implements vscode.CoveredCount {
constructor(public covered: number, public total: number) { }
}
@es5ClassCompat
export class FileCoverage implements vscode.FileCoverage {
public static fromDetails(uri: vscode.Uri, details: vscode.DetailedCoverage[]): vscode.FileCoverage {
const statements = new CoveredCount(0, 0);
const branches = new CoveredCount(0, 0);
const fn = new CoveredCount(0, 0);
for (const detail of details) {
if ('branches' in detail) {
statements.total += 1;
statements.covered += detail.executionCount > 0 ? 1 : 0;
for (const branch of detail.branches) {
branches.total += 1;
branches.covered += branch.executionCount > 0 ? 1 : 0;
}
} else {
fn.total += 1;
fn.covered += detail.executionCount > 0 ? 1 : 0;
}
}
const coverage = new FileCoverage(
uri,
statements,
branches.total > 0 ? branches : undefined,
fn.total > 0 ? fn : undefined,
);
coverage.detailedCoverage = details;
return coverage;
}
detailedCoverage?: vscode.DetailedCoverage[];
constructor(
public readonly uri: vscode.Uri,
public statementCoverage: vscode.CoveredCount,
public branchCoverage?: vscode.CoveredCount,
public functionCoverage?: vscode.CoveredCount,
) { }
}
@es5ClassCompat
export class StatementCoverage implements vscode.StatementCoverage {
constructor(
public executionCount: number,
public location: Position | Range,
public branches: vscode.BranchCoverage[] = [],
) { }
}
@es5ClassCompat
export class BranchCoverage implements vscode.BranchCoverage {
constructor(
public executionCount: number,
public location: Position | Range,
) { }
}
@es5ClassCompat
export class FunctionCoverage implements vscode.FunctionCoverage {
constructor(
public executionCount: number,
public location: Position | Range,
) { }
}
//#endregion
export enum ExternalUriOpenerPriority {
None = 0,
Option = 1,