From eaad698d800f500892ef4297e4583951314470cc Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 12 Mar 2024 08:09:30 -0700 Subject: [PATCH 1/3] wip --- .../vscode.proposed.testCoverage.d.ts | 49 +++++-------------- 1 file changed, 11 insertions(+), 38 deletions(-) diff --git a/src/vscode-dts/vscode.proposed.testCoverage.d.ts b/src/vscode-dts/vscode.proposed.testCoverage.d.ts index 614792e0d9cf2..631a9b8f81f03 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; + onWillDispose: 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; + getDetailedCoverage?: (fileCoverage: FileCoverage, token: CancellationToken) => Thenable; } /** @@ -86,18 +71,6 @@ declare module 'vscode' { */ branchCoverage?: CoveredCount; - /** - * Declaration coverage information. Depending on the reporter and - * language, this may be types such as functions, methods, or namespaces. - */ - 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. From a06953c75d27535caa89df748bedcbc0b5094ef4 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 12 Mar 2024 14:42:26 -0700 Subject: [PATCH 2/3] testing: update testing API with proposals This replaces the test provider with: - An `addCoverage` method on the TestRun. The benefit of this is that test data can be provided gradually, even upserted, over a test run to provide more live results. - A `loadDetailedCoverage` on the profile which loads coverage details. This is the "resolve" handler before. - An additional onDidDispose event on the TestRun that extensions can use to clear allocated resources. Note that the the `FileCoverage` instance given to `loadDetailedCoverage` is the same instance provided to `addCoverage`, so it can be subclassed to tag in additional detail. The `TestRun` instance is also the same, so `WeakMap`s can be used for associating data with both. Naming might change over the next week, but there were good feelings about this structure the API call this morning. This change has a backwards-compatibility shim, but extensions should migrate over before our next stable release in ~3 weeks from today. fyi @jdneo @eleanorjboyd --- .../api/browser/mainThreadTesting.ts | 42 ++-- .../workbench/api/common/extHost.protocol.ts | 13 +- src/vs/workbench/api/common/extHostTesting.ts | 204 +++++++++--------- .../api/common/extHostTypeConverters.ts | 6 +- src/vs/workbench/api/common/extHostTypes.ts | 4 +- .../api/test/browser/extHostTesting.test.ts | 12 +- .../browser/codeCoverageDecorations.ts | 8 +- .../testing/browser/testCoverageView.ts | 24 +++ .../contrib/testing/common/observableUtils.ts | 27 +++ .../contrib/testing/common/testCoverage.ts | 133 ++++++------ .../testing/common/testCoverageService.ts | 21 +- .../contrib/testing/common/testResult.ts | 3 +- .../contrib/testing/common/testTypes.ts | 9 +- .../vscode.proposed.testCoverage.d.ts | 14 +- 14 files changed, 287 insertions(+), 233 deletions(-) create mode 100644 src/vs/workbench/contrib/testing/common/observableUtils.ts 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 9055bfa9aea04..eb9937ecd4534 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2680,13 +2680,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 */ @@ -2757,7 +2754,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 eb1e89cf5c636..fdcce5de2a89f 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 058582f76ab82..eab21af62d045 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -4032,7 +4032,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); @@ -4064,7 +4064,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..baaf8805dfc32 100644 --- a/src/vs/workbench/contrib/testing/common/testCoverage.ts +++ b/src/vs/workbench/contrib/testing/common/testCoverage.ts @@ -5,43 +5,78 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { ResourceMap } from 'vs/base/common/map'; +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 = structuredClone(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 +99,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 +130,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 +146,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 +161,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 +169,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 631a9b8f81f03..fc6e49647774c 100644 --- a/src/vscode-dts/vscode.proposed.testCoverage.d.ts +++ b/src/vscode-dts/vscode.proposed.testCoverage.d.ts @@ -17,7 +17,7 @@ declare module 'vscode' { * An event fired when the editor is no longer interested in data * associated with the test run. */ - onWillDispose: Event; + onDidDispose: Event; } export interface TestRunProfile { @@ -27,7 +27,7 @@ declare module 'vscode' { * The {@link FileCoverage} object passed to this function is the same instance * emitted on {@link TestRun.addCoverage} calls associated with this profile. */ - getDetailedCoverage?: (fileCoverage: FileCoverage, token: CancellationToken) => Thenable; + loadDetailedCoverage?: (testRun: TestRun, fileCoverage: FileCoverage, token: CancellationToken) => Thenable; } /** @@ -71,13 +71,19 @@ declare module 'vscode' { */ branchCoverage?: CoveredCount; + /** + * Declaration coverage information. Depending on the reporter and + * language, this may be types such as functions, methods, or namespaces. + */ + declarationCoverage?: CoveredCount; + /** * 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 @@ -190,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; } From e0520fc4fe05f63736fec9380aa27b60f86c57bf Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 12 Mar 2024 17:01:36 -0700 Subject: [PATCH 3/3] avoid structuredClone --- src/vs/workbench/contrib/testing/common/testCoverage.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/vs/workbench/contrib/testing/common/testCoverage.ts b/src/vs/workbench/contrib/testing/common/testCoverage.ts index baaf8805dfc32..9f6de896f2d1e 100644 --- a/src/vs/workbench/contrib/testing/common/testCoverage.ts +++ b/src/vs/workbench/contrib/testing/common/testCoverage.ts @@ -5,6 +5,7 @@ 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'; @@ -59,7 +60,7 @@ export class TestCoverage { node.value = coverage; } else if (!node.value) { // clone because later intersertions can modify the counts: - const intermediate = structuredClone(rawCoverage); + const intermediate = deepClone(rawCoverage); intermediate.id = String(incId++); intermediate.uri = this.treePathToUri(canonical.slice(0, chain.length)); node.value = new ComputedFileCoverage(intermediate);