From 2749de38081ed05ee185d897bcb17892893888ea Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Tue, 7 Nov 2023 10:37:37 -0800 Subject: [PATCH] testing: implement proposed active profiles api (#197664) testing: implement proposed active profiles api For https://github.com/microsoft/vscode/issues/193160 --- extensions/vscode-api-tests/package.json | 1 + src/vs/base/common/event.ts | 2 +- .../api/browser/mainThreadTesting.ts | 18 +++- .../workbench/api/common/extHost.protocol.ts | 2 + src/vs/workbench/api/common/extHostTesting.ts | 66 ++++++++++++- .../api/test/browser/extHostTesting.test.ts | 94 ++++++++++++++++++- .../api/test/common/testRPCProtocol.ts | 12 +++ .../common/extensionsApiProposals.ts | 1 + .../vscode.proposed.testingActiveProfile.d.ts | 21 +++++ 9 files changed, 205 insertions(+), 12 deletions(-) create mode 100644 src/vscode-dts/vscode.proposed.testingActiveProfile.d.ts diff --git a/extensions/vscode-api-tests/package.json b/extensions/vscode-api-tests/package.json index beb65ffb2e6..401b9a4da03 100644 --- a/extensions/vscode-api-tests/package.json +++ b/extensions/vscode-api-tests/package.json @@ -52,6 +52,7 @@ "treeViewReveal", "workspaceTrust", "telemetry", + "testingActiveProfile", "windowActivity", "interactiveUserActions" ], diff --git a/src/vs/base/common/event.ts b/src/vs/base/common/event.ts index 9f76502f48a..07f7846157a 100644 --- a/src/vs/base/common/event.ts +++ b/src/vs/base/common/event.ts @@ -540,8 +540,8 @@ export namespace Event { export interface IChainableSythensis { map(fn: (i: T) => O): IChainableSythensis; forEach(fn: (i: T) => void): IChainableSythensis; + filter(fn: (e: T) => e is R): IChainableSythensis; filter(fn: (e: T) => boolean): IChainableSythensis; - filter(fn: (e: T | R) => e is R): IChainableSythensis; reduce(merge: (last: R, event: T) => R, initial: R): IChainableSythensis; reduce(merge: (last: R | undefined, event: T) => R): IChainableSythensis; latch(equals?: (a: T, b: T) => boolean): IChainableSythensis; diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index d4858ccb417..356310400ee 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -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 = {}; + 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(); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 18035b6cb54..4da9a5a9267 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -2638,6 +2638,8 @@ export interface ExtHostTestingShape { $refreshTests(controllerId: string, token: CancellationToken): Promise; /** Ensures any pending test diffs are flushed */ $syncTests(): Promise; + /** Sets the active test run profiles */ + $setActiveRunProfiles(profiles: Record): void; } export interface ExtHostLocalizationShape { diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 65dd87b6e3f..7756fffa851 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -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; collection: ExtHostTestItemCollection; extension: Readonly; + activeProfiles: Set; } -export class ExtHostTesting implements ExtHostTestingShape { - private readonly resultsChangedEmitter = new Emitter(); - private readonly controllers = new Map(); +type ActiveProfileChangeEvent = Map>; + +export class ExtHostTesting extends Disposable implements ExtHostTestingShape { + private readonly resultsChangedEmitter = this._register(new Emitter()); + protected readonly controllers = new Map(); private readonly proxy: MainThreadTestingShape; private readonly runTracker: TestRunCoordinator; private readonly observer: TestObservers; + private readonly activeProfilesChangedEmitter = this._register(new Emitter()); public onResultsChanged = this.resultsChangedEmitter.event; public results: ReadonlyArray = []; @@ -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(); + const activeProfiles = new Set(); 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): 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(); + 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; + readonly #onDidChangeActiveProfiles: Event; #profiles?: Map; 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, + extension: IRelaxedExtensionDescription, + activeProfiles: ReadonlySet, + onDidChangeActiveProfiles: Event, 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]; diff --git a/src/vs/workbench/api/test/browser/extHostTesting.test.ts b/src/vs/workbench/api/test/browser/extHostTesting.test.ts index 0a7b6004539..11dfe6d72a1 100644 --- a/src/vs/workbench/api/test/browser/extHostTesting.test.ts +++ b/src/vs/workbench/api/test/browser/extHostTesting.test.ts @@ -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()(), new Map(), 'ctrlId', 42, 'Do Run', TestRunProfileKind.Run, () => { }, false); + configuration = new TestRunProfileImpl(mockObject()(), 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() { + 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); + const changeBA = Event.toPromise(profBA.onDidChangeSelected as Event); + const changeBB = Event.toPromise(profBB.onDidChangeSelected as Event); + + 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); + }); + }); }); diff --git a/src/vs/workbench/api/test/common/testRPCProtocol.ts b/src/vs/workbench/api/test/common/testRPCProtocol.ts index 7907665f711..abf854afa06 100644 --- a/src/vs/workbench/api/test/common/testRPCProtocol.ts +++ b/src/vs/workbench/api/test/common/testRPCProtocol.ts @@ -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(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; diff --git a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts index 683efb3184f..577da2ca078 100644 --- a/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts +++ b/src/vs/workbench/services/extensions/common/extensionsApiProposals.ts @@ -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', diff --git a/src/vscode-dts/vscode.proposed.testingActiveProfile.d.ts b/src/vscode-dts/vscode.proposed.testingActiveProfile.d.ts new file mode 100644 index 00000000000..8bce86f3347 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.testingActiveProfile.d.ts @@ -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; + } +}