mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-23 18:19:12 +01:00
901 lines
26 KiB
TypeScript
901 lines
26 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 { 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 { Iterable } from 'vs/base/common/iterator';
|
|
import { Disposable, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
|
|
import { MarshalledId } from 'vs/base/common/marshalling';
|
|
import { deepFreeze } from 'vs/base/common/objects';
|
|
import { isDefined } from 'vs/base/common/types';
|
|
import { generateUuid } from 'vs/base/common/uuid';
|
|
import { ExtHostTestingShape, 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 { TestItemImpl, TestItemRootImpl } from 'vs/workbench/api/common/extHostTestingPrivateApi';
|
|
import * as Convert from 'vs/workbench/api/common/extHostTypeConverters';
|
|
import { TestRunProfileGroup, TestRunRequest } from 'vs/workbench/api/common/extHostTypes';
|
|
import { SingleUseTestCollection } from 'vs/workbench/contrib/testing/common/ownedTestCollection';
|
|
import { AbstractIncrementalTestCollection, CoverageDetails, IFileCoverage, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, ITestIdWithSrc, ITestItem, RunTestForControllerRequest, TestRunProfileBitset, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
|
|
import { TestId, TestIdPathParts, TestPosition } from 'vs/workbench/contrib/testing/common/testId';
|
|
import type * as vscode from 'vscode';
|
|
|
|
interface ControllerInfo {
|
|
controller: vscode.TestController,
|
|
profiles: Map<number, vscode.TestRunProfile>,
|
|
collection: SingleUseTestCollection,
|
|
}
|
|
|
|
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 ? Convert.TestItem.toItemFromContext(arg) : arg,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Implements vscode.test.registerTestProvider
|
|
*/
|
|
public createTestController(controllerId: string, label: string): vscode.TestController {
|
|
const disposable = new DisposableStore();
|
|
const collection = disposable.add(new SingleUseTestCollection(controllerId));
|
|
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.$updateControllerLabel(controllerId, label);
|
|
},
|
|
get id() {
|
|
return controllerId;
|
|
},
|
|
createRunProfile: (label, group, runHandler, isDefault) => {
|
|
// 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++;
|
|
}
|
|
|
|
const profile = new TestRunProfileImpl(this.proxy, controllerId, profileId, label, group, runHandler, isDefault);
|
|
profiles.set(profileId, profile);
|
|
return profile;
|
|
},
|
|
createTestRun: (request, name, persist = true) => {
|
|
return this.runTracker.createTestRun(controllerId, collection, request, name, persist);
|
|
},
|
|
set resolveChildrenHandler(fn) {
|
|
collection.resolveHandler = fn;
|
|
},
|
|
get resolveChildrenHandler() {
|
|
return collection.resolveHandler;
|
|
},
|
|
dispose: () => {
|
|
disposable.dispose();
|
|
},
|
|
};
|
|
|
|
// back compat:
|
|
(controller as any).createRunConfiguration = controller.createRunProfile;
|
|
|
|
proxy.$registerTestController(controllerId, label);
|
|
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)));
|
|
|
|
return controller;
|
|
}
|
|
|
|
/**
|
|
* Implements vscode.test.createTestItem
|
|
*/
|
|
public createTestItem(id: string, label: string, uri?: vscode.Uri) {
|
|
return new TestItemImpl(id, label, uri);
|
|
}
|
|
|
|
/**
|
|
* 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({
|
|
targets: [{
|
|
testIds: req.include?.map(t => t.id) ?? [controller.collection.root.id],
|
|
profileGroup: profileGroupToBitset[profile.group],
|
|
profileId: profile.profileId,
|
|
controllerId: profile.controllerId,
|
|
}],
|
|
exclude: req.exclude?.map(t => ({ testId: t.id, controllerId: profile.controllerId })),
|
|
}, token);
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
$provideFileCoverage(runId: string, taskId: string, token: CancellationToken): Promise<IFileCoverage[]> {
|
|
return Iterable.find(this.runTracker.trackers, t => t.id === runId)?.getCoverage(taskId)?.provideFileCoverage(token) ?? Promise.resolve([]);
|
|
}
|
|
|
|
/**
|
|
* @inheritdoc
|
|
*/
|
|
$resolveFileCoverage(runId: string, taskId: string, fileIndex: number, token: CancellationToken): Promise<CoverageDetails[]> {
|
|
return Iterable.find(this.runTracker.trackers, t => t.id === runId)?.getCoverage(taskId)?.resolveFileCoverage(fileIndex, token) ?? Promise.resolve([]);
|
|
}
|
|
|
|
/** @inheritdoc */
|
|
$configureRunProfile(controllerId: string, profileId: number) {
|
|
this.controllers.get(controllerId)?.profiles.get(profileId)?.configureHandler?.();
|
|
}
|
|
|
|
/**
|
|
* 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({ controllerId, testId }: ITestIdWithSrc, levels: number) {
|
|
const collection = this.controllers.get(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: TestsDiff): void {
|
|
this.observer.applyDiff(diff);
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
}
|
|
|
|
this.runTracker.cancelRunById(req.runId);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: TestRunImpl, 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 taskId = generateUuid();
|
|
const coverage = new TestRunCoverageBearer(this.proxy, this.dto.id, taskId);
|
|
const run = new TestRunImpl(name, this.cts.token, taskId, coverage, this.dto, this.sharedTestIds, this.proxy, () => {
|
|
this.tasks.delete(run.taskId);
|
|
if (!this.isRunning) {
|
|
this.dispose();
|
|
}
|
|
});
|
|
|
|
this.tasks.set(run.taskId, { run, coverage });
|
|
return run;
|
|
}
|
|
|
|
public override dispose() {
|
|
if (!this.disposed) {
|
|
this.disposed = true;
|
|
this.endEmitter.fire();
|
|
this.cts.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: SingleUseTestCollection, 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);
|
|
const profile = tryGetProfileFromTestRunReq(request);
|
|
this.proxy.$startedExtensionTestRun({
|
|
controllerId,
|
|
profile: profile && { group: profileGroupToBitset[profile.group], id: profile.profileId },
|
|
exclude: request.exclude?.map(t => t.id) ?? [],
|
|
id: dto.id,
|
|
include: request.include?.map(t => t.id) ?? [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: SingleUseTestCollection, request: vscode.TestRunRequest) {
|
|
return new TestRunDto(
|
|
controllerId,
|
|
generateUuid(),
|
|
request.include?.map(t => TestId.fromExtHostTestItem(t, controllerId).toString()) ?? [controllerId],
|
|
request.exclude?.map(t => TestId.fromExtHostTestItem(t, controllerId).toString()) ?? [],
|
|
collection,
|
|
);
|
|
}
|
|
|
|
public static fromInternal(request: RunTestForControllerRequest, collection: SingleUseTestCollection) {
|
|
return new TestRunDto(
|
|
request.controllerId,
|
|
request.runId,
|
|
request.testIds,
|
|
request.excludeExtIds,
|
|
collection,
|
|
);
|
|
}
|
|
|
|
constructor(
|
|
public readonly controllerId: string,
|
|
public readonly id: string,
|
|
include: string[],
|
|
exclude: string[],
|
|
public readonly colllection: SingleUseTestCollection,
|
|
) {
|
|
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) ?? [];
|
|
}
|
|
}
|
|
|
|
class TestRunImpl implements vscode.TestRun {
|
|
readonly #proxy: MainThreadTestingShape;
|
|
readonly #req: TestRunDto;
|
|
readonly #sharedIds: Set<string>;
|
|
readonly #onEnd: () => void;
|
|
readonly #coverage: TestRunCoverageBearer;
|
|
#ended = false;
|
|
|
|
public set coverageProvider(provider: vscode.TestCoverageProvider | undefined) {
|
|
this.#coverage.coverageProvider = provider;
|
|
}
|
|
|
|
public get coverageProvider() {
|
|
return this.#coverage.coverageProvider;
|
|
}
|
|
|
|
constructor(
|
|
public readonly name: string | undefined,
|
|
public readonly token: CancellationToken,
|
|
public readonly taskId: string,
|
|
coverage: TestRunCoverageBearer,
|
|
dto: TestRunDto,
|
|
sharedTestIds: Set<string>,
|
|
proxy: MainThreadTestingShape,
|
|
onEnd: () => void,
|
|
) {
|
|
this.#onEnd = onEnd;
|
|
this.#proxy = proxy;
|
|
this.#req = dto;
|
|
this.#coverage = coverage;
|
|
this.#sharedIds = sharedTestIds;
|
|
proxy.$startedTestRunTask(dto.id, { id: this.taskId, name, running: true });
|
|
}
|
|
|
|
setState(test: vscode.TestItem, state: vscode.TestResultState, duration?: number): void {
|
|
const req = this.#req;
|
|
if (!this.#ended && req.isIncluded(test)) {
|
|
this.ensureTestIsKnown(test);
|
|
this.#proxy.$updateTestStateInRun(req.id, this.taskId, TestId.fromExtHostTestItem(test, req.controllerId).toString(), state, duration);
|
|
}
|
|
}
|
|
|
|
appendMessage(test: vscode.TestItem, message: vscode.TestMessage): void {
|
|
const req = this.#req;
|
|
if (!this.#ended && req.isIncluded(test)) {
|
|
this.ensureTestIsKnown(test);
|
|
this.#proxy.$appendTestMessageInRun(req.id, this.taskId, TestId.fromExtHostTestItem(test, req.controllerId).toString(), Convert.TestMessage.from(message));
|
|
}
|
|
}
|
|
|
|
appendOutput(output: string): void {
|
|
if (!this.#ended) {
|
|
this.#proxy.$appendOutputToRun(this.#req.id, this.taskId, VSBuffer.fromString(output));
|
|
}
|
|
}
|
|
|
|
end(): void {
|
|
if (!this.#ended) {
|
|
this.#ended = true;
|
|
this.#proxy.$finishedTestRunTask(this.#req.id, this.taskId);
|
|
this.#onEnd();
|
|
}
|
|
}
|
|
|
|
private ensureTestIsKnown(test: vscode.TestItem) {
|
|
const sent = this.#sharedIds;
|
|
if (sent.has(test.id)) {
|
|
return;
|
|
}
|
|
|
|
const chain: ITestItem[] = [];
|
|
const root = this.#req.colllection.root;
|
|
while (true) {
|
|
chain.unshift(Convert.TestItem.from(test, root.id));
|
|
|
|
if (sent.has(test.id)) {
|
|
break;
|
|
}
|
|
|
|
sent.add(test.id);
|
|
if (!test.parent) {
|
|
break;
|
|
}
|
|
|
|
test = test.parent;
|
|
}
|
|
|
|
if (!sent.has(root.id)) {
|
|
sent.add(root.id);
|
|
chain.unshift(Convert.TestItem.from(root, root.id));
|
|
}
|
|
|
|
this.#proxy.$addTestsToRun(this.#req.controllerId, this.#req.id, chain);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @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;
|
|
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 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,
|
|
public readonly controllerId: string,
|
|
public readonly profileId: number,
|
|
private _label: string,
|
|
public readonly group: vscode.TestRunProfileGroup,
|
|
public runHandler: (request: vscode.TestRunRequest, token: vscode.CancellationToken) => Thenable<void> | void,
|
|
private _isDefault = false,
|
|
) {
|
|
this.#proxy = proxy;
|
|
|
|
const groupBitset = profileGroupToBitset[group];
|
|
if (typeof groupBitset !== 'number') {
|
|
throw new Error(`Unknown TestRunProfile.group ${group}`);
|
|
}
|
|
|
|
this.#proxy.$publishTestRunProfile({
|
|
profileId: profileId,
|
|
controllerId,
|
|
label: _label,
|
|
group: groupBitset,
|
|
isDefault: _isDefault,
|
|
hasConfigurationHandler: false,
|
|
});
|
|
}
|
|
|
|
dispose(): void {
|
|
this.#proxy.$removeTestProfile(this.controllerId, this.profileId);
|
|
}
|
|
}
|
|
|
|
const profileGroupToBitset: { [K in TestRunProfileGroup]: TestRunProfileBitset } = {
|
|
[TestRunProfileGroup.Coverage]: TestRunProfileBitset.Coverage,
|
|
[TestRunProfileGroup.Debug]: TestRunProfileBitset.Debug,
|
|
[TestRunProfileGroup.Run]: TestRunProfileBitset.Run,
|
|
};
|