testing: implement proposed active profiles api (#197664)

testing: implement proposed active profiles api

For https://github.com/microsoft/vscode/issues/193160
This commit is contained in:
Connor Peet 2023-11-07 10:37:37 -08:00 committed by GitHub
parent 273862296b
commit 2749de3808
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 205 additions and 12 deletions

View File

@ -52,6 +52,7 @@
"treeViewReveal",
"workspaceTrust",
"telemetry",
"testingActiveProfile",
"windowActivity",
"interactiveUserActions"
],

View File

@ -540,8 +540,8 @@ export namespace Event {
export interface IChainableSythensis<T> {
map<O>(fn: (i: T) => O): IChainableSythensis<O>;
forEach(fn: (i: T) => void): IChainableSythensis<T>;
filter<R extends T>(fn: (e: T) => e is R): IChainableSythensis<R>;
filter(fn: (e: T) => boolean): IChainableSythensis<T>;
filter<R>(fn: (e: T | R) => e is R): IChainableSythensis<R>;
reduce<R>(merge: (last: R, event: T) => R, initial: R): IChainableSythensis<R>;
reduce<R>(merge: (last: R | undefined, event: T) => R): IChainableSythensis<R>;
latch(equals?: (a: T, b: T) => boolean): IChainableSythensis<T>;

View File

@ -5,12 +5,13 @@
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 { revive } from 'vs/base/common/marshalling';
import { URI } from 'vs/base/common/uri';
import { Range } from 'vs/editor/common/core/range';
import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue';
import { ExtensionRunTestsRequest, IFileCoverage, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes';
import { ExtensionRunTestsRequest, IFileCoverage, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestRunProfileBitset, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes';
import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage';
import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService';
import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult';
@ -45,6 +46,21 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh
this.proxy.$cancelExtensionTestRun(runId);
}));
this._register(Event.debounce(testProfiles.onDidChange, (_last, e) => e)(() => {
const defaults = new Set([
...testProfiles.getGroupDefaultProfiles(TestRunProfileBitset.Run),
...testProfiles.getGroupDefaultProfiles(TestRunProfileBitset.Debug),
...testProfiles.getGroupDefaultProfiles(TestRunProfileBitset.Coverage),
]);
const obj: Record</* controller id */string, /* profile id */ number[]> = {};
for (const { controller, profiles } of this.testProfiles.all()) {
obj[controller.id] = profiles.filter(p => defaults.has(p)).map(p => p.profileId);
}
this.proxy.$setActiveRunProfiles(obj);
}));
this._register(resultService.onResultsChanged(evt => {
const results = 'completed' in evt ? evt.completed : ('inserted' in evt ? evt.inserted : undefined);
const serialized = results?.toJSONWithMessages();

View File

@ -2638,6 +2638,8 @@ export interface ExtHostTestingShape {
$refreshTests(controllerId: string, token: CancellationToken): Promise<void>;
/** Ensures any pending test diffs are flushed */
$syncTests(): Promise<void>;
/** Sets the active test run profiles */
$setActiveRunProfiles(profiles: Record</* controller id */string, /* profile id */ number[]>): void;
}
export interface ExtHostLocalizationShape {

View File

@ -29,6 +29,7 @@ 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, 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';
interface ControllerInfo {
@ -36,14 +37,18 @@ interface ControllerInfo {
profiles: Map<number, vscode.TestRunProfile>;
collection: ExtHostTestItemCollection;
extension: Readonly<IRelaxedExtensionDescription>;
activeProfiles: Set<number>;
}
export class ExtHostTesting implements ExtHostTestingShape {
private readonly resultsChangedEmitter = new Emitter<void>();
private readonly controllers = new Map</* controller ID */ string, ControllerInfo>();
type ActiveProfileChangeEvent = Map</* controllerId */ string, Map< /* profileId */number, boolean>>;
export class ExtHostTesting extends Disposable implements ExtHostTestingShape {
private readonly resultsChangedEmitter = this._register(new Emitter<void>());
protected readonly controllers = new Map</* controller ID */ string, ControllerInfo>();
private readonly proxy: MainThreadTestingShape;
private readonly runTracker: TestRunCoordinator;
private readonly observer: TestObservers;
private readonly activeProfilesChangedEmitter = this._register(new Emitter<ActiveProfileChangeEvent>());
public onResultsChanged = this.resultsChangedEmitter.event;
public results: ReadonlyArray<vscode.TestRunResult> = [];
@ -53,6 +58,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
commands: ExtHostCommands,
private readonly editors: ExtHostDocumentsAndEditors,
) {
super();
this.proxy = rpc.getProxy(MainContext.MainThreadTesting);
this.observer = new TestObservers(this.proxy);
this.runTracker = new TestRunCoordinator(this.proxy);
@ -110,6 +116,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
collection.root.label = label;
const profiles = new Map<number, vscode.TestRunProfile>();
const activeProfiles = new Set<number>();
const proxy = this.proxy;
const controller: vscode.TestController = {
@ -140,7 +147,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
profileId++;
}
return new TestRunProfileImpl(this.proxy, profiles, controllerId, profileId, label, group, runHandler, isDefault, tag, supportsContinuousRun);
return new TestRunProfileImpl(this.proxy, profiles, extension, activeProfiles, this.activeProfilesChangedEmitter.event, controllerId, profileId, label, group, runHandler, isDefault, tag, supportsContinuousRun);
},
createTestItem(id, label, uri) {
return new TestItemImpl(controllerId, id, label, uri);
@ -170,7 +177,7 @@ export class ExtHostTesting implements ExtHostTestingShape {
proxy.$registerTestController(controllerId, label, !!refreshHandler);
disposable.add(toDisposable(() => proxy.$unregisterTestController(controllerId)));
const info: ControllerInfo = { controller, collection, profiles: profiles, extension };
const info: ControllerInfo = { controller, collection, profiles, extension, activeProfiles };
this.controllers.set(controllerId, info);
disposable.add(toDisposable(() => this.controllers.delete(controllerId)));
@ -245,6 +252,33 @@ export class ExtHostTesting implements ExtHostTestingShape {
this.controllers.get(controllerId)?.profiles.get(profileId)?.configureHandler?.();
}
/** @inheritdoc */
$setActiveRunProfiles(profiles: Record</* controller id */string, /* profile id */ number[]>): void {
const evt: ActiveProfileChangeEvent = new Map();
for (const [controllerId, profileIds] of Object.entries(profiles)) {
const ctrl = this.controllers.get(controllerId);
if (!ctrl) {
continue;
}
const changes = new Map<number, boolean>();
const added = profileIds.filter(id => !ctrl.activeProfiles.has(id));
const removed = [...ctrl.activeProfiles].filter(id => !profileIds.includes(id));
for (const id of added) {
changes.set(id, true);
ctrl.activeProfiles.add(id);
}
for (const id of removed) {
changes.set(id, false);
ctrl.activeProfiles.delete(id);
}
if (changes.size) {
evt.set(controllerId, changes);
}
}
this.activeProfilesChangedEmitter.fire(evt);
}
/** @inheritdoc */
async $refreshTests(controllerId: string, token: CancellationToken) {
await this.controllers.get(controllerId)?.controller.refreshHandler?.(token);
@ -1005,6 +1039,9 @@ class TestObservers {
export class TestRunProfileImpl implements vscode.TestRunProfile {
readonly #proxy: MainThreadTestingShape;
readonly #extension: IRelaxedExtensionDescription;
readonly #activeProfiles: ReadonlySet<number>;
readonly #onDidChangeActiveProfiles: Event<ActiveProfileChangeEvent>;
#profiles?: Map<number, vscode.TestRunProfile>;
private _configureHandler?: (() => void);
@ -1065,9 +1102,25 @@ export class TestRunProfileImpl implements vscode.TestRunProfile {
}
}
public get isSelected() {
checkProposedApiEnabled(this.#extension, 'testingActiveProfile');
return this.#activeProfiles.has(this.profileId);
}
public get onDidChangeSelected() {
checkProposedApiEnabled(this.#extension, 'testingActiveProfile');
return Event.chain(this.#onDidChangeActiveProfiles, $ => $
.map(ev => ev.get(this.controllerId)?.get(this.profileId))
.filter(isDefined)
);
}
constructor(
proxy: MainThreadTestingShape,
profiles: Map<number, vscode.TestRunProfile>,
extension: IRelaxedExtensionDescription,
activeProfiles: ReadonlySet<number>,
onDidChangeActiveProfiles: Event<ActiveProfileChangeEvent>,
public readonly controllerId: string,
public readonly profileId: number,
private _label: string,
@ -1079,6 +1132,9 @@ export class TestRunProfileImpl implements vscode.TestRunProfile {
) {
this.#proxy = proxy;
this.#profiles = profiles;
this.#extension = extension;
this.#activeProfiles = activeProfiles;
this.#onDidChangeActiveProfiles = onDidChangeActiveProfiles;
profiles.set(profileId, this);
const groupBitset = profileGroupToBitset[kind];

View File

@ -7,21 +7,27 @@ import * as assert from 'assert';
import * as sinon from 'sinon';
import { VSBuffer } from 'vs/base/common/buffer';
import { CancellationTokenSource } from 'vs/base/common/cancellation';
import { Event } from 'vs/base/common/event';
import { Iterable } from 'vs/base/common/iterator';
import { URI } from 'vs/base/common/uri';
import { mockObject, MockObject } from 'vs/base/test/common/mock';
import { mock, mockObject, MockObject } from 'vs/base/test/common/mock';
import { ensureNoDisposablesAreLeakedInTestSuite } from 'vs/base/test/common/utils';
import * as editorRange from 'vs/editor/common/core/range';
import { IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { ExtensionIdentifier, IRelaxedExtensionDescription } from 'vs/platform/extensions/common/extensions';
import { NullLogService } from 'vs/platform/log/common/log';
import { MainThreadTestingShape } from 'vs/workbench/api/common/extHost.protocol';
import { ExtHostCommands } from 'vs/workbench/api/common/extHostCommands';
import { ExtHostDocumentsAndEditors } from 'vs/workbench/api/common/extHostDocumentsAndEditors';
import { TestRunCoordinator, TestRunDto, TestRunProfileImpl } from 'vs/workbench/api/common/extHostTesting';
import { IExtHostTelemetry } from 'vs/workbench/api/common/extHostTelemetry';
import { ExtHostTesting, TestRunCoordinator, TestRunDto, TestRunProfileImpl } from 'vs/workbench/api/common/extHostTesting';
import { ExtHostTestItemCollection, TestItemImpl } from 'vs/workbench/api/common/extHostTestItem';
import * as convert from 'vs/workbench/api/common/extHostTypeConverters';
import { Location, Position, Range, TestMessage, TestResultState, TestRunProfileKind, TestRunRequest as TestRunRequestImpl, TestTag } from 'vs/workbench/api/common/extHostTypes';
import { AnyCallRPCProtocol } from 'vs/workbench/api/test/common/testRPCProtocol';
import { TestId } from 'vs/workbench/contrib/testing/common/testId';
import { TestDiffOpType, TestItemExpandState, TestMessageType, TestsDiff } from 'vs/workbench/contrib/testing/common/testTypes';
import type { TestItem, TestRunRequest } from 'vscode';
import { nullExtensionDescription } from 'vs/workbench/services/extensions/common/extensions';
import type { TestController, TestItem, TestRunProfile, TestRunRequest } from 'vscode';
const simplify = (item: TestItem) => ({
id: item.id,
@ -604,7 +610,7 @@ suite('ExtHost Testing', () => {
cts = new CancellationTokenSource();
c = new TestRunCoordinator(proxy);
configuration = new TestRunProfileImpl(mockObject<MainThreadTestingShape>()(), new Map(), 'ctrlId', 42, 'Do Run', TestRunProfileKind.Run, () => { }, false);
configuration = new TestRunProfileImpl(mockObject<MainThreadTestingShape>()(), new Map(), nullExtensionDescription, new Set(), Event.None, 'ctrlId', 42, 'Do Run', TestRunProfileKind.Run, () => { }, false);
await single.expand(single.root.id, Infinity);
single.collectDiff();
@ -867,4 +873,82 @@ suite('ExtHost Testing', () => {
task1.end();
});
});
suite('service', () => {
let ctrl: TestExtHostTesting;
class TestExtHostTesting extends ExtHostTesting {
public getProfileInternalId(ctrl: TestController, profile: TestRunProfile) {
for (const [id, p] of this.controllers.get(ctrl.id)!.profiles) {
if (profile === p) {
return id;
}
}
throw new Error('profile not found');
}
}
setup(() => {
const rpcProtocol = AnyCallRPCProtocol();
ctrl = ds.add(new TestExtHostTesting(
rpcProtocol,
new ExtHostCommands(rpcProtocol, new NullLogService(), new class extends mock<IExtHostTelemetry>() {
override onExtensionError(): boolean {
return true;
}
}),
new ExtHostDocumentsAndEditors(rpcProtocol, new NullLogService()),
));
});
test('exposes active profiles correctly', async () => {
const extA = { ...nullExtensionDescription, identifier: new ExtensionIdentifier('ext.a'), enabledApiProposals: ['testingActiveProfile'] };
const extB = { ...nullExtensionDescription, identifier: new ExtensionIdentifier('ext.b'), enabledApiProposals: ['testingActiveProfile'] };
const ctrlA = ds.add(ctrl.createTestController(extA, 'a', 'ctrla'));
const profAA = ds.add(ctrlA.createRunProfile('aa', TestRunProfileKind.Run, () => { }));
const profAB = ds.add(ctrlA.createRunProfile('ab', TestRunProfileKind.Run, () => { }));
const ctrlB = ds.add(ctrl.createTestController(extB, 'b', 'ctrlb'));
const profBA = ds.add(ctrlB.createRunProfile('ba', TestRunProfileKind.Run, () => { }));
const profBB = ds.add(ctrlB.createRunProfile('bb', TestRunProfileKind.Run, () => { }));
const neverCalled = sinon.stub();
// empty default state:
assert.deepStrictEqual(profAA.isSelected, false);
assert.deepStrictEqual(profBA.isSelected, false);
assert.deepStrictEqual(profBB.isSelected, false);
// fires a change event:
const changeA = Event.toPromise(profAA.onDidChangeSelected as Event<boolean>);
const changeBA = Event.toPromise(profBA.onDidChangeSelected as Event<boolean>);
const changeBB = Event.toPromise(profBB.onDidChangeSelected as Event<boolean>);
ds.add(profAB.onDidChangeSelected(neverCalled));
assert.strictEqual(neverCalled.called, false);
ctrl.$setActiveRunProfiles({
a: [ctrl.getProfileInternalId(ctrlA, profAA)],
b: [ctrl.getProfileInternalId(ctrlB, profBA), ctrl.getProfileInternalId(ctrlB, profBB)]
});
assert.deepStrictEqual(await changeA, true);
assert.deepStrictEqual(await changeBA, true);
assert.deepStrictEqual(await changeBB, true);
// updates internal state:
assert.deepStrictEqual(profAA.isSelected, true);
assert.deepStrictEqual(profBA.isSelected, true);
assert.deepStrictEqual(profBB.isSelected, true);
assert.deepStrictEqual(profAB.isSelected, false);
// no-ops if equal
ds.add(profAA.onDidChangeSelected(neverCalled));
ctrl.$setActiveRunProfiles({
a: [ctrl.getProfileInternalId(ctrlA, profAA)],
});
assert.strictEqual(neverCalled.called, false);
});
});
});

View File

@ -28,6 +28,18 @@ export function SingleProxyRPCProtocol(thing: any): IExtHostContext & IExtHostRp
};
}
/** Makes a fake {@link SingleProxyRPCProtocol} on which any method can be called */
export function AnyCallRPCProtocol<T>(useCalls?: { [K in keyof T]: T[K] }) {
return SingleProxyRPCProtocol(new Proxy({}, {
get(_target, prop: string) {
if (useCalls && prop in useCalls) {
return (useCalls as any)[prop];
}
return () => Promise.resolve(undefined);
}
}));
}
export class TestRPCProtocol implements IExtHostContext, IExtHostRpcService {
public _serviceBrand: undefined;

View File

@ -97,6 +97,7 @@ export const allApiProposals = Object.freeze({
terminalSelection: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalSelection.d.ts',
testCoverage: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testCoverage.d.ts',
testObserver: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testObserver.d.ts',
testingActiveProfile: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testingActiveProfile.d.ts',
textSearchProvider: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.textSearchProvider.d.ts',
timeline: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.timeline.d.ts',
tokenInformation: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.tokenInformation.d.ts',

View File

@ -0,0 +1,21 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
// https://github.com/microsoft/vscode/issues/193160 @connor4312
declare module 'vscode' {
export interface TestRunProfile {
/**
* Whether this profile is currently selected as a default by the user
*/
readonly isSelected: boolean;
/**
* Fired when a user has changed whether this is a selected profile. The
* event contains the new value of {@link isSelected}
*/
onDidChangeSelected: Event<boolean>;
}
}