mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-23 18:19:12 +01:00
970 lines
29 KiB
TypeScript
970 lines
29 KiB
TypeScript
/*---------------------------------------------------------------------------------------------
|
|
* Copyright (c) Microsoft Corporation. All rights reserved.
|
|
* Licensed under the MIT License. See License.txt in the project root for license information.
|
|
*--------------------------------------------------------------------------------------------*/
|
|
|
|
import { mapFind } from 'vs/base/common/arrays';
|
|
import { VSBuffer } from 'vs/base/common/buffer';
|
|
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
|
|
import { Emitter, Event } from 'vs/base/common/event';
|
|
import { once } from 'vs/base/common/functional';
|
|
import { hash } from 'vs/base/common/hash';
|
|
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
|
|
import { MarshalledId } from 'vs/base/common/marshallingIds';
|
|
import { deepFreeze } from 'vs/base/common/objects';
|
|
import { isDefined } from 'vs/base/common/types';
|
|
import { generateUuid } from 'vs/base/common/uuid';
|
|
import { ExtHostTestingShape, ILocationDto, MainContext, MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol';
|
|
import { ExtHostCommands } from 'vs/workbench/api/common/extHostCommands';
|
|
import { IExtHostRpcService } from 'vs/workbench/api/common/extHostRpcService';
|
|
import { ExtHostTestItemCollection, TestItemImpl, TestItemRootImpl, toItemFromContext } from 'vs/workbench/api/common/extHostTestItem';
|
|
import * as Convert from 'vs/workbench/api/common/extHostTypeConverters';
|
|
import { TestRunProfileKind, TestRunRequest } from 'vs/workbench/api/common/extHostTypes';
|
|
import { TestId, TestIdPathParts, TestPosition } from 'vs/workbench/contrib/testing/common/testId';
|
|
import { InvalidTestItemError } from 'vs/workbench/contrib/testing/common/testItemCollection';
|
|
import { AbstractIncrementalTestCollection, CoverageDetails, IFileCoverage, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, ITestItem, RunTestForControllerRequest, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes';
|
|
import type * as vscode from 'vscode';
|
|
|
|
interface ControllerInfo {
|
|
controller: vscode.TestController;
|
|
profiles: Map<number, vscode.TestRunProfile>;
|
|
collection: ExtHostTestItemCollection;
|
|
}
|
|
|
|
export class ExtHostTesting implements ExtHostTestingShape {
|
|
private readonly resultsChangedEmitter = new Emitter<void>();
|
|
private readonly controllers = new Map</* controller ID */ string, ControllerInfo>();
|
|
private readonly proxy: MainThreadTestingShape;
|
|
private readonly runTracker: TestRunCoordinator;
|
|
private readonly observer: TestObservers;
|
|
|
|
public onResultsChanged = this.resultsChangedEmitter.event;
|
|
public results: ReadonlyArray<vscode.TestRunResult> = [];
|
|
|
|
constructor(@IExtHostRpcService rpc: IExtHostRpcService, commands: ExtHostCommands) {
|
|
this.proxy = rpc.getProxy(MainContext.MainThreadTesting);
|
|
this.observer = new TestObservers(this.proxy);
|
|
this.runTracker = new TestRunCoordinator(this.proxy);
|
|
|
|
commands.registerArgumentProcessor({
|
|
processArgument: arg =>
|
|
arg?.$mid === MarshalledId.TestItemContext ? toItemFromContext(arg) : arg,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Implements vscode.test.registerTestProvider
|
|
*/
|
|
public createTestController(controllerId: string, label: string, refreshHandler?: (token: CancellationToken) => Thenable<void> | void): vscode.TestController {
|
|
if (this.controllers.has(controllerId)) {
|
|
throw new Error(`Attempt to insert a duplicate controller with ID "${controllerId}"`);
|
|
}
|
|
|
|
const disposable = new DisposableStore();
|
|
const collection = disposable.add(new ExtHostTestItemCollection(controllerId, label));
|
|
collection.root.label = label;
|
|
|
|
const profiles = new Map<number, vscode.TestRunProfile>();
|
|
const proxy = this.proxy;
|
|
|
|
const controller: vscode.TestController = {
|
|
items: collection.root.children,
|
|
get label() {
|
|
return label;
|
|
},
|
|
set label(value: string) {
|
|
label = value;
|
|
collection.root.label = value;
|
|
proxy.$updateController(controllerId, { label });
|
|
},
|
|
get refreshHandler() {
|
|
return refreshHandler;
|
|
},
|
|
set refreshHandler(value: ((token: CancellationToken) => Thenable<void> | void) | undefined) {
|
|
refreshHandler = value;
|
|
proxy.$updateController(controllerId, { canRefresh: !!value });
|
|
},
|
|
get id() {
|
|
return controllerId;
|
|
},
|
|
createRunProfile: (label, group, runHandler, isDefault, tag?: vscode.TestTag | undefined) => {
|
|
// Derive the profile ID from a hash so that the same profile will tend
|
|
// to have the same hashes, allowing re-run requests to work across reloads.
|
|
let profileId = hash(label);
|
|
while (profiles.has(profileId)) {
|
|
profileId++;
|
|
}
|
|
|
|
return new TestRunProfileImpl(this.proxy, profiles, controllerId, profileId, label, group, runHandler, isDefault, tag);
|
|
},
|
|
createTestItem(id, label, uri) {
|
|
return new TestItemImpl(controllerId, id, label, uri);
|
|
},
|
|
createTestRun: (request, name, persist = true) => {
|
|
return this.runTracker.createTestRun(controllerId, collection, request, name, persist);
|
|
},
|
|
set resolveHandler(fn) {
|
|
collection.resolveHandler = fn;
|
|
},
|
|
get resolveHandler() {
|
|
return collection.resolveHandler as undefined | ((item?: vscode.TestItem) => void);
|
|
},
|
|
dispose: () => {
|
|
disposable.dispose();
|
|
},
|
|
};
|
|
|
|
proxy.$registerTestController(controllerId, label, !!refreshHandler);
|
|
disposable.add(toDisposable(() => proxy.$unregisterTestController(controllerId)));
|
|
|
|
const info: ControllerInfo = { controller, collection, profiles: profiles };
|
|
this.controllers.set(controllerId, info);
|
|
disposable.add(toDisposable(() => this.controllers.delete(controllerId)));
|
|
|
|
disposable.add(collection.onDidGenerateDiff(diff => proxy.$publishDiff(controllerId, diff.map(TestsDiffOp.serialize))));
|
|
|
|
return controller;
|
|
}
|
|
|
|
/**
|
|
* Implements vscode.test.createTestObserver
|
|
*/
|
|
public createTestObserver() {
|
|
return this.observer.checkout();
|
|
}
|
|
|
|
|
|
/**
|
|
* Implements vscode.test.runTests
|
|
*/
|
|
public async runTests(req: vscode.TestRunRequest, token = CancellationToken.None) {
|
|
const profile = tryGetProfileFromTestRunReq(req);
|
|
if (!profile) {
|
|
throw new Error('The request passed to `vscode.test.runTests` must include a profile');
|
|
}
|
|
|
|
const controller = this.controllers.get(profile.controllerId);
|
|
if (!controller) {
|
|
throw new Error('Controller not found');
|
|
}
|
|
|
|
await this.proxy.$runTests({
|
|
isUiTriggered: false,
|
|
targets: [{
|
|
testIds: req.include?.map(t => TestId.fromExtHostTestItem(t, controller.collection.root.id).toString()) ?? [controller.collection.root.id],
|
|
profileGroup: profileGroupToBitset[profile.kind],
|
|
profileId: profile.profileId,
|
|
controllerId: profile.controllerId,
|
|
}],
|
|
exclude: req.exclude?.map(t => t.id),
|
|
}, token);
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
$provideFileCoverage(runId: string, taskId: string, token: CancellationToken): Promise<IFileCoverage[]> {
|
|
const coverage = mapFind(this.runTracker.trackers, t => t.id === runId ? t.getCoverage(taskId) : undefined);
|
|
return coverage?.provideFileCoverage(token) ?? Promise.resolve([]);
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
$resolveFileCoverage(runId: string, taskId: string, fileIndex: number, token: CancellationToken): Promise<CoverageDetails[]> {
|
|
const coverage = mapFind(this.runTracker.trackers, t => t.id === runId ? t.getCoverage(taskId) : undefined);
|
|
return coverage?.resolveFileCoverage(fileIndex, token) ?? Promise.resolve([]);
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
$configureRunProfile(controllerId: string, profileId: number) {
|
|
this.controllers.get(controllerId)?.profiles.get(profileId)?.configureHandler?.();
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
async $refreshTests(controllerId: string, token: CancellationToken) {
|
|
await this.controllers.get(controllerId)?.controller.refreshHandler?.(token);
|
|
}
|
|
|
|
/**
|
|
* Updates test results shown to extensions.
|
|
* @override
|
|
*/
|
|
public $publishTestResults(results: ISerializedTestResults[]): void {
|
|
this.results = Object.freeze(
|
|
results
|
|
.map(r => deepFreeze(Convert.TestResults.to(r)))
|
|
.concat(this.results)
|
|
.sort((a, b) => b.completedAt - a.completedAt)
|
|
.slice(0, 32),
|
|
);
|
|
|
|
this.resultsChangedEmitter.fire();
|
|
}
|
|
|
|
/**
|
|
* Expands the nodes in the test tree. If levels is less than zero, it will
|
|
* be treated as infinite.
|
|
*/
|
|
public async $expandTest(testId: string, levels: number) {
|
|
const collection = this.controllers.get(TestId.fromString(testId).controllerId)?.collection;
|
|
if (collection) {
|
|
await collection.expand(testId, levels < 0 ? Infinity : levels);
|
|
collection.flushDiff();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Receives a test update from the main thread. Called (eventually) whenever
|
|
* tests change.
|
|
*/
|
|
public $acceptDiff(diff: TestsDiffOp.Serialized[]): void {
|
|
this.observer.applyDiff(diff.map(TestsDiffOp.deserialize));
|
|
}
|
|
|
|
/**
|
|
* Runs tests with the given set of IDs. Allows for test from multiple
|
|
* providers to be run.
|
|
* @override
|
|
*/
|
|
public async $runControllerTests(req: RunTestForControllerRequest, token: CancellationToken): Promise<void> {
|
|
const lookup = this.controllers.get(req.controllerId);
|
|
if (!lookup) {
|
|
return;
|
|
}
|
|
|
|
const { collection, profiles } = lookup;
|
|
const profile = profiles.get(req.profileId);
|
|
if (!profile) {
|
|
return;
|
|
}
|
|
|
|
const includeTests = req.testIds
|
|
.map((testId) => collection.tree.get(testId))
|
|
.filter(isDefined);
|
|
|
|
const excludeTests = req.excludeExtIds
|
|
.map(id => lookup.collection.tree.get(id))
|
|
.filter(isDefined)
|
|
.filter(exclude => includeTests.some(
|
|
include => include.fullId.compare(exclude.fullId) === TestPosition.IsChild,
|
|
));
|
|
|
|
if (!includeTests.length) {
|
|
return;
|
|
}
|
|
|
|
const publicReq = new TestRunRequest(
|
|
includeTests.some(i => i.actual instanceof TestItemRootImpl) ? undefined : includeTests.map(t => t.actual),
|
|
excludeTests.map(t => t.actual),
|
|
profile,
|
|
);
|
|
|
|
const tracker = this.runTracker.prepareForMainThreadTestRun(
|
|
publicReq,
|
|
TestRunDto.fromInternal(req, lookup.collection),
|
|
token,
|
|
);
|
|
|
|
try {
|
|
await profile.runHandler(publicReq, token);
|
|
} finally {
|
|
if (tracker.isRunning && !token.isCancellationRequested) {
|
|
await Event.toPromise(tracker.onEnd);
|
|
}
|
|
|
|
tracker.dispose();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancels an ongoing test run.
|
|
*/
|
|
public $cancelExtensionTestRun(runId: string | undefined) {
|
|
if (runId === undefined) {
|
|
this.runTracker.cancelAllRuns();
|
|
} else {
|
|
this.runTracker.cancelRunById(runId);
|
|
}
|
|
}
|
|
}
|
|
|
|
class TestRunTracker extends Disposable {
|
|
private readonly tasks = new Map</* task ID */string, { run: vscode.TestRun; coverage: TestRunCoverageBearer }>();
|
|
private readonly sharedTestIds = new Set<string>();
|
|
private readonly cts: CancellationTokenSource;
|
|
private readonly endEmitter = this._register(new Emitter<void>());
|
|
private disposed = false;
|
|
|
|
/**
|
|
* Fires when a test ends, and no more tests are left running.
|
|
*/
|
|
public readonly onEnd = this.endEmitter.event;
|
|
|
|
/**
|
|
* Gets whether there are any tests running.
|
|
*/
|
|
public get isRunning() {
|
|
return this.tasks.size > 0;
|
|
}
|
|
|
|
/**
|
|
* Gets the run ID.
|
|
*/
|
|
public get id() {
|
|
return this.dto.id;
|
|
}
|
|
|
|
constructor(private readonly dto: TestRunDto, private readonly proxy: MainThreadTestingShape, parentToken?: CancellationToken) {
|
|
super();
|
|
this.cts = this._register(new CancellationTokenSource(parentToken));
|
|
this._register(this.cts.token.onCancellationRequested(() => {
|
|
for (const { run } of this.tasks.values()) {
|
|
run.end();
|
|
}
|
|
}));
|
|
}
|
|
|
|
public getCoverage(taskId: string) {
|
|
return this.tasks.get(taskId)?.coverage;
|
|
}
|
|
|
|
public createRun(name: string | undefined) {
|
|
const runId = this.dto.id;
|
|
const ctrlId = this.dto.controllerId;
|
|
const taskId = generateUuid();
|
|
const coverage = new TestRunCoverageBearer(this.proxy, runId, taskId);
|
|
|
|
const guardTestMutation = <Args extends unknown[]>(fn: (test: vscode.TestItem, ...args: Args) => void) =>
|
|
(test: vscode.TestItem, ...args: Args) => {
|
|
if (ended) {
|
|
console.warn(`Setting the state of test "${test.id}" is a no-op after the run ends.`);
|
|
return;
|
|
}
|
|
|
|
if (!this.dto.isIncluded(test)) {
|
|
return;
|
|
}
|
|
|
|
this.ensureTestIsKnown(test);
|
|
fn(test, ...args);
|
|
};
|
|
|
|
const appendMessages = (test: vscode.TestItem, messages: vscode.TestMessage | readonly vscode.TestMessage[]) => {
|
|
const converted = messages instanceof Array
|
|
? messages.map(Convert.TestMessage.from)
|
|
: [Convert.TestMessage.from(messages)];
|
|
|
|
if (test.uri && test.range) {
|
|
const defaultLocation: ILocationDto = { range: Convert.Range.from(test.range), uri: test.uri };
|
|
for (const message of converted) {
|
|
message.location = message.location || defaultLocation;
|
|
}
|
|
}
|
|
|
|
this.proxy.$appendTestMessagesInRun(runId, taskId, TestId.fromExtHostTestItem(test, ctrlId).toString(), converted);
|
|
};
|
|
|
|
let ended = false;
|
|
const run: vscode.TestRun = {
|
|
isPersisted: this.dto.isPersisted,
|
|
token: this.cts.token,
|
|
name,
|
|
get coverageProvider() {
|
|
return coverage.coverageProvider;
|
|
},
|
|
set coverageProvider(provider) {
|
|
coverage.coverageProvider = provider;
|
|
},
|
|
//#region state mutation
|
|
enqueued: guardTestMutation(test => {
|
|
this.proxy.$updateTestStateInRun(runId, taskId, TestId.fromExtHostTestItem(test, ctrlId).toString(), TestResultState.Queued);
|
|
}),
|
|
skipped: guardTestMutation(test => {
|
|
this.proxy.$updateTestStateInRun(runId, taskId, TestId.fromExtHostTestItem(test, ctrlId).toString(), TestResultState.Skipped);
|
|
}),
|
|
started: guardTestMutation(test => {
|
|
this.proxy.$updateTestStateInRun(runId, taskId, TestId.fromExtHostTestItem(test, ctrlId).toString(), TestResultState.Running);
|
|
}),
|
|
errored: guardTestMutation((test, messages, duration) => {
|
|
appendMessages(test, messages);
|
|
this.proxy.$updateTestStateInRun(runId, taskId, TestId.fromExtHostTestItem(test, ctrlId).toString(), TestResultState.Errored, duration);
|
|
}),
|
|
failed: guardTestMutation((test, messages, duration) => {
|
|
appendMessages(test, messages);
|
|
this.proxy.$updateTestStateInRun(runId, taskId, TestId.fromExtHostTestItem(test, ctrlId).toString(), TestResultState.Failed, duration);
|
|
}),
|
|
passed: guardTestMutation((test, duration) => {
|
|
this.proxy.$updateTestStateInRun(runId, taskId, TestId.fromExtHostTestItem(test, this.dto.controllerId).toString(), TestResultState.Passed, duration);
|
|
}),
|
|
//#endregion
|
|
appendOutput: (output, location?: vscode.Location, test?: vscode.TestItem) => {
|
|
if (ended) {
|
|
return;
|
|
}
|
|
|
|
if (test) {
|
|
if (this.dto.isIncluded(test)) {
|
|
this.ensureTestIsKnown(test);
|
|
} else {
|
|
test = undefined;
|
|
}
|
|
}
|
|
|
|
this.proxy.$appendOutputToRun(
|
|
runId,
|
|
taskId,
|
|
VSBuffer.fromString(output),
|
|
location && Convert.location.from(location),
|
|
test && TestId.fromExtHostTestItem(test, ctrlId).toString(),
|
|
);
|
|
},
|
|
end: () => {
|
|
if (ended) {
|
|
return;
|
|
}
|
|
|
|
ended = true;
|
|
this.proxy.$finishedTestRunTask(runId, taskId);
|
|
this.tasks.delete(taskId);
|
|
if (!this.isRunning) {
|
|
this.dispose();
|
|
}
|
|
}
|
|
};
|
|
|
|
this.tasks.set(taskId, { run, coverage });
|
|
this.proxy.$startedTestRunTask(runId, { id: taskId, name, running: true });
|
|
|
|
return run;
|
|
}
|
|
|
|
public override dispose() {
|
|
if (!this.disposed) {
|
|
this.disposed = true;
|
|
this.endEmitter.fire();
|
|
this.cts.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
|
|
private ensureTestIsKnown(test: vscode.TestItem) {
|
|
if (!(test instanceof TestItemImpl)) {
|
|
throw new InvalidTestItemError(test.id);
|
|
}
|
|
|
|
if (this.sharedTestIds.has(TestId.fromExtHostTestItem(test, this.dto.controllerId).toString())) {
|
|
return;
|
|
}
|
|
|
|
const chain: ITestItem.Serialized[] = [];
|
|
const root = this.dto.colllection.root;
|
|
while (true) {
|
|
const converted = Convert.TestItem.from(test as TestItemImpl);
|
|
chain.unshift(converted);
|
|
|
|
if (this.sharedTestIds.has(converted.extId)) {
|
|
break;
|
|
}
|
|
|
|
this.sharedTestIds.add(converted.extId);
|
|
if (test === root) {
|
|
break;
|
|
}
|
|
|
|
test = test.parent || root;
|
|
}
|
|
|
|
this.proxy.$addTestsToRun(this.dto.controllerId, this.dto.id, chain);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Queues runs for a single extension and provides the currently-executing
|
|
* run so that `createTestRun` can be properly correlated.
|
|
*/
|
|
export class TestRunCoordinator {
|
|
private tracked = new Map<vscode.TestRunRequest, TestRunTracker>();
|
|
|
|
public get trackers() {
|
|
return this.tracked.values();
|
|
}
|
|
|
|
constructor(private readonly proxy: MainThreadTestingShape) { }
|
|
|
|
/**
|
|
* Registers a request as being invoked by the main thread, so
|
|
* `$startedExtensionTestRun` is not invoked. The run must eventually
|
|
* be cancelled manually.
|
|
*/
|
|
public prepareForMainThreadTestRun(req: vscode.TestRunRequest, dto: TestRunDto, token: CancellationToken) {
|
|
return this.getTracker(req, dto, 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.dispose();
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Cancels an existing test run via its cancellation token.
|
|
*/
|
|
public cancelAllRuns() {
|
|
for (const tracker of this.tracked.values()) {
|
|
tracker.dispose();
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Implements the public `createTestRun` API.
|
|
*/
|
|
public createTestRun(controllerId: string, collection: ExtHostTestItemCollection, request: vscode.TestRunRequest, name: string | undefined, persist: boolean): vscode.TestRun {
|
|
const existing = this.tracked.get(request);
|
|
if (existing) {
|
|
return existing.createRun(name);
|
|
}
|
|
|
|
// If there is not an existing tracked extension for the request, start
|
|
// a new, detached session.
|
|
const dto = TestRunDto.fromPublic(controllerId, collection, request, persist);
|
|
const profile = tryGetProfileFromTestRunReq(request);
|
|
this.proxy.$startedExtensionTestRun({
|
|
controllerId,
|
|
profile: profile && { group: profileGroupToBitset[profile.kind], id: profile.profileId },
|
|
exclude: request.exclude?.map(t => TestId.fromExtHostTestItem(t, collection.root.id).toString()) ?? [],
|
|
id: dto.id,
|
|
include: request.include?.map(t => TestId.fromExtHostTestItem(t, collection.root.id).toString()) ?? [collection.root.id],
|
|
persist
|
|
});
|
|
|
|
const tracker = this.getTracker(request, dto);
|
|
tracker.onEnd(() => this.proxy.$finishedExtensionTestRun(dto.id));
|
|
return tracker.createRun(name);
|
|
}
|
|
|
|
private getTracker(req: vscode.TestRunRequest, dto: TestRunDto, token?: CancellationToken) {
|
|
const tracker = new TestRunTracker(dto, this.proxy, token);
|
|
this.tracked.set(req, tracker);
|
|
tracker.onEnd(() => this.tracked.delete(req));
|
|
return tracker;
|
|
}
|
|
}
|
|
|
|
const tryGetProfileFromTestRunReq = (request: vscode.TestRunRequest) => {
|
|
if (!request.profile) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!(request.profile instanceof TestRunProfileImpl)) {
|
|
throw new Error(`TestRunRequest.profile is not an instance created from TestController.createRunProfile`);
|
|
}
|
|
|
|
return request.profile;
|
|
};
|
|
|
|
export class TestRunDto {
|
|
private readonly includePrefix: string[];
|
|
private readonly excludePrefix: string[];
|
|
|
|
public static fromPublic(controllerId: string, collection: ExtHostTestItemCollection, request: vscode.TestRunRequest, persist: boolean) {
|
|
return new TestRunDto(
|
|
controllerId,
|
|
generateUuid(),
|
|
request.include?.map(t => TestId.fromExtHostTestItem(t, controllerId).toString()) ?? [controllerId],
|
|
request.exclude?.map(t => TestId.fromExtHostTestItem(t, controllerId).toString()) ?? [],
|
|
persist,
|
|
collection,
|
|
);
|
|
}
|
|
|
|
public static fromInternal(request: RunTestForControllerRequest, collection: ExtHostTestItemCollection) {
|
|
return new TestRunDto(
|
|
request.controllerId,
|
|
request.runId,
|
|
request.testIds,
|
|
request.excludeExtIds,
|
|
true,
|
|
collection,
|
|
);
|
|
}
|
|
|
|
constructor(
|
|
public readonly controllerId: string,
|
|
public readonly id: string,
|
|
include: string[],
|
|
exclude: string[],
|
|
public readonly isPersisted: boolean,
|
|
public readonly colllection: ExtHostTestItemCollection,
|
|
) {
|
|
this.includePrefix = include.map(id => id + TestIdPathParts.Delimiter);
|
|
this.excludePrefix = exclude.map(id => id + TestIdPathParts.Delimiter);
|
|
}
|
|
|
|
public isIncluded(test: vscode.TestItem) {
|
|
const id = TestId.fromExtHostTestItem(test, this.controllerId).toString() + TestIdPathParts.Delimiter;
|
|
for (const prefix of this.excludePrefix) {
|
|
if (id === prefix || id.startsWith(prefix)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
for (const prefix of this.includePrefix) {
|
|
if (id === prefix || id.startsWith(prefix)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
}
|
|
|
|
class TestRunCoverageBearer {
|
|
private _coverageProvider?: vscode.TestCoverageProvider;
|
|
private fileCoverage?: Promise<vscode.FileCoverage[] | null | undefined>;
|
|
|
|
public set coverageProvider(provider: vscode.TestCoverageProvider | undefined) {
|
|
if (this._coverageProvider) {
|
|
throw new Error('The TestCoverageProvider cannot be replaced after being provided');
|
|
}
|
|
|
|
if (!provider) {
|
|
return;
|
|
}
|
|
|
|
this._coverageProvider = provider;
|
|
this.proxy.$signalCoverageAvailable(this.runId, this.taskId);
|
|
}
|
|
|
|
public get coverageProvider() {
|
|
return this._coverageProvider;
|
|
}
|
|
|
|
constructor(
|
|
private readonly proxy: MainThreadTestingShape,
|
|
private readonly runId: string,
|
|
private readonly taskId: string,
|
|
) {
|
|
}
|
|
|
|
public async provideFileCoverage(token: CancellationToken): Promise<IFileCoverage[]> {
|
|
if (!this._coverageProvider) {
|
|
return [];
|
|
}
|
|
|
|
if (!this.fileCoverage) {
|
|
this.fileCoverage = (async () => this._coverageProvider!.provideFileCoverage(token))();
|
|
}
|
|
|
|
try {
|
|
const coverage = await this.fileCoverage;
|
|
return coverage?.map(Convert.TestCoverage.fromFile) ?? [];
|
|
} catch (e) {
|
|
this.fileCoverage = undefined;
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
public async resolveFileCoverage(index: number, token: CancellationToken): Promise<CoverageDetails[]> {
|
|
const fileCoverage = await this.fileCoverage;
|
|
let file = fileCoverage?.[index];
|
|
if (!this._coverageProvider || !fileCoverage || !file) {
|
|
return [];
|
|
}
|
|
|
|
if (!file.detailedCoverage) {
|
|
file = fileCoverage[index] = await this._coverageProvider.resolveFileCoverage?.(file, token) ?? file;
|
|
}
|
|
|
|
return file.detailedCoverage?.map(Convert.TestCoverage.fromDetailed) ?? [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
interface MirroredCollectionTestItem extends IncrementalTestCollectionItem {
|
|
revived: vscode.TestItem;
|
|
depth: number;
|
|
}
|
|
|
|
class MirroredChangeCollector extends IncrementalChangeCollector<MirroredCollectionTestItem> {
|
|
private readonly added = new Set<MirroredCollectionTestItem>();
|
|
private readonly updated = new Set<MirroredCollectionTestItem>();
|
|
private readonly removed = new Set<MirroredCollectionTestItem>();
|
|
|
|
private readonly alreadyRemoved = new Set<string>();
|
|
|
|
public get isEmpty() {
|
|
return this.added.size === 0 && this.removed.size === 0 && this.updated.size === 0;
|
|
}
|
|
|
|
constructor(private readonly emitter: Emitter<vscode.TestsChangeEvent>) {
|
|
super();
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
public override add(node: MirroredCollectionTestItem): void {
|
|
this.added.add(node);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
public override update(node: MirroredCollectionTestItem): void {
|
|
Object.assign(node.revived, Convert.TestItem.toPlain(node.item));
|
|
if (!this.added.has(node)) {
|
|
this.updated.add(node);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
public override remove(node: MirroredCollectionTestItem): void {
|
|
if (this.added.has(node)) {
|
|
this.added.delete(node);
|
|
return;
|
|
}
|
|
|
|
this.updated.delete(node);
|
|
|
|
if (node.parent && this.alreadyRemoved.has(node.parent)) {
|
|
this.alreadyRemoved.add(node.item.extId);
|
|
return;
|
|
}
|
|
|
|
this.removed.add(node);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
public getChangeEvent(): vscode.TestsChangeEvent {
|
|
const { added, updated, removed } = this;
|
|
return {
|
|
get added() { return [...added].map(n => n.revived); },
|
|
get updated() { return [...updated].map(n => n.revived); },
|
|
get removed() { return [...removed].map(n => n.revived); },
|
|
};
|
|
}
|
|
|
|
public override complete() {
|
|
if (!this.isEmpty) {
|
|
this.emitter.fire(this.getChangeEvent());
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Maintains tests in this extension host sent from the main thread.
|
|
* @private
|
|
*/
|
|
export class MirroredTestCollection extends AbstractIncrementalTestCollection<MirroredCollectionTestItem> {
|
|
private changeEmitter = new Emitter<vscode.TestsChangeEvent>();
|
|
|
|
/**
|
|
* Change emitter that fires with the same sematics as `TestObserver.onDidChangeTests`.
|
|
*/
|
|
public readonly onDidChangeTests = this.changeEmitter.event;
|
|
|
|
/**
|
|
* Gets a list of root test items.
|
|
*/
|
|
public get rootTests() {
|
|
return super.roots;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* If the test ID exists, returns its underlying ID.
|
|
*/
|
|
public getMirroredTestDataById(itemId: string) {
|
|
return this.items.get(itemId);
|
|
}
|
|
|
|
/**
|
|
* If the test item is a mirrored test item, returns its underlying ID.
|
|
*/
|
|
public getMirroredTestDataByReference(item: vscode.TestItem) {
|
|
return this.items.get(item.id);
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
protected createItem(item: InternalTestItem, parent?: MirroredCollectionTestItem): MirroredCollectionTestItem {
|
|
return {
|
|
...item,
|
|
// todo@connor4312: make this work well again with children
|
|
revived: Convert.TestItem.toPlain(item.item) as vscode.TestItem,
|
|
depth: parent ? parent.depth + 1 : 0,
|
|
children: new Set(),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* @override
|
|
*/
|
|
protected override createChangeCollector() {
|
|
return new MirroredChangeCollector(this.changeEmitter);
|
|
}
|
|
}
|
|
|
|
class TestObservers {
|
|
private current?: {
|
|
observers: number;
|
|
tests: MirroredTestCollection;
|
|
};
|
|
|
|
constructor(private readonly proxy: MainThreadTestingShape) {
|
|
}
|
|
|
|
public checkout(): vscode.TestObserver {
|
|
if (!this.current) {
|
|
this.current = this.createObserverData();
|
|
}
|
|
|
|
const current = this.current;
|
|
current.observers++;
|
|
|
|
return {
|
|
onDidChangeTest: current.tests.onDidChangeTests,
|
|
get tests() { return [...current.tests.rootTests].map(t => t.revived); },
|
|
dispose: once(() => {
|
|
if (--current.observers === 0) {
|
|
this.proxy.$unsubscribeFromDiffs();
|
|
this.current = undefined;
|
|
}
|
|
}),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Gets the internal test data by its reference.
|
|
*/
|
|
public getMirroredTestDataByReference(ref: vscode.TestItem) {
|
|
return this.current?.tests.getMirroredTestDataByReference(ref);
|
|
}
|
|
|
|
/**
|
|
* Applies test diffs to the current set of observed tests.
|
|
*/
|
|
public applyDiff(diff: TestsDiff) {
|
|
this.current?.tests.apply(diff);
|
|
}
|
|
|
|
private createObserverData() {
|
|
const tests = new MirroredTestCollection();
|
|
this.proxy.$subscribeToDiffs();
|
|
return { observers: 0, tests, };
|
|
}
|
|
}
|
|
|
|
export class TestRunProfileImpl implements vscode.TestRunProfile {
|
|
readonly #proxy: MainThreadTestingShape;
|
|
#profiles?: Map<number, vscode.TestRunProfile>;
|
|
private _configureHandler?: (() => void);
|
|
|
|
public get label() {
|
|
return this._label;
|
|
}
|
|
|
|
public set label(label: string) {
|
|
if (label !== this._label) {
|
|
this._label = label;
|
|
this.#proxy.$updateTestRunConfig(this.controllerId, this.profileId, { label });
|
|
}
|
|
}
|
|
|
|
public get isDefault() {
|
|
return this._isDefault;
|
|
}
|
|
|
|
public set isDefault(isDefault: boolean) {
|
|
if (isDefault !== this._isDefault) {
|
|
this._isDefault = isDefault;
|
|
this.#proxy.$updateTestRunConfig(this.controllerId, this.profileId, { isDefault });
|
|
}
|
|
}
|
|
|
|
public get tag() {
|
|
return this._tag;
|
|
}
|
|
|
|
public set tag(tag: vscode.TestTag | undefined) {
|
|
if (tag?.id !== this._tag?.id) {
|
|
this._tag = tag;
|
|
this.#proxy.$updateTestRunConfig(this.controllerId, this.profileId, {
|
|
tag: tag ? Convert.TestTag.namespace(this.controllerId, tag.id) : null,
|
|
});
|
|
}
|
|
}
|
|
|
|
public get configureHandler() {
|
|
return this._configureHandler;
|
|
}
|
|
|
|
public set configureHandler(handler: undefined | (() => void)) {
|
|
if (handler !== this._configureHandler) {
|
|
this._configureHandler = handler;
|
|
this.#proxy.$updateTestRunConfig(this.controllerId, this.profileId, { hasConfigurationHandler: !!handler });
|
|
}
|
|
}
|
|
|
|
constructor(
|
|
proxy: MainThreadTestingShape,
|
|
profiles: Map<number, vscode.TestRunProfile>,
|
|
public readonly controllerId: string,
|
|
public readonly profileId: number,
|
|
private _label: string,
|
|
public readonly kind: vscode.TestRunProfileKind,
|
|
public runHandler: (request: vscode.TestRunRequest, token: vscode.CancellationToken) => Thenable<void> | void,
|
|
private _isDefault = false,
|
|
public _tag: vscode.TestTag | undefined = undefined,
|
|
) {
|
|
this.#proxy = proxy;
|
|
this.#profiles = profiles;
|
|
profiles.set(profileId, this);
|
|
|
|
const groupBitset = profileGroupToBitset[kind];
|
|
if (typeof groupBitset !== 'number') {
|
|
throw new Error(`Unknown TestRunProfile.group ${kind}`);
|
|
}
|
|
|
|
this.#proxy.$publishTestRunProfile({
|
|
profileId: profileId,
|
|
controllerId,
|
|
tag: _tag ? Convert.TestTag.namespace(this.controllerId, _tag.id) : null,
|
|
label: _label,
|
|
group: groupBitset,
|
|
isDefault: _isDefault,
|
|
hasConfigurationHandler: false,
|
|
});
|
|
}
|
|
|
|
dispose(): void {
|
|
if (this.#profiles?.delete(this.profileId)) {
|
|
this.#profiles = undefined;
|
|
this.#proxy.$removeTestProfile(this.controllerId, this.profileId);
|
|
}
|
|
}
|
|
}
|
|
|
|
const profileGroupToBitset: { [K in TestRunProfileKind]: TestRunProfileBitset } = {
|
|
[TestRunProfileKind.Coverage]: TestRunProfileBitset.Coverage,
|
|
[TestRunProfileKind.Debug]: TestRunProfileBitset.Debug,
|
|
[TestRunProfileKind.Run]: TestRunProfileBitset.Run,
|
|
};
|
|
|