diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 4807eeb41a1..5f30809d18c 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2835,4 +2835,195 @@ declare module 'vscode' { } //#endregion + + //#region https://github.com/microsoft/vscode/issues/123713 @connor4312 + export interface TestRun { + /** + * Test coverage provider for this result. An extension can defer setting + * this until after a run is complete and coverage is available. + */ + coverageProvider?: TestCoverageProvider + // ... + } + + /** + * Provides information about test coverage for a test result. + * Methods on the provider will not be called until the test run is complete + */ + export interface TestCoverageProvider { + /** + * Returns coverage information for all files involved in the test run. + * @param token A cancellation token. + * @return Coverage metadata for all files involved in the test. + */ + provideFileCoverage(token: CancellationToken): ProviderResult; + + /** + * Give a FileCoverage to fill in more data, namely {@link FileCoverage.detailedCoverage}. + * The editor will only resolve a FileCoverage once, and onyl if detailedCoverage + * is undefined. + * + * @param coverage A coverage object obtained from {@link provideFileCoverage} + * @param token A cancellation token. + * @return The resolved file coverage, or a thenable that resolves to one. It + * is OK to return the given `coverage`. When no result is returned, the + * given `coverage` will be used. + */ + resolveFileCoverage?(coverage: T, token: CancellationToken): ProviderResult; + } + + /** + * A class that contains information about a covered resource. A count can + * be give for lines, branches, and functions in a file. + */ + export class CoveredCount { + /** + * Number of items covered in the file. + */ + covered: number; + /** + * Total number of covered items in the file. + */ + total: number; + + /** + * @param covered Value for {@link CovereredCount.covered} + * @param total Value for {@link CovereredCount.total} + */ + constructor(covered: number, total: number); + } + + /** + * Contains coverage metadata for a file. + */ + export class FileCoverage { + /** + * File URI. + */ + readonly uri: Uri; + + /** + * Statement coverage information. If the reporter does not provide statement + * coverage information, this can instead be used to represent line coverage. + */ + statementCoverage: CoveredCount; + + /** + * Branch coverage information. + */ + branchCoverage?: CoveredCount; + + /** + * Function coverage information. + */ + functionCoverage?: CoveredCount; + + /** + * Detailed, per-statement coverage. If this is undefined, the editor will + * call {@link TestCoverageProvider.resolveFileCoverage} when necessary. + */ + detailedCoverage?: DetailedCoverage[]; + + /** + * Creates a {@link FileCoverage} instance with counts filled in from + * the coverage details. + * @param uri Covered file URI + * @param detailed Detailed coverage information + */ + static fromDetails(uri: Uri, details: readonly DetailedCoverage[]): FileCoverage; + + /** + * @param uri Covered file URI + * @param statementCoverage Statement coverage information. If the reporter + * does not provide statement coverage information, this can instead be + * used to represent line coverage. + * @param branchCoverage Branch coverage information + * @param functionCoverage Function coverage information + */ + constructor( + uri: Uri, + statementCoverage: CoveredCount, + branchCoverage?: CoveredCount, + functionCoverage?: CoveredCount, + ); + } + + /** + * Contains coverage information for a single statement or line. + */ + export class StatementCoverage { + /** + * The number of times this statement was executed. If zero, the + * statement will be marked as un-covered. + */ + executionCount: number; + + /** + * Statement location. + */ + location: Position | Range; + + /** + * Coverage from branches of this line or statement. If it's not a + * conditional, this will be empty. + */ + branches: BranchCoverage[]; + + /** + * @param location The statement position. + * @param executionCount The number of times this statement was + * executed. If zero, the statement will be marked as un-covered. + * @param branches Coverage from branches of this line. If it's not a + * conditional, this should be omitted. + */ + constructor(executionCount: number, location: Position | Range, branches?: BranchCoverage[]); + } + + /** + * Contains coverage information for a branch of a {@link StatementCoverage}. + */ + export class BranchCoverage { + /** + * The number of times this branch was executed. If zero, the + * branch will be marked as un-covered. + */ + executionCount: number; + + /** + * Branch location. + */ + location?: Position | Range; + + /** + * @param executionCount The number of times this branch was executed. + * @param location The branch position. + */ + constructor(executionCount: number, location?: Position | Range); + } + + /** + * Contains coverage information for a function or method. + */ + export class FunctionCoverage { + /** + * The number of times this function was executed. If zero, the + * function will be marked as un-covered. + */ + executionCount: number; + + /** + * Function location. + */ + location: Position | Range; + + /** + * @param executionCount The number of times this function was executed. + * @param location The function position. + */ + constructor(executionCount: number, location: Position | Range); + } + + export type DetailedCoverage = StatementCoverage | FunctionCoverage; + + //#endregion } diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index 94735126579..b440572b157 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -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).value = new TestCoverage({ + provideFileCoverage: token => this.proxy.$provideFileCoverage(runId, taskId, token), + resolveFileCoverage: (i, token) => this.proxy.$resolveFileCoverage(runId, taskId, i, token), + }); + }); + } + /** * @inheritdoc */ diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a79c2a62bde..4c2b5d1f832 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -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 }; }; diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index ac10e361c32..b2e505dd965 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -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; $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; + /** Requests file coverage for a test run. Errors if not available. */ + $provideFileCoverage(runId: string, taskId: string, token: CancellationToken): Promise; + /** + * 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; } 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. */ diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index c80485b1c40..cb6fb32b626 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -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 { + 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 { + 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(); + private readonly tasks = new Map(); private readonly sharedTestIds = new Set(); private readonly cts: CancellationTokenSource; private readonly endEmitter = this._register(new Emitter()); @@ -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; + + 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 { + 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 { + 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; 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, 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 }); } diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index b75f1888bca..998e59b2adf 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -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 { diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 19d2c268e7b..704417acf9c 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -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, diff --git a/src/vs/workbench/contrib/testing/common/testCollection.ts b/src/vs/workbench/contrib/testing/common/testCollection.ts index db83e25a725..2821c77ab34 100644 --- a/src/vs/workbench/contrib/testing/common/testCollection.ts +++ b/src/vs/workbench/contrib/testing/common/testCollection.ts @@ -6,6 +6,7 @@ import { IMarkdownString } from 'vs/base/common/htmlContent'; import { MarshalledId } from 'vs/base/common/marshalling'; import { URI } from 'vs/base/common/uri'; +import { IPosition } from 'vs/editor/common/core/position'; import { IRange, Range } from 'vs/editor/common/core/range'; import { TestMessageSeverity, TestResultState } from 'vs/workbench/api/common/extHostTypes'; @@ -180,6 +181,48 @@ export interface ISerializedTestResults { name: string; } +export interface ITestCoverage { + files: IFileCoverage[]; +} + +export interface ICoveredCount { + covered: number; + total: number; +} + +export interface IFileCoverage { + uri: URI; + statement: ICoveredCount; + branch?: ICoveredCount; + function?: ICoveredCount; + details?: CoverageDetails[]; +} + +export const enum DetailType { + Function, + Statement, +} + +export type CoverageDetails = IFunctionCoverage | IStatementCoverage; + +export interface IBranchCoverage { + count: number; + location?: IRange | IPosition; +} + +export interface IFunctionCoverage { + type: DetailType.Function; + count: number; + location?: IRange | IPosition; +} + +export interface IStatementCoverage { + type: DetailType.Statement; + count: number; + location: IRange | IPosition; + branches?: IBranchCoverage[]; +} + export const enum TestDiffOpType { /** Adds a new test (with children) */ Add, diff --git a/src/vs/workbench/contrib/testing/common/testCoverage.ts b/src/vs/workbench/contrib/testing/common/testCoverage.ts new file mode 100644 index 00000000000..2abedf8ca9a --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/testCoverage.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { CancellationToken } from 'vs/base/common/cancellation'; +import { URI } from 'vs/base/common/uri'; +import { IFileCoverage, CoverageDetails, ICoveredCount } from 'vs/workbench/contrib/testing/common/testCollection'; + +export interface ICoverageAccessor { + provideFileCoverage: (token: CancellationToken) => Promise, + resolveFileCoverage: (fileIndex: number, token: CancellationToken) => Promise, +} + +/** + * Class that exposese coverage information for a run. + */ +export class TestCoverage { + private fileCoverage?: Promise; + + constructor(private readonly accessor: ICoverageAccessor) { } + + /** + * Gets coverage information for all files. + */ + public async getAllFiles(token = CancellationToken.None) { + if (!this.fileCoverage) { + this.fileCoverage = this.accessor.provideFileCoverage(token); + } + + try { + return await this.fileCoverage; + } catch (e) { + this.fileCoverage = undefined; + throw e; + } + } + + /** + * Gets coverage information for a specific file. + */ + public async getUri(uri: URI, token = CancellationToken.None) { + const files = await this.getAllFiles(token); + return files.find(f => f.uri.toString() === uri.toString()); + } +} + +export class FileCoverage { + private _details?: CoverageDetails[] | Promise; + public readonly uri: URI; + public readonly statement: ICoveredCount; + public readonly branch?: ICoveredCount; + public readonly function?: ICoveredCount; + + /** Gets the total coverage percent based on information provided. */ + public get tpc() { + let numerator = this.statement.covered; + let denominator = this.statement.total; + + if (this.branch) { + numerator += this.branch.covered; + denominator += this.branch.total; + } + + if (this.function) { + numerator += this.function.covered; + denominator += this.function.total; + } + + return denominator === 0 ? 1 : numerator / denominator; + } + + constructor(coverage: IFileCoverage, private readonly index: number, private readonly accessor: ICoverageAccessor) { + this.uri = URI.revive(coverage.uri); + this.statement = coverage.statement; + this.branch = coverage.branch; + this.function = coverage.branch; + this._details = coverage.details; + } + + /** + * Gets per-line coverage details. + */ + public async details(token = CancellationToken.None) { + if (!this._details) { + this._details = this.accessor.resolveFileCoverage(this.index, token); + } + + try { + return await this._details; + } catch (e) { + this._details = undefined; + throw e; + } + } +} diff --git a/src/vs/workbench/contrib/testing/common/testResult.ts b/src/vs/workbench/contrib/testing/common/testResult.ts index 523cf7cea50..e5fceb26238 100644 --- a/src/vs/workbench/contrib/testing/common/testResult.ts +++ b/src/vs/workbench/contrib/testing/common/testResult.ts @@ -12,9 +12,18 @@ import { Range } from 'vs/editor/common/core/range'; import { localize } from 'vs/nls'; import { TestResultState } from 'vs/workbench/api/common/extHostTypes'; import { IComputedStateAccessor, refreshComputedState } from 'vs/workbench/contrib/testing/common/getComputedState'; +import { IObservableValue, MutableObservableValue, staticObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; import { ExtensionRunTestsRequest, ISerializedTestResults, ITestItem, ITestMessage, ITestRunTask, ITestTaskState, RunTestsRequest, TestIdPath, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; import { maxPriority, statesInOrder } from 'vs/workbench/contrib/testing/common/testingStates'; +export interface ITestRunTaskWithCoverage extends ITestRunTask { + /** + * Contains test coverage for the result, if it's available. + */ + readonly coverage: IObservableValue; +} + export interface ITestResult { /** * Count of the number of tests in each run state. @@ -50,7 +59,7 @@ export interface ITestResult { /** * List of this result's subtasks. */ - tasks: ReadonlyArray; + tasks: ReadonlyArray; /** * Gets the state of the test by its extension-assigned ID. @@ -243,7 +252,7 @@ export class LiveTestResult implements ITestResult { public readonly onChange = this.changeEmitter.event; public readonly onComplete = this.completeEmitter.event; - public readonly tasks: ITestRunTask[] = []; + public readonly tasks: ITestRunTaskWithCoverage[] = []; public readonly name = localize('runFinished', 'Test run at {0}', new Date().toLocaleString()); /** @@ -323,7 +332,7 @@ export class LiveTestResult implements ITestResult { */ public addTask(task: ITestRunTask) { const index = this.tasks.length; - this.tasks.push(task); + this.tasks.push({ ...task, coverage: new MutableObservableValue(undefined) }); for (const test of this.tests) { test.tasks.push({ duration: undefined, messages: [], state: TestResultState.Unset }); @@ -557,7 +566,7 @@ export class HydratedTestResult implements ITestResult { /** * @inheritdoc */ - public readonly tasks: ITestRunTask[]; + public readonly tasks: ITestRunTaskWithCoverage[]; /** * @inheritdoc @@ -580,7 +589,7 @@ export class HydratedTestResult implements ITestResult { ) { this.id = serialized.id; this.completedAt = serialized.completedAt; - this.tasks = serialized.tasks; + this.tasks = serialized.tasks.map(task => ({ ...task, coverage: staticObservableValue(undefined) })); this.name = serialized.name; for (const item of serialized.items) {