mirror of
https://github.com/microsoft/vscode.git
synced 2026-04-24 02:28:34 +01:00
887 lines
25 KiB
TypeScript
887 lines
25 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 { RunOnceScheduler } from 'vs/base/common/async';
|
|
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 * as Convert from 'vs/workbench/api/common/extHostTypeConverters';
|
|
import { TestItemImpl, TestRunConfigurationGroup, TestRunRequest } from 'vs/workbench/api/common/extHostTypes';
|
|
import { SingleUseTestCollection, TestPosition } from 'vs/workbench/contrib/testing/common/ownedTestCollection';
|
|
import { AbstractIncrementalTestCollection, CoverageDetails, IFileCoverage, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, ISerializedTestResults, ITestIdWithSrc, ITestItem, RunTestForControllerRequest, TestRunConfigurationBitset, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection';
|
|
import type * as vscode from 'vscode';
|
|
|
|
export class ExtHostTesting implements ExtHostTestingShape {
|
|
private readonly resultsChangedEmitter = new Emitter<void>();
|
|
private readonly controllers = new Map</* controller ID */ string, {
|
|
controller: vscode.TestController,
|
|
configurations: Map<number, vscode.TestRunConfiguration>,
|
|
collection: SingleUseTestCollection,
|
|
}>();
|
|
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 initialExpand = disposable.add(new RunOnceScheduler(() => collection.expand(collection.root.id, 0), 0));
|
|
const configurations = new Map<number, vscode.TestRunConfiguration>();
|
|
const proxy = this.proxy;
|
|
|
|
const controller: vscode.TestController = {
|
|
root: collection.root,
|
|
get label() {
|
|
return label;
|
|
},
|
|
set label(value: string) {
|
|
label = value;
|
|
proxy.$updateControllerLabel(controllerId, label);
|
|
},
|
|
get id() {
|
|
return controllerId;
|
|
},
|
|
createRunConfiguration: (label, group, runHandler, isDefault) => {
|
|
// Derive the config ID from a hash so that the same config will tend
|
|
// to have the same hashes, allowing re-run requests to work across reloads.
|
|
let configId = hash(label);
|
|
while (configurations.has(configId)) {
|
|
configId++;
|
|
}
|
|
|
|
const config = new TestRunConfigurationImpl(this.proxy, controllerId, configId, label, group, runHandler, isDefault);
|
|
configurations.set(configId, config);
|
|
return config;
|
|
},
|
|
createTestRun: (request, name, persist = true) => {
|
|
return this.runTracker.createTestRun(controllerId, request, name, persist);
|
|
},
|
|
createTestItem(id: string, label: string, parent: vscode.TestItem, uri: vscode.Uri, data?: unknown) {
|
|
if (!(parent instanceof TestItemImpl)) {
|
|
throw new Error(`The "parent" passed in for TestItem ${id} is invalid`);
|
|
}
|
|
|
|
return new TestItemImpl(id, label, uri, data, parent);
|
|
},
|
|
set resolveChildrenHandler(fn) {
|
|
collection.resolveHandler = fn;
|
|
if (fn) {
|
|
initialExpand.schedule();
|
|
}
|
|
},
|
|
get resolveChildrenHandler() {
|
|
return collection.resolveHandler;
|
|
},
|
|
dispose: () => {
|
|
disposable.dispose();
|
|
},
|
|
};
|
|
|
|
proxy.$registerTestController(controllerId, label);
|
|
disposable.add(toDisposable(() => proxy.$unregisterTestController(controllerId)));
|
|
|
|
this.controllers.set(controllerId, { controller, collection, configurations });
|
|
disposable.add(toDisposable(() => this.controllers.delete(controllerId)));
|
|
|
|
disposable.add(collection.onDidGenerateDiff(diff => proxy.$publishDiff(controllerId, diff)));
|
|
|
|
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 config = tryGetConfigFromTestRunReq(req);
|
|
if (!config) {
|
|
throw new Error('The request passed to `vscode.test.runTests` must include a configuration');
|
|
}
|
|
|
|
if (!req.tests.length) {
|
|
return;
|
|
}
|
|
|
|
const testListToProviders = (tests: ReadonlyArray<vscode.TestItem>) =>
|
|
tests
|
|
.map(this.getInternalTestForReference, this)
|
|
.filter(isDefined)
|
|
.map(t => ({ controllerId: t.controllerId, testId: t.item.extId, configId: config }));
|
|
|
|
await this.proxy.$runTests({
|
|
targets: [{
|
|
testIds: req.tests.map(t => t.id),
|
|
profileGroup: configGroupToBitset[config.group],
|
|
profileId: config.configId,
|
|
controllerId: config.controllerId,
|
|
}],
|
|
exclude: req.exclude
|
|
? testListToProviders(req.exclude).map(t => ({ testId: t.testId, controllerId: t.controllerId }))
|
|
: undefined,
|
|
}, 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 */
|
|
$configureRunConfig(controllerId: string, configId: number) {
|
|
this.controllers.get(controllerId)?.configurations.get(configId)?.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, configurations } = lookup;
|
|
const configuration = configurations.get(req.configId);
|
|
if (!configuration) {
|
|
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 => collection.tree.comparePositions(include, exclude) === TestPosition.IsChild,
|
|
));
|
|
|
|
if (!includeTests.length) {
|
|
return;
|
|
}
|
|
|
|
const publicReq = new TestRunRequest(
|
|
includeTests.map(t => t.actual),
|
|
excludeTests.map(t => t.actual),
|
|
configuration,
|
|
);
|
|
|
|
const tracker = this.runTracker.prepareForMainThreadTestRun(publicReq, TestRunDto.fromInternal(req), token);
|
|
|
|
try {
|
|
await configuration.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);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets the internal test item associated with the reference from the extension.
|
|
*/
|
|
private getInternalTestForReference(test: vscode.TestItem) {
|
|
return mapFind(this.controllers.values(), ({ collection }) => collection.getTestByReference(test))
|
|
?? this.observer.getMirroredTestDataByReference(test);
|
|
}
|
|
}
|
|
|
|
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, 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, request);
|
|
const config = tryGetConfigFromTestRunReq(request);
|
|
this.proxy.$startedExtensionTestRun({
|
|
controllerId,
|
|
config: config && { group: configGroupToBitset[config.group], id: config.configId },
|
|
exclude: request.exclude?.map(t => t.id) ?? [],
|
|
id: dto.id,
|
|
tests: request.tests.map(t => t.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 tryGetConfigFromTestRunReq = (request: vscode.TestRunRequest) => {
|
|
if (!request.configuration) {
|
|
return undefined;
|
|
}
|
|
|
|
if (!(request.configuration instanceof TestRunConfigurationImpl)) {
|
|
throw new Error(`TestRunRequest.configuration is not an instance created from TestController.createRunConfiguration`);
|
|
}
|
|
|
|
return request.configuration;
|
|
};
|
|
|
|
export class TestRunDto {
|
|
public static fromPublic(controllerId: string, request: vscode.TestRunRequest) {
|
|
return new TestRunDto(
|
|
controllerId,
|
|
generateUuid(),
|
|
new Set(request.tests.map(t => t.id)),
|
|
new Set(request.exclude?.map(t => t.id) ?? Iterable.empty()),
|
|
);
|
|
}
|
|
|
|
public static fromInternal(request: RunTestForControllerRequest) {
|
|
return new TestRunDto(
|
|
request.controllerId,
|
|
request.runId,
|
|
new Set(request.testIds),
|
|
new Set(request.excludeExtIds),
|
|
);
|
|
}
|
|
|
|
constructor(
|
|
public readonly controllerId: string,
|
|
public readonly id: string,
|
|
private readonly include: ReadonlySet<string>,
|
|
private readonly exclude: ReadonlySet<string>,
|
|
) { }
|
|
|
|
public isIncluded(test: vscode.TestItem) {
|
|
for (let t: vscode.TestItem | undefined = test; t; t = t.parent) {
|
|
if (this.include.has(t.id)) {
|
|
return true;
|
|
} else if (this.exclude.has(t.id)) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
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 {
|
|
if (!this.#ended && this.#req.isIncluded(test)) {
|
|
this.ensureTestIsKnown(test);
|
|
this.#proxy.$updateTestStateInRun(this.#req.id, this.taskId, test.id, state, duration);
|
|
}
|
|
}
|
|
|
|
appendMessage(test: vscode.TestItem, message: vscode.TestMessage): void {
|
|
if (!this.#ended && this.#req.isIncluded(test)) {
|
|
this.ensureTestIsKnown(test);
|
|
this.#proxy.$appendTestMessageInRun(this.#req.id, this.taskId, test.id, 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[] = [];
|
|
while (true) {
|
|
chain.unshift(Convert.TestItem.from(test));
|
|
|
|
if (sent.has(test.id)) {
|
|
break;
|
|
}
|
|
|
|
sent.add(test.id);
|
|
if (!test.parent) {
|
|
break;
|
|
}
|
|
|
|
test = test.parent;
|
|
}
|
|
|
|
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 TestRunConfigurationImpl implements vscode.TestRunConfiguration {
|
|
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.configId, { 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.configId, { 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.configId, { hasConfigurationHandler: !!handler });
|
|
}
|
|
}
|
|
|
|
constructor(
|
|
proxy: MainThreadTestingShape,
|
|
public readonly controllerId: string,
|
|
public readonly configId: number,
|
|
private _label: string,
|
|
public readonly group: vscode.TestRunConfigurationGroup,
|
|
public runHandler: vscode.TestRunHandler,
|
|
private _isDefault = false,
|
|
) {
|
|
this.#proxy = proxy;
|
|
|
|
const groupBitset = configGroupToBitset[group];
|
|
if (typeof groupBitset !== 'number') {
|
|
throw new Error(`Unknown TestRunConfiguration.group ${group}`);
|
|
}
|
|
|
|
this.#proxy.$publishTestRunConfig({
|
|
profileId: configId,
|
|
controllerId,
|
|
label: _label,
|
|
group: groupBitset,
|
|
isDefault: _isDefault,
|
|
hasConfigurationHandler: false,
|
|
});
|
|
}
|
|
|
|
dispose(): void {
|
|
this.#proxy.$removeTestRunConfig(this.controllerId, this.configId);
|
|
}
|
|
}
|
|
|
|
const configGroupToBitset: { [K in TestRunConfigurationGroup]: TestRunConfigurationBitset } = {
|
|
[TestRunConfigurationGroup.Coverage]: TestRunConfigurationBitset.Coverage,
|
|
[TestRunConfigurationGroup.Debug]: TestRunConfigurationBitset.Debug,
|
|
[TestRunConfigurationGroup.Run]: TestRunConfigurationBitset.Run,
|
|
};
|