diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index fa5376ca4b1b4..c9976ce372711 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -7,7 +7,7 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; import { Disposable, DisposableStore, IDisposable, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ISettableObservable } from 'vs/base/common/observable'; +import { ISettableObservable, transaction } from 'vs/base/common/observable'; import { WellDefinedPrefixTree } from 'vs/base/common/prefixTree'; import { URI } from 'vs/base/common/uri'; import { Range } from 'vs/editor/common/core/range'; @@ -58,10 +58,17 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh })); this._register(resultService.onResultsChanged(evt => { - const results = 'completed' in evt ? evt.completed : ('inserted' in evt ? evt.inserted : undefined); - const serialized = results?.toJSONWithMessages(); - if (serialized) { - this.proxy.$publishTestResults([serialized]); + if ('completed' in evt) { + const serialized = evt.completed.toJSONWithMessages(); + if (serialized) { + this.proxy.$publishTestResults([serialized]); + } + } else if ('removed' in evt) { + evt.removed.forEach(r => { + if (r instanceof LiveTestResult) { + this.proxy.$disposeRun(r.id); + } + }); } })); } @@ -121,21 +128,28 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh /** * @inheritdoc */ - $signalCoverageAvailable(runId: string, taskId: string, available: boolean): void { + $appendCoverage(runId: string, taskId: string, coverage: IFileCoverage.Serialized): void { this.withLiveRun(runId, run => { const task = run.tasks.find(t => t.id === taskId); if (!task) { return; } - const fn = available ? ((token: CancellationToken) => TestCoverage.load(taskId, { - provideFileCoverage: async token => await this.proxy.$provideFileCoverage(runId, taskId, token) - .then(c => c.map(u => IFileCoverage.deserialize(this.uriIdentityService, u))), - resolveFileCoverage: (i, token) => this.proxy.$resolveFileCoverage(runId, taskId, i, token) - .then(d => d.map(CoverageDetails.deserialize)), - }, this.uriIdentityService, token)) : undefined; - - (task.coverage as ISettableObservable Promise)>).set(fn, undefined); + const deserialized = IFileCoverage.deserialize(this.uriIdentityService, coverage); + + transaction(tx => { + let value = task.coverage.read(undefined); + if (!value) { + value = new TestCoverage(taskId, this.uriIdentityService, { + getCoverageDetails: (id, token) => this.proxy.$getCoverageDetails(id, token) + .then(r => r.map(CoverageDetails.deserialize)), + }); + value.append(deserialized, tx); + (task.coverage as ISettableObservable).set(value, tx); + } else { + value.append(deserialized, tx); + } + }); }); } diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 664d03ac70c52..6f0a050694d1d 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2681,13 +2681,10 @@ export interface ExtHostTestingShape { $publishTestResults(results: ISerializedTestResults[]): void; /** Expands a test item's children, by the given number of levels. */ $expandTest(testId: string, 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; + /** Requests coverage details for a test run. Errors if not available. */ + $getCoverageDetails(coverageId: string, token: CancellationToken): Promise; + /** Disposes resources associated with a test run. */ + $disposeRun(runId: string): void; /** Configures a test run config. */ $configureRunProfile(controllerId: string, configId: number): void; /** Asks the controller to refresh its tests */ @@ -2758,7 +2755,7 @@ export interface MainThreadTestingShape { /** Appends raw output to the test run.. */ $appendOutputToRun(runId: string, taskId: string, output: VSBuffer, location?: ILocationDto, testId?: string): void; /** Triggered when coverage is added to test results. */ - $signalCoverageAvailable(runId: string, taskId: string, available: boolean): void; + $appendCoverage(runId: string, taskId: string, coverage: IFileCoverage.Serialized): 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 dcae2c2c45cec..c15138fe33b16 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -27,7 +27,7 @@ import { TestRunProfileKind, TestRunRequest } from 'vs/workbench/api/common/extH import { TestCommandId } from 'vs/workbench/contrib/testing/common/constants'; import { TestId, TestIdPathParts, TestPosition } from 'vs/workbench/contrib/testing/common/testId'; import { InvalidTestItemError } from 'vs/workbench/contrib/testing/common/testItemCollection'; -import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, ITestRunProfile, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, KEEP_N_LAST_COVERAGE_REPORTS, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes'; +import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, ITestRunProfile, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes'; import { checkProposedApiEnabled } from 'vs/workbench/services/extensions/common/extensions'; import type * as vscode from 'vscode'; @@ -236,19 +236,16 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { /** * @inheritdoc */ - async $provideFileCoverage(runId: string, taskId: string, token: CancellationToken): Promise { - const coverage = this.runTracker.getCoverageReport(runId, taskId); - const fileCoverage = await coverage?.provideFileCoverage(token); - return fileCoverage ?? []; + async $getCoverageDetails(coverageId: string, token: CancellationToken): Promise { + const details = await this.runTracker.getCoverageDetails(coverageId, token); + return details?.map(Convert.TestCoverage.fromDetails); } /** * @inheritdoc */ - async $resolveFileCoverage(runId: string, taskId: string, fileIndex: number, token: CancellationToken): Promise { - const coverage = this.runTracker.getCoverageReport(runId, taskId); - const details = await coverage?.resolveFileCoverage(fileIndex, token); - return details ?? []; + async $disposeRun(runId: string) { + this.runTracker.disposeTestRun(runId); } /** @inheritdoc */ @@ -389,6 +386,7 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { publicReq, TestRunDto.fromInternal(req, lookup.collection), extension, + profile, token, ); @@ -402,8 +400,6 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { if (tracker.hasRunningTasks && !token.isCancellationRequested) { await Event.toPromise(tracker.onEnd); } - - tracker.dispose(); } } } @@ -434,16 +430,16 @@ const enum TestRunTrackerState { class TestRunTracker extends Disposable { private state = TestRunTrackerState.Running; + private running = 0; private readonly tasks = new Map(); private readonly sharedTestIds = new Set(); private readonly cts: CancellationTokenSource; private readonly endEmitter = this._register(new Emitter()); - private readonly coverageEmitter = this._register(new Emitter<{ runId: string; taskId: string; coverage: TestRunCoverageBearer | undefined }>()); - - /** - * Fired when a coverage provider is added or removed from a task. - */ - public readonly onDidCoverage = this.coverageEmitter.event; + private readonly onDidDispose: Event; + private readonly publishedCoverage = new Map Thenable; + }>(); /** * Fires when a test ends, and no more tests are left running. @@ -454,7 +450,7 @@ class TestRunTracker extends Disposable { * Gets whether there are any tests running. */ public get hasRunningTasks() { - return this.tasks.size > 0; + return this.running > 0; } /** @@ -469,6 +465,7 @@ class TestRunTracker extends Disposable { private readonly proxy: MainThreadTestingShape, private readonly extension: IRelaxedExtensionDescription, private readonly logService: ILogService, + private readonly profile: vscode.TestRunProfile | undefined, parentToken?: CancellationToken, ) { super(); @@ -476,6 +473,13 @@ class TestRunTracker extends Disposable { const forciblyEnd = this._register(new RunOnceScheduler(() => this.forciblyEndTasks(), RUN_CANCEL_DEADLINE)); this._register(this.cts.token.onCancellationRequested(() => forciblyEnd.schedule())); + + const didDisposeEmitter = new Emitter(); + this.onDidDispose = didDisposeEmitter.event; + this._register(toDisposable(() => { + didDisposeEmitter.fire(); + didDisposeEmitter.dispose(); + })); } /** Requests cancellation of the run. On the second call, forces cancellation. */ @@ -488,14 +492,32 @@ class TestRunTracker extends Disposable { } } + /** Gets details for a previously-emitted coverage object. */ + public getCoverageDetails(id: string, token: CancellationToken) { + const [, taskId, covId] = TestId.fromString(id).path; /** runId, taskId, URI */ + const obj = this.publishedCoverage.get(covId); + if (!obj) { + return []; + } + + if (obj.backCompatResolve) { + return obj.backCompatResolve(token); + } + + const task = this.tasks.get(taskId); + if (!task) { + throw new Error('unreachable: run task was not found'); + } + + return this.profile?.loadDetailedCoverage?.(task.run, obj.coverage, token) ?? []; + } + /** Creates the public test run interface to give to extensions. */ public createRun(name: string | undefined): vscode.TestRun { const runId = this.dto.id; const ctrlId = this.dto.controllerId; const taskId = generateUuid(); const extension = this.extension; - const coverageEmitter = this.coverageEmitter; - let coverage: TestRunCoverageBearer | undefined; const guardTestMutation = (fn: (test: vscode.TestItem, ...args: Args) => void) => (test: vscode.TestItem, ...args: Args) => { @@ -527,18 +549,45 @@ class TestRunTracker extends Disposable { this.proxy.$appendTestMessagesInRun(runId, taskId, TestId.fromExtHostTestItem(test, ctrlId).toString(), converted); }; + const addCoverage = (coverage: vscode.FileCoverage, backCompatResolve?: (token: vscode.CancellationToken) => Thenable) => { + const uriStr = coverage.uri.toString(); + const id = new TestId([runId, taskId, uriStr]).toString(); + this.publishedCoverage.set(uriStr, { coverage, backCompatResolve }); + this.proxy.$appendCoverage(runId, taskId, Convert.TestCoverage.fromFile(id, coverage)); + }; + + interface ICoverageProvider { + provideFileCoverage(token: CancellationToken): vscode.ProviderResult; + resolveFileCoverage?(coverage: vscode.FileCoverage, token: CancellationToken): vscode.ProviderResult; + } + let ended = false; - const run: vscode.TestRun = { + let coverageProvider: ICoverageProvider | undefined; + const run: vscode.TestRun & { coverageProvider?: ICoverageProvider } = { isPersisted: this.dto.isPersisted, token: this.cts.token, name, + onDidDispose: this.onDidDispose, + // todo@connor4312: back compat get coverageProvider() { - return coverage?.provider; + return coverageProvider; }, - set coverageProvider(provider) { + // todo@connor4312: back compat + set coverageProvider(provider: ICoverageProvider | undefined) { checkProposedApiEnabled(extension, 'testCoverage'); - coverage = provider && new TestRunCoverageBearer(provider); - coverageEmitter.fire({ taskId, runId, coverage }); + coverageProvider = provider; + if (provider) { + Promise.resolve(provider.provideFileCoverage(CancellationToken.None)).then(coverage => { + coverage?.forEach(c => addCoverage(c, provider.resolveFileCoverage && (async token => { + const r = await provider.resolveFileCoverage!(c, token); + return (r || c as any).detailedCoverage; + }))); + }); + } + }, + addCoverage: coverage => { + checkProposedApiEnabled(extension, 'testCoverage'); + addCoverage(coverage); }, //#region state mutation enqueued: guardTestMutation(test => { @@ -590,13 +639,13 @@ class TestRunTracker extends Disposable { ended = true; this.proxy.$finishedTestRunTask(runId, taskId); - this.tasks.delete(taskId); - if (!this.tasks.size) { + if (!--this.running) { this.markEnded(); } } }; + this.running++; this.tasks.set(taskId, { run }); this.proxy.$startedTestRunTask(runId, { id: taskId, name, running: true }); @@ -652,18 +701,13 @@ class TestRunTracker extends Disposable { } } -interface CoverageReportRecord { - runId: string; - coverage: Map; -} - /** * Queues runs for a single extension and provides the currently-executing * run so that `createTestRun` can be properly correlated. */ export class TestRunCoordinator { private readonly tracked = new Map(); - private readonly coverageReports: CoverageReportRecord[] = []; + private readonly trackedById = new Map(); public get trackers() { return this.tracked.values(); @@ -677,10 +721,23 @@ export class TestRunCoordinator { /** * Gets a coverage report for a given run and task ID. */ - public getCoverageReport(runId: string, taskId: string) { - return this.coverageReports - .find(r => r.runId === runId) - ?.coverage.get(taskId); + public getCoverageDetails(id: string, token: vscode.CancellationToken) { + const runId = TestId.root(id); + return this.trackedById.get(runId)?.getCoverageDetails(id, token) || []; + } + + /** + * Disposes the test run, called when the main thread is no longer interested + * in associated data. + */ + public disposeTestRun(runId: string) { + this.trackedById.get(runId)?.dispose(); + this.trackedById.delete(runId); + for (const [req, { id }] of this.tracked) { + if (id === runId) { + this.tracked.delete(req); + } + } } /** @@ -688,20 +745,15 @@ export class TestRunCoordinator { * `$startedExtensionTestRun` is not invoked. The run must eventually * be cancelled manually. */ - public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, extension: Readonly, token: CancellationToken) { - return this.getTracker(req, dto, extension, token); + public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, extension: Readonly, profile: vscode.TestRunProfile, token: CancellationToken) { + return this.getTracker(req, dto, extension, profile, token); } /** * Cancels an existing test run via its cancellation token. */ public cancelRunById(runId: string) { - for (const tracker of this.tracked.values()) { - if (tracker.id === runId) { - tracker.cancel(); - return; - } - } + this.trackedById.get(runId)?.cancel(); } /** @@ -713,7 +765,6 @@ export class TestRunCoordinator { } } - /** * Implements the public `createTestRun` API. */ @@ -737,37 +788,18 @@ export class TestRunCoordinator { persist }); - const tracker = this.getTracker(request, dto, extension); + const tracker = this.getTracker(request, dto, extension, request.profile); Event.once(tracker.onEnd)(() => { this.proxy.$finishedExtensionTestRun(dto.id); - tracker.dispose(); }); return tracker.createRun(name); } - private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, extension: IRelaxedExtensionDescription, token?: CancellationToken) { - const tracker = new TestRunTracker(dto, this.proxy, extension, this.logService, token); + private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, extension: IRelaxedExtensionDescription, profile: vscode.TestRunProfile | undefined, token?: CancellationToken) { + const tracker = new TestRunTracker(dto, this.proxy, extension, this.logService, profile, token); this.tracked.set(req, tracker); - - let coverageReports: CoverageReportRecord | undefined; - const coverageListener = tracker.onDidCoverage(({ runId, taskId, coverage }) => { - if (!coverageReports) { - coverageReports = { runId, coverage: new Map() }; - this.coverageReports.unshift(coverageReports); - if (this.coverageReports.length > KEEP_N_LAST_COVERAGE_REPORTS) { - this.coverageReports.pop(); - } - } - - coverageReports.coverage.set(taskId, coverage); - this.proxy.$signalCoverageAvailable(runId, taskId, !!coverage); - }); - - Event.once(tracker.onEnd)(() => { - this.tracked.delete(req); - coverageListener.dispose(); - }); + this.trackedById.set(tracker.id, tracker); return tracker; } } @@ -840,40 +872,6 @@ export class TestRunDto { } } -class TestRunCoverageBearer { - private fileCoverage?: Promise; - - constructor(public readonly provider: vscode.TestCoverageProvider) { } - - public async provideFileCoverage(token: CancellationToken): Promise { - if (!this.fileCoverage) { - this.fileCoverage = (async () => this.provider.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.provider || !fileCoverage || !file) { - return []; - } - - if (!file.detailedCoverage) { - file = fileCoverage[index] = await this.provider.resolveFileCoverage?.(file, token) ?? file; - } - - return file.detailedCoverage?.map(Convert.TestCoverage.fromDetailed) ?? []; - } -} - /** * @private */ diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 040e0f8b173e1..c997c0fb383f9 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -2023,7 +2023,7 @@ export namespace TestCoverage { return 'line' in location ? Position.from(location) : Range.from(location); } - export function fromDetailed(coverage: vscode.DetailedCoverage): CoverageDetails.Serialized { + export function fromDetails(coverage: vscode.FileCoverageDetail): CoverageDetails.Serialized { if ('branches' in coverage) { return { count: coverage.executed, @@ -2043,13 +2043,13 @@ export namespace TestCoverage { } } - export function fromFile(coverage: vscode.FileCoverage): IFileCoverage.Serialized { + export function fromFile(id: string, coverage: vscode.FileCoverage): IFileCoverage.Serialized { return { + id, uri: coverage.uri, statement: fromCoveredCount(coverage.statementCoverage), branch: coverage.branchCoverage && fromCoveredCount(coverage.branchCoverage), declaration: coverage.declarationCoverage && fromCoveredCount(coverage.declarationCoverage), - details: coverage.detailedCoverage?.map(fromDetailed), }; } } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index cd118265d5e8b..5b7fb23081d86 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -4037,7 +4037,7 @@ const validateCC = (cc?: vscode.CoveredCount) => { }; export class FileCoverage implements vscode.FileCoverage { - public static fromDetails(uri: vscode.Uri, details: vscode.DetailedCoverage[]): vscode.FileCoverage { + public static fromDetails(uri: vscode.Uri, details: vscode.FileCoverageDetail[]): vscode.FileCoverage { const statements = new CoveredCount(0, 0); const branches = new CoveredCount(0, 0); const decl = new CoveredCount(0, 0); @@ -4069,7 +4069,7 @@ export class FileCoverage implements vscode.FileCoverage { return coverage; } - detailedCoverage?: vscode.DetailedCoverage[]; + detailedCoverage?: vscode.FileCoverageDetail[]; constructor( public readonly uri: vscode.Uri, diff --git a/src/vs/workbench/api/test/browser/extHostTesting.test.ts b/src/vs/workbench/api/test/browser/extHostTesting.test.ts index f7e03c8d80fe2..e931132b918d3 100644 --- a/src/vs/workbench/api/test/browser/extHostTesting.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTesting.test.ts @@ -605,6 +605,12 @@ suite('ExtHost Testing', () => { let dto: TestRunDto; const ext: IRelaxedExtensionDescription = {} as any; + teardown(() => { + for (const { id } of c.trackers) { + c.disposeTestRun(id); + } + }); + setup(async () => { proxy = mockObject()(); cts = new CancellationTokenSource(); @@ -631,7 +637,7 @@ suite('ExtHost Testing', () => { }); test('tracks a run started from a main thread request', () => { - const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, cts.token)); + const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, configuration, cts.token)); assert.strictEqual(tracker.hasRunningTasks, false); const task1 = c.createTestRun(ext, 'ctrl', single, req, 'run1', true); @@ -656,7 +662,7 @@ suite('ExtHost Testing', () => { test('run cancel force ends after a timeout', () => { const clock = sinon.useFakeTimers(); try { - const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, cts.token)); + const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, configuration, cts.token)); const task = c.createTestRun(ext, 'ctrl', single, req, 'run1', true); const onEnded = sinon.stub(); ds.add(tracker.onEnd(onEnded)); @@ -681,7 +687,7 @@ suite('ExtHost Testing', () => { }); test('run cancel force ends on second cancellation request', () => { - const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, cts.token)); + const tracker = ds.add(c.prepareForMainThreadTestRun(req, dto, ext, configuration, cts.token)); const task = c.createTestRun(ext, 'ctrl', single, req, 'run1', true); const onEnded = sinon.stub(); ds.add(tracker.onEnd(onEnded)); diff --git a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts index d96710fbd0d00..6a11a647a6cf7 100644 --- a/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts +++ b/src/vs/workbench/contrib/testing/browser/codeCoverageDecorations.ts @@ -81,7 +81,13 @@ export class CodeCoverageDecorations extends Disposable implements IEditorContri return; } - return report.getUri(model.uri); + const file = report.getUri(model.uri); + if (file) { + return file; + } + + report.didAddCoverage.read(reader); // re-read if changes when there's no report + return undefined; }); this._register(autorun(reader => { diff --git a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts index 2451613400fa4..17cfc93ca4016 100644 --- a/src/vs/workbench/contrib/testing/browser/testCoverageView.ts +++ b/src/vs/workbench/contrib/testing/browser/testCoverageView.ts @@ -8,6 +8,7 @@ import { IIdentityProvider, IListVirtualDelegate } from 'vs/base/browser/ui/list import { ICompressedTreeElement, ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; import { ITreeNode, ITreeSorter } from 'vs/base/browser/ui/tree/tree'; +import { findLast } from 'vs/base/common/arraysFind'; import { assertNever } from 'vs/base/common/assert'; import { Codicon } from 'vs/base/common/codicons'; import { memoize } from 'vs/base/common/decorators'; @@ -42,6 +43,7 @@ import { IViewDescriptorService } from 'vs/workbench/common/views'; import { testingStatesToIcons, testingWasCovered } from 'vs/workbench/contrib/testing/browser/icons'; import { CoverageBarSource, ManagedTestCoverageBars } from 'vs/workbench/contrib/testing/browser/testCoverageBars'; import { TestCommandId, Testing } from 'vs/workbench/contrib/testing/common/constants'; +import { onObservableChange } from 'vs/workbench/contrib/testing/common/observableUtils'; import { ComputedFileCoverage, FileCoverage, TestCoverage, getTotalCoveragePercent } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; import { CoverageDetails, DetailType, ICoveredCount, IDeclarationCoverage, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes'; @@ -198,6 +200,7 @@ const shouldShowDeclDetailsOnExpand = (c: CoverageTreeElement): c is IPrefixTree class TestCoverageTree extends Disposable { private readonly tree: WorkbenchCompressibleObjectTree; + private readonly inputDisposables = this._register(new DisposableStore()); constructor( container: HTMLElement, @@ -294,6 +297,8 @@ class TestCoverageTree extends Disposable { } public setInput(coverage: TestCoverage) { + this.inputDisposables.clear(); + const files = []; for (let node of coverage.tree.nodes) { // when showing initial children, only show from the first file or tee @@ -315,6 +320,17 @@ class TestCoverageTree extends Disposable { }; }; + this.inputDisposables.add(onObservableChange(coverage.didAddCoverage, nodes => { + const toRender = findLast(nodes, n => this.tree.hasElement(n)); + if (toRender) { + this.tree.setChildren( + toRender, + Iterable.map(toRender.children?.values() || [], toChild), + { diffIdentityProvider: { getId: el => (el as TestCoverageFileNode).value!.id } } + ); + } + })); + this.tree.setChildren(null, Iterable.map(files, toChild)); } @@ -416,6 +432,7 @@ interface FileTemplateData { container: HTMLElement; bars: ManagedTestCoverageBars; templateDisposables: DisposableStore; + elementsDisposables: DisposableStore; label: IResourceLabel; } @@ -440,6 +457,7 @@ class FileCoverageRenderer implements ICompressibleTreeRenderer basenameOrAuthority((e as TestCoverageFileNode).value!.uri)) : basenameOrAuthority(file.uri); + templateData.elementsDisposables.add(autorun(reader => { + stat.value?.didChange.read(reader); + templateData.bars.setCoverageInfo(file); + })); templateData.bars.setCoverageInfo(file); templateData.label.setResource({ resource: file.uri, name }, { diff --git a/src/vs/workbench/contrib/testing/common/observableUtils.ts b/src/vs/workbench/contrib/testing/common/observableUtils.ts new file mode 100644 index 0000000000000..26c6c087d7891 --- /dev/null +++ b/src/vs/workbench/contrib/testing/common/observableUtils.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDisposable } from 'vs/base/common/lifecycle'; +import { IObservable, IObserver } from 'vs/base/common/observable'; + +export function onObservableChange(observable: IObservable, callback: (value: T) => void): IDisposable { + const o: IObserver = { + beginUpdate() { }, + endUpdate() { }, + handlePossibleChange(observable) { + observable.reportChanges(); + }, + handleChange(_observable: IObservable, change: TChange) { + callback(change as any as T); + } + }; + + observable.addObserver(o); + return { + dispose() { + observable.removeObserver(o); + } + }; +} diff --git a/src/vs/workbench/contrib/testing/common/testCoverage.ts b/src/vs/workbench/contrib/testing/common/testCoverage.ts index 05d532263da7d..9f6de896f2d1e 100644 --- a/src/vs/workbench/contrib/testing/common/testCoverage.ts +++ b/src/vs/workbench/contrib/testing/common/testCoverage.ts @@ -5,43 +5,79 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { ResourceMap } from 'vs/base/common/map'; +import { deepClone } from 'vs/base/common/objects'; +import { ITransaction, observableSignal } from 'vs/base/common/observable'; import { IPrefixTreeNode, WellDefinedPrefixTree } from 'vs/base/common/prefixTree'; import { URI } from 'vs/base/common/uri'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { CoverageDetails, ICoveredCount, IFileCoverage } from 'vs/workbench/contrib/testing/common/testTypes'; export interface ICoverageAccessor { - provideFileCoverage: (token: CancellationToken) => Promise; - resolveFileCoverage: (fileIndex: number, token: CancellationToken) => Promise; + getCoverageDetails: (id: string, token: CancellationToken) => Promise; } +let incId = 0; + /** * Class that exposese coverage information for a run. */ export class TestCoverage { - private _tree?: WellDefinedPrefixTree; - - public static async load(taskId: string, accessor: ICoverageAccessor, uriIdentityService: IUriIdentityService, token: CancellationToken) { - const files = await accessor.provideFileCoverage(token); - const map = new ResourceMap(); - for (const [i, file] of files.entries()) { - map.set(file.uri, new FileCoverage(file, i, accessor)); - } - return new TestCoverage(taskId, map, uriIdentityService); - } - - public get tree() { - return this._tree ??= this.buildCoverageTree(); - } + private readonly fileCoverage = new ResourceMap(); + public readonly didAddCoverage = observableSignal[]>(this); + public readonly tree = new WellDefinedPrefixTree(); public readonly associatedData = new Map(); constructor( public readonly fromTaskId: string, - private readonly fileCoverage: ResourceMap, private readonly uriIdentityService: IUriIdentityService, + private readonly accessor: ICoverageAccessor, ) { } + public append(rawCoverage: IFileCoverage, tx: ITransaction | undefined) { + const coverage = new FileCoverage(rawCoverage, this.accessor); + const previous = this.getComputedForUri(coverage.uri); + const applyDelta = (kind: 'statement' | 'branch' | 'declaration', node: ComputedFileCoverage) => { + if (!node[kind]) { + if (coverage[kind]) { + node[kind] = { ...coverage[kind]! }; + } + } else { + node[kind]!.covered += (coverage[kind]?.covered || 0) - (previous?.[kind]?.covered || 0); + node[kind]!.total += (coverage[kind]?.total || 0) - (previous?.[kind]?.total || 0); + } + }; + + // We insert using the non-canonical path to normalize for casing differences + // between URIs, but when inserting an intermediate node always use 'a' canonical + // version. + const canonical = [...this.treePathForUri(coverage.uri, /* canonical = */ true)]; + const chain: IPrefixTreeNode[] = []; + this.tree.insert(this.treePathForUri(coverage.uri, /* canonical = */ false), coverage, node => { + chain.push(node); + + if (chain.length === canonical.length - 1) { + node.value = coverage; + } else if (!node.value) { + // clone because later intersertions can modify the counts: + const intermediate = deepClone(rawCoverage); + intermediate.id = String(incId++); + intermediate.uri = this.treePathToUri(canonical.slice(0, chain.length)); + node.value = new ComputedFileCoverage(intermediate); + } else { + applyDelta('statement', node.value); + applyDelta('branch', node.value); + applyDelta('declaration', node.value); + node.value.didChange.trigger(tx); + } + }); + + this.fileCoverage.set(coverage.uri, coverage); + if (chain) { + this.didAddCoverage.trigger(tx, chain); + } + } + /** * Gets coverage information for all files. */ @@ -64,54 +100,6 @@ export class TestCoverage { return this.tree.find(this.treePathForUri(uri, /* canonical = */ false)); } - private buildCoverageTree() { - const tree = new WellDefinedPrefixTree(); - const nodeCanonicalSegments = new Map, string>(); - - // 1. Initial iteration. We insert based on the case-erased file path, and - // then tag the nodes with their 'canonical' path segment preserving the - // original casing we were given, to avoid #200604 - for (const file of this.fileCoverage.values()) { - const keyPath = this.treePathForUri(file.uri, /* canonical = */ false); - const canonicalPath = this.treePathForUri(file.uri, /* canonical = */ true); - tree.insert(keyPath, file, node => { - nodeCanonicalSegments.set(node, canonicalPath.next().value as string); - }); - } - - // 2. Depth-first iteration to create computed nodes - const calculateComputed = (path: string[], node: IPrefixTreeNode): AbstractFileCoverage => { - if (node.value) { - return node.value; - } - - const fileCoverage: IFileCoverage = { - uri: this.treePathToUri(path), - statement: ICoveredCount.empty(), - }; - - if (node.children) { - for (const [prefix, child] of node.children) { - path.push(nodeCanonicalSegments.get(child) || prefix); - const v = calculateComputed(path, child); - path.pop(); - - ICoveredCount.sum(fileCoverage.statement, v.statement); - if (v.branch) { ICoveredCount.sum(fileCoverage.branch ??= ICoveredCount.empty(), v.branch); } - if (v.declaration) { ICoveredCount.sum(fileCoverage.declaration ??= ICoveredCount.empty(), v.declaration); } - } - } - - return node.value = new ComputedFileCoverage(fileCoverage); - }; - - for (const node of tree.nodes) { - calculateComputed([], node); - } - - return tree; - } - private *treePathForUri(uri: URI, canconicalPath: boolean) { yield uri.scheme; yield uri.authority; @@ -143,10 +131,12 @@ export const getTotalCoveragePercent = (statement: ICoveredCount, branch: ICover }; export abstract class AbstractFileCoverage { + public readonly id: string; public readonly uri: URI; - public readonly statement: ICoveredCount; - public readonly branch?: ICoveredCount; - public readonly declaration?: ICoveredCount; + public statement: ICoveredCount; + public branch?: ICoveredCount; + public declaration?: ICoveredCount; + public readonly didChange = observableSignal(this); /** * Gets the total coverage percent based on information provided. @@ -157,6 +147,7 @@ export abstract class AbstractFileCoverage { } constructor(coverage: IFileCoverage) { + this.id = coverage.id; this.uri = coverage.uri; this.statement = coverage.statement; this.branch = coverage.branch; @@ -171,7 +162,7 @@ export abstract class AbstractFileCoverage { export class ComputedFileCoverage extends AbstractFileCoverage { } export class FileCoverage extends AbstractFileCoverage { - private _details?: CoverageDetails[] | Promise; + private _details?: Promise; private resolved?: boolean; /** Gets whether details are synchronously available */ @@ -179,16 +170,15 @@ export class FileCoverage extends AbstractFileCoverage { return this._details instanceof Array || this.resolved; } - constructor(coverage: IFileCoverage, private readonly index: number, private readonly accessor: ICoverageAccessor) { + constructor(coverage: IFileCoverage, private readonly accessor: ICoverageAccessor) { super(coverage); - this._details = coverage.details; } /** * Gets per-line coverage details. */ public async details(token = CancellationToken.None) { - this._details ??= this.accessor.resolveFileCoverage(this.index, token); + this._details ??= this.accessor.getCoverageDetails(this.id, token); try { const d = await this._details; diff --git a/src/vs/workbench/contrib/testing/common/testCoverageService.ts b/src/vs/workbench/contrib/testing/common/testCoverageService.ts index 57c0832fdfdea..0bf62937458f3 100644 --- a/src/vs/workbench/contrib/testing/common/testCoverageService.ts +++ b/src/vs/workbench/contrib/testing/common/testCoverageService.ts @@ -6,16 +6,14 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { IObservable, observableValue } from 'vs/base/common/observable'; -import { localize } from 'vs/nls'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; -import { INotificationService } from 'vs/platform/notification/common/notification'; -import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; import { Testing } from 'vs/workbench/contrib/testing/common/constants'; import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; import { ITestRunTaskResults } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; +import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; export const ITestCoverageService = createDecorator('testCoverageService'); @@ -50,7 +48,6 @@ export class TestCoverageService extends Disposable implements ITestCoverageServ @IContextKeyService contextKeyService: IContextKeyService, @ITestResultService resultService: ITestResultService, @IViewsService private readonly viewsService: IViewsService, - @INotificationService private readonly notificationService: INotificationService, ) { super(); this._isOpenKey = TestingContextKeys.isTestCoverageOpen.bindTo(contextKeyService); @@ -76,21 +73,13 @@ export class TestCoverageService extends Disposable implements ITestCoverageServ public async openCoverage(task: ITestRunTaskResults, focus = true) { this.lastOpenCts.value?.cancel(); const cts = this.lastOpenCts.value = new CancellationTokenSource(); - const getCoverage = task.coverage.get(); - if (!getCoverage) { + const coverage = task.coverage.get(); + if (!coverage) { return; } - try { - const coverage = await getCoverage(cts.token); - this.selected.set(coverage, undefined); - this._isOpenKey.set(true); - } catch (e) { - if (!cts.token.isCancellationRequested) { - this.notificationService.error(localize('testCoverageError', 'Failed to load test coverage: {0}', String(e))); - } - return; - } + this.selected.set(coverage, undefined); + this._isOpenKey.set(true); if (focus && !cts.token.isCancellationRequested) { this.viewsService.openView(Testing.CoverageViewId, true); diff --git a/src/vs/workbench/contrib/testing/common/testResult.ts b/src/vs/workbench/contrib/testing/common/testResult.ts index e6056621bf2b7..fc178c0d7d3d4 100644 --- a/src/vs/workbench/contrib/testing/common/testResult.ts +++ b/src/vs/workbench/contrib/testing/common/testResult.ts @@ -5,7 +5,6 @@ import { DeferredPromise } from 'vs/base/common/async'; import { VSBuffer } from 'vs/base/common/buffer'; -import { CancellationToken } from 'vs/base/common/cancellation'; import { Emitter, Event } from 'vs/base/common/event'; import { Lazy } from 'vs/base/common/lazy'; import { Disposable } from 'vs/base/common/lifecycle'; @@ -25,7 +24,7 @@ export interface ITestRunTaskResults extends ITestRunTask { /** * Contains test coverage for the result, if it's available. */ - readonly coverage: IObservable Promise)>; + readonly coverage: IObservable; /** * Messages from the task not associated with any specific test. diff --git a/src/vs/workbench/contrib/testing/common/testTypes.ts b/src/vs/workbench/contrib/testing/common/testTypes.ts index c760e14bef2ce..620bba113d63e 100644 --- a/src/vs/workbench/contrib/testing/common/testTypes.ts +++ b/src/vs/workbench/contrib/testing/common/testTypes.ts @@ -556,36 +556,35 @@ export namespace ICoveredCount { } export interface IFileCoverage { + id: string; uri: URI; statement: ICoveredCount; branch?: ICoveredCount; declaration?: ICoveredCount; - details?: CoverageDetails[]; } - export namespace IFileCoverage { export interface Serialized { + id: string; uri: UriComponents; statement: ICoveredCount; branch?: ICoveredCount; declaration?: ICoveredCount; - details?: CoverageDetails.Serialized[]; } export const serialize = (original: Readonly): Serialized => ({ + id: original.id, statement: original.statement, branch: original.branch, declaration: original.declaration, - details: original.details?.map(CoverageDetails.serialize), uri: original.uri.toJSON(), }); export const deserialize = (uriIdentity: ITestUriCanonicalizer, serialized: Serialized): IFileCoverage => ({ + id: serialized.id, statement: serialized.statement, branch: serialized.branch, declaration: serialized.declaration, - details: serialized.details?.map(CoverageDetails.deserialize), uri: uriIdentity.asCanonicalUri(URI.revive(serialized.uri)), }); } diff --git a/src/vscode-dts/vscode.proposed.testCoverage.d.ts b/src/vscode-dts/vscode.proposed.testCoverage.d.ts index 614792e0d9cf2..fc6e49647774c 100644 --- a/src/vscode-dts/vscode.proposed.testCoverage.d.ts +++ b/src/vscode-dts/vscode.proposed.testCoverage.d.ts @@ -9,40 +9,25 @@ declare module 'vscode' { 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. + * Adds coverage for a file in the run. */ - coverageProvider?: TestCoverageProvider; - // ... - } + addCoverage(fileCoverage: FileCoverage): void; - /** - * 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. + * An event fired when the editor is no longer interested in data + * associated with the test run. */ - // @API - pass something into the provide method: - // (1) have TestController#coverageProvider: TestCoverageProvider - // (2) pass TestRun into this method - provideFileCoverage(token: CancellationToken): ProviderResult; + onDidDispose: Event; + } + export interface TestRunProfile { /** - * Give a FileCoverage to fill in more data, namely {@link FileCoverage.detailedCoverage}. - * The editor will only resolve a FileCoverage once, and only if detailedCoverage - * is undefined. + * A function that provides detailed statement and function-level coverage for a file. * - * @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. + * The {@link FileCoverage} object passed to this function is the same instance + * emitted on {@link TestRun.addCoverage} calls associated with this profile. */ - resolveFileCoverage?(coverage: T, token: CancellationToken): ProviderResult; + loadDetailedCoverage?: (testRun: TestRun, fileCoverage: FileCoverage, token: CancellationToken) => Thenable; } /** @@ -92,19 +77,13 @@ declare module 'vscode' { */ declarationCoverage?: 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; + static fromDetails(uri: Uri, details: readonly FileCoverageDetail[]): FileCoverage; /** * @param uri Covered file URI @@ -217,6 +196,6 @@ declare module 'vscode' { constructor(name: string, executed: number | boolean, location: Position | Range); } - export type DetailedCoverage = StatementCoverage | DeclarationCoverage; + export type FileCoverageDetail = StatementCoverage | DeclarationCoverage; }