diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts index e1b2fd8b51e..d22cb023c67 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts @@ -25,7 +25,7 @@ const TEST_FILE_PATTERN = 'src/vs/**/*.{test,integrationTest}.ts'; const getWorkspaceFolderForTestFile = (uri: vscode.Uri) => (uri.path.endsWith('.test.ts') || uri.path.endsWith('.integrationTest.ts')) && - uri.path.includes('/src/vs/') + uri.path.includes('/src/vs/') ? vscode.workspace.getWorkspaceFolder(uri) : undefined; @@ -41,6 +41,18 @@ export async function activate(context: vscode.ExtensionContext) { const ctrl = vscode.tests.createTestController('selfhost-test-controller', 'VS Code Tests'); const fileChangedEmitter = new vscode.EventEmitter(); + // todo@connor4312: tidy this up and make it work + // context.subscriptions.push(vscode.tests.registerTestFollowupProvider({ + // async provideFollowup(result, test, taskIndex, messageIndex, token) { + // await new Promise(r => setTimeout(r, 2000)); + // return [{ + // title: '$(sparkle) Ask copilot for help', + // command: 'asdf' + // }]; + // }, + // })); + + ctrl.resolveHandler = async test => { if (!test) { context.subscriptions.push(await startWatchingWorkspace(ctrl, fileChangedEmitter)); @@ -62,7 +74,7 @@ export async function activate(context: vscode.ExtensionContext) { }); const createRunHandler = ( - runnerCtor: { new (folder: vscode.WorkspaceFolder): VSCodeTestRunner }, + runnerCtor: { new(folder: vscode.WorkspaceFolder): VSCodeTestRunner }, kind: vscode.TestRunProfileKind, args: string[] = [] ) => { diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts index bd2a35d7abf..17e65cbce50 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/failingDeepStrictEqualAssertFixer.ts @@ -71,8 +71,6 @@ export class FailingDeepStrictEqualAssertFixer { }, }) ); - - tests.testResults; } dispose() { @@ -99,15 +97,15 @@ const formatJsonValue = (value: unknown) => { context => (node: ts.Node) => { const visitor = (node: ts.Node): ts.Node => ts.isPropertyAssignment(node) && - ts.isStringLiteralLike(node.name) && - identifierLikeRe.test(node.name.text) + ts.isStringLiteralLike(node.name) && + identifierLikeRe.test(node.name.text) ? ts.factory.createPropertyAssignment( - ts.factory.createIdentifier(node.name.text), - ts.visitNode(node.initializer, visitor) as ts.Expression - ) + ts.factory.createIdentifier(node.name.text), + ts.visitNode(node.initializer, visitor) as ts.Expression + ) : ts.isStringLiteralLike(node) && node.text === '[undefined]' - ? ts.factory.createIdentifier('undefined') - : ts.visitEachChild(node, visitor, context); + ? ts.factory.createIdentifier('undefined') + : ts.visitEachChild(node, visitor, context); return ts.visitNode(node, visitor); }, @@ -190,7 +188,7 @@ class StrictEqualAssertion { return undefined; } - constructor(private readonly expression: ts.CallExpression) {} + constructor(private readonly expression: ts.CallExpression) { } /** Gets the expected value */ public get expectedValue(): ts.Expression | undefined { diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index e6e48c56bcb..0402df1b5fb 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -18,13 +18,13 @@ import { TestId } from 'vs/workbench/contrib/testing/common/testId'; import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService'; import { LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; -import { IMainThreadTestController, ITestRootProvider, ITestService } from 'vs/workbench/contrib/testing/common/testService'; +import { IMainThreadTestController, ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { CoverageDetails, ExtensionRunTestsRequest, IFileCoverage, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestRunProfileBitset, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes'; import { IExtHostContext, extHostNamedCustomer } from 'vs/workbench/services/extensions/common/extHostCustomers'; import { ExtHostContext, ExtHostTestingShape, ILocationDto, ITestControllerPatch, MainContext, MainThreadTestingShape } from '../common/extHost.protocol'; @extHostNamedCustomer(MainContext.MainThreadTesting) -export class MainThreadTesting extends Disposable implements MainThreadTestingShape, ITestRootProvider { +export class MainThreadTesting extends Disposable implements MainThreadTestingShape { private readonly proxy: ExtHostTestingShape; private readonly diffListener = this._register(new MutableDisposable()); private readonly testProviderRegistrations = new Map this.proxy.$runControllerTests(reqs, token), startContinuousRun: (reqs, token) => this.proxy.$startContinuousRun(reqs, token), expandTest: (testId, levels) => this.proxy.$expandTest(testId, isFinite(levels) ? levels : -1), + provideTestFollowups: (req, token) => this.proxy.$provideTestFollowups(req, token), + executeTestFollowup: id => this.proxy.$executeTestFollowup(id), + disposeTestFollowups: ids => this.proxy.$disposeTestFollowups(ids), }; disposable.add(toDisposable(() => this.testProfiles.removeProfile(controllerId))); diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index a2cbad65c65..10ea7accc6b 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -453,6 +453,10 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I checkProposedApiEnabled(extension, 'testObserver'); return extHostTesting.runTests(provider); }, + registerTestFollowupProvider(provider) { + checkProposedApiEnabled(extension, 'testObserver'); + return extHostTesting.registerTestFollowupProvider(provider); + }, get onDidChangeTestResults() { checkProposedApiEnabled(extension, 'testObserver'); return _asExtensionEvent(extHostTesting.onResultsChanged); diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 2e0a39fb1a5..2d10d6b7129 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -65,7 +65,7 @@ import { InputValidationType } from 'vs/workbench/contrib/scm/common/scm'; import { IWorkspaceSymbol, NotebookPriorityInfo } from 'vs/workbench/contrib/search/common/search'; import { IRawClosedNotebookFileMatch } from 'vs/workbench/contrib/search/common/searchNotebookHelpers'; import { IKeywordRecognitionEvent, ISpeechProviderMetadata, ISpeechToTextEvent, ITextToSpeechEvent } from 'vs/workbench/contrib/speech/common/speechService'; -import { CoverageDetails, ExtensionRunTestsRequest, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestResultState, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes'; +import { CoverageDetails, ExtensionRunTestsRequest, ICallProfileRunHandler, IFileCoverage, ISerializedTestResults, IStartControllerTests, ITestItem, ITestMessage, ITestRunProfile, ITestRunTask, ResolvedTestRunRequest, TestMessageFollowupRequest, TestMessageFollowupResponse, TestResultState, TestsDiffOp } from 'vs/workbench/contrib/testing/common/testTypes'; import { Timeline, TimelineChangeEvent, TimelineOptions, TimelineProviderDescriptor } from 'vs/workbench/contrib/timeline/common/timeline'; import { TypeHierarchyItem } from 'vs/workbench/contrib/typeHierarchy/common/typeHierarchy'; import { RelatedInformationResult, RelatedInformationType } from 'vs/workbench/services/aiRelatedInformation/common/aiRelatedInformation'; @@ -2703,8 +2703,6 @@ export interface ExtHostTestingShape { $cancelExtensionTestRun(runId: string | undefined): void; /** Handles a diff of tests, as a result of a subscribeToDiffs() call */ $acceptDiff(diff: TestsDiffOp.Serialized[]): void; - /** Publishes that a test run finished. */ - $publishTestResults(results: ISerializedTestResults[]): void; /** Expands a test item's children, by the given number of levels. */ $expandTest(testId: string, levels: number): Promise; /** Requests coverage details for a test run. Errors if not available. */ @@ -2719,6 +2717,17 @@ export interface ExtHostTestingShape { $syncTests(): Promise; /** Sets the active test run profiles */ $setDefaultRunProfiles(profiles: Record): void; + + // --- test results: + + /** Publishes that a test run finished. */ + $publishTestResults(results: ISerializedTestResults[]): void; + /** Requests followup actions for a test (failure) message */ + $provideTestFollowups(req: TestMessageFollowupRequest, token: CancellationToken): Promise; + /** Actions a followup actions for a test (failure) message */ + $executeTestFollowup(id: number): Promise; + /** Disposes followup actions for a test (failure) message */ + $disposeTestFollowups(id: number[]): void; } export interface ExtHostLocalizationShape { diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index 343cdc18143..64458c0b585 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -27,7 +27,7 @@ import { TestRunProfileKind, TestRunRequest, FileCoverage } from 'vs/workbench/a 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, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, ITestRunProfile, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestResultState, TestRunProfileBitset, TestsDiff, TestsDiffOp, isStartControllerTests } from 'vs/workbench/contrib/testing/common/testTypes'; +import { AbstractIncrementalTestCollection, CoverageDetails, ICallProfileRunHandler, ISerializedTestResults, IStartControllerTests, IStartControllerTestsResult, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessageMenuArgs, ITestRunProfile, IncrementalChangeCollector, IncrementalTestCollectionItem, InternalTestItem, TestMessageFollowupRequest, TestMessageFollowupResponse, 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'; @@ -41,6 +41,10 @@ interface ControllerInfo { type DefaultProfileChangeEvent = Map>; +let followupCounter = 0; + +const testResultInternalIDs = new WeakMap(); + export class ExtHostTesting extends Disposable implements ExtHostTestingShape { private readonly resultsChangedEmitter = this._register(new Emitter()); protected readonly controllers = new Map(); @@ -48,14 +52,16 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { private readonly runTracker: TestRunCoordinator; private readonly observer: TestObservers; private readonly defaultProfilesChangedEmitter = this._register(new Emitter()); + private readonly followupProviders = new Set(); + private readonly testFollowups = new Map(); public onResultsChanged = this.resultsChangedEmitter.event; public results: ReadonlyArray = []; constructor( @IExtHostRpcService rpc: IExtHostRpcService, - @ILogService logService: ILogService, - commands: ExtHostCommands, + @ILogService private readonly logService: ILogService, + private readonly commands: ExtHostCommands, private readonly editors: ExtHostDocumentsAndEditors, ) { super(); @@ -222,6 +228,14 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { }, token); } + /** + * Implements vscode.test.registerTestFollowupProvider + */ + public registerTestFollowupProvider(provider: vscode.TestFollowupProvider): vscode.Disposable { + this.followupProviders.add(provider); + return { dispose: () => { this.followupProviders.delete(provider); } }; + } + /** * @inheritdoc */ @@ -292,7 +306,11 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { public $publishTestResults(results: ISerializedTestResults[]): void { this.results = Object.freeze( results - .map(Convert.TestResults.to) + .map(r => { + const o = Convert.TestResults.to(r); + testResultInternalIDs.set(o, r.id); + return o; + }) .concat(this.results) .sort((a, b) => b.completedAt - a.completedAt) .slice(0, 32), @@ -348,6 +366,52 @@ export class ExtHostTesting extends Disposable implements ExtHostTestingShape { return res; } + /** @inheritdoc */ + public async $provideTestFollowups(req: TestMessageFollowupRequest, token: CancellationToken): Promise { + const results = this.results.find(r => testResultInternalIDs.get(r) === req.resultId); + const test = results && findTestInResultSnapshot(TestId.fromString(req.extId), results?.results); + if (!test) { + return []; + } + + let followups: vscode.Command[] = []; + await Promise.all([...this.followupProviders].map(async provider => { + try { + const r = await provider.provideFollowup(results, test, req.taskIndex, req.messageIndex, token); + if (r) { + followups = followups.concat(r); + } + } catch (e) { + this.logService.error(`Error thrown while providing followup for test message`, e); + } + })); + + if (token.isCancellationRequested) { + return []; + } + + return followups.map(command => { + const id = followupCounter++; + this.testFollowups.set(id, command); + return { title: command.title, id }; + }); + } + + $disposeTestFollowups(id: number[]): void { + for (const i of id) { + this.testFollowups.delete(i); + } + } + + $executeTestFollowup(id: number): Promise { + const command = this.testFollowups.get(id); + if (!command) { + return Promise.resolve(); + } + + return this.commands.executeCommand(command.command, ...(command.arguments || [])); + } + private async runControllerTestRequest(req: ICallProfileRunHandler | ICallProfileRunHandler, isContinuous: boolean, token: CancellationToken): Promise { const lookup = this.controllers.get(req.controllerId); if (!lookup) { @@ -1202,3 +1266,20 @@ const profileGroupToBitset: { [K in TestRunProfileKind]: TestRunProfileBitset } [TestRunProfileKind.Debug]: TestRunProfileBitset.Debug, [TestRunProfileKind.Run]: TestRunProfileBitset.Run, }; + +function findTestInResultSnapshot(extId: TestId, snapshot: readonly Readonly[]) { + for (let i = 0; i < extId.path.length; i++) { + const item = snapshot.find(s => s.id === extId.path[i]); + if (!item) { + return undefined; + } + + if (i === extId.path.length - 1) { + return item; + } + + snapshot = item.children; + } + + return undefined; +} diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index c08621d410b..e37cf2c6815 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -267,6 +267,48 @@ cursor: pointer; } +.testing-followup-action { + position: absolute; + top: 100%; + left: 22px; + right: 22px; + margin-top: -25px; + line-height: 25px; + overflow: hidden; + pointer-events: none; + background: linear-gradient(transparent, var(--vscode-peekViewEditor-background) 50%); + + &.animated { + animation: fadeIn 150ms ease-out; + } + + > a { + display: flex; + align-items: center; + gap: 4px; + cursor: pointer; + pointer-events: auto; + width: fit-content; + + &, .codicon { + color: var(--vscode-textLink-foreground); + } + + &:hover { + color: var(--vscode-textLink-activeForeground); + } + + &[aria-disabled="true"] { + color: inherit; + cursor: default; + + .codicon { + color: inherit; + } + } + } +} + /** -- filter */ .monaco-action-bar.testing-filter-action-bar { flex-shrink: 0; diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index 2bd0e5f3243..afbb0811a1c 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as dom from 'vs/base/browser/dom'; +import { StandardKeyboardEvent } from 'vs/base/browser/keyboardEvent'; import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; import { alert } from 'vs/base/browser/ui/aria/aria'; import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; @@ -16,6 +17,7 @@ import { ITreeContextMenuEvent, ITreeNode } from 'vs/base/browser/ui/tree/tree'; import { Action, IAction, Separator } from 'vs/base/common/actions'; import { Delayer, Limiter, RunOnceScheduler } from 'vs/base/common/async'; import { VSBuffer } from 'vs/base/common/buffer'; +import { CancellationTokenSource } from 'vs/base/common/cancellation'; import { Codicon } from 'vs/base/common/codicons'; import { Color } from 'vs/base/common/color'; import { Emitter, Event } from 'vs/base/common/event'; @@ -97,7 +99,7 @@ import { ITestExplorerFilterState } from 'vs/workbench/contrib/testing/common/te import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService'; import { ITaskRawOutput, ITestResult, ITestRunTaskResults, LiveTestResult, TestResultItemChange, TestResultItemChangeReason, maxCountPriority, resultItemParents } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService, ResultChangeEvent } from 'vs/workbench/contrib/testing/common/testResultService'; -import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; +import { ITestFollowup, ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { IRichLocation, ITestErrorMessage, ITestItem, ITestItemContext, ITestMessage, ITestMessageMenuArgs, ITestRunTask, ITestTaskState, InternalTestItem, TestMessageType, TestResultItem, TestResultState, TestRunProfileBitset, getMarkId, testResultStateToContextValues } from 'vs/workbench/contrib/testing/common/testTypes'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { IShowResultOptions, ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener'; @@ -769,11 +771,82 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo } } +const FOLLOWUP_ANIMATION_MIN_TIME = 500; + +class FollowupActionWidget extends Disposable { + private readonly el = dom.h('div.testing-followup-action', []); + private readonly visibleStore = this._register(new DisposableStore()); + + constructor( + private readonly container: HTMLElement, + @ITestService private readonly testService: ITestService, + ) { + super(); + } + + public show(subject: InspectSubject) { + this.visibleStore.clear(); + if (subject instanceof MessageSubject) { + this.showMessage(subject); + } + } + + private async showMessage(subject: MessageSubject) { + const cts = this.visibleStore.add(new CancellationTokenSource()); + const start = Date.now(); + const followups = await this.testService.provideTestFollowups({ + extId: subject.test.extId, + messageIndex: subject.messageIndex, + resultId: subject.result.id, + taskIndex: subject.taskIndex, + }, cts.token); + + + if (!followups.followups.length || cts.token.isCancellationRequested) { + followups.dispose(); + return; + } + + this.visibleStore.add(followups); + + dom.clearNode(this.el.root); + this.el.root.classList.toggle('animated', Date.now() - start > FOLLOWUP_ANIMATION_MIN_TIME); + for (const fu of followups.followups) { + const link = document.createElement('a'); + link.tabIndex = 0; + dom.reset(link, ...renderLabelWithIcons(fu.message)); + + this.visibleStore.add(dom.addDisposableListener(link, 'click', () => this.actionFollowup(link, fu))); + this.visibleStore.add(dom.addDisposableListener(link, 'keydown', e => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Space) || event.equals(KeyCode.Enter)) { + this.actionFollowup(link, fu); + } + })); + + this.el.root.appendChild(link); + } + + this.container.appendChild(this.el.root); + this.visibleStore.add(toDisposable(() => { + this.el.root.parentElement?.removeChild(this.el.root); + })); + } + + private actionFollowup(link: HTMLAnchorElement, fu: ITestFollowup) { + if (link.ariaDisabled !== 'true') { + link.ariaDisabled = 'true'; + fu.execute(); + } + } +} + class TestResultsViewContent extends Disposable { private static lastSplitWidth?: number; private readonly didReveal = this._register(new Emitter<{ subject: InspectSubject; preserveFocus: boolean }>()); private readonly currentSubjectStore = this._register(new DisposableStore()); + private followupWidget!: FollowupActionWidget; private messageContextKeyService!: IContextKeyService; private contextKeyTestMessage!: IContextKey; private contextKeyResultOutdated!: IContextKey; @@ -810,6 +883,7 @@ class TestResultsViewContent extends Disposable { const { historyVisible, showRevealLocationOnMessages } = this.options; const isInPeekView = this.editor !== undefined; const messageContainer = this.messageContainer = dom.append(containerElement, dom.$('.test-output-peek-message-container')); + this.followupWidget = this._register(this.instantiationService.createInstance(FollowupActionWidget, messageContainer)); this.contentProviders = [ this._register(this.instantiationService.createInstance(DiffContentProvider, this.editor, messageContainer)), this._register(this.instantiationService.createInstance(MarkdownTestMessagePeek, messageContainer)), @@ -883,7 +957,7 @@ class TestResultsViewContent extends Disposable { this.current = opts.subject; return this.contentProvidersUpdateLimiter.queue(async () => { await Promise.all(this.contentProviders.map(p => p.update(opts.subject))); - + this.followupWidget.show(opts.subject); this.currentSubjectStore.clear(); this.populateFloatingClick(opts.subject); }); diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts index 7af1ee2c331..09f008fa4fe 100644 --- a/src/vs/workbench/contrib/testing/common/testService.ts +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -12,7 +12,7 @@ import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { IUriIdentityService } from 'vs/platform/uriIdentity/common/uriIdentity'; import { IObservableValue, MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; -import { AbstractIncrementalTestCollection, ICallProfileRunHandler, IncrementalTestCollectionItem, InternalTestItem, ITestItemContext, ResolvedTestRunRequest, IStartControllerTests, IStartControllerTestsResult, TestItemExpandState, TestRunProfileBitset, TestsDiff } from 'vs/workbench/contrib/testing/common/testTypes'; +import { AbstractIncrementalTestCollection, ICallProfileRunHandler, IncrementalTestCollectionItem, InternalTestItem, ITestItemContext, ResolvedTestRunRequest, IStartControllerTests, IStartControllerTestsResult, TestItemExpandState, TestRunProfileBitset, TestsDiff, TestMessageFollowupResponse, TestMessageFollowupRequest } from 'vs/workbench/contrib/testing/common/testTypes'; import { TestExclusions } from 'vs/workbench/contrib/testing/common/testExclusions'; import { TestId } from 'vs/workbench/contrib/testing/common/testId'; import { ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; @@ -29,6 +29,9 @@ export interface IMainThreadTestController { expandTest(id: string, levels: number): Promise; startContinuousRun(request: ICallProfileRunHandler[], token: CancellationToken): Promise; runTests(request: IStartControllerTests[], token: CancellationToken): Promise; + provideTestFollowups(req: TestMessageFollowupRequest, token: CancellationToken): Promise; + executeTestFollowup(id: number): Promise; + disposeTestFollowups(ids: number[]): void; } export interface IMainThreadTestCollection extends AbstractIncrementalTestCollection { @@ -213,14 +216,6 @@ export const testsUnderUri = async function* (testService: ITestService, ident: } }; -/** - * An instance of the RootProvider should be registered for each extension - * host. - */ -export interface ITestRootProvider { - // todo: nothing, yet -} - /** * A run request that expresses the intent of the request and allows the * test service to resolve the specifics of the group. @@ -236,6 +231,15 @@ export interface AmbiguousRunTestsRequest { continuous?: boolean; } +export interface ITestFollowup { + message: string; + execute(): Promise; +} + +export interface ITestFollowups extends IDisposable { + followups: ITestFollowup[]; +} + export interface ITestService { readonly _serviceBrand: undefined; /** @@ -304,6 +308,11 @@ export interface ITestService { */ runResolvedTests(req: ResolvedTestRunRequest, token?: CancellationToken): Promise; + /** + * Provides followup actions for a test run. + */ + provideTestFollowups(req: TestMessageFollowupRequest, token: CancellationToken): Promise; + /** * Ensures the test diff from the remote ext host is flushed and waits for * any "busy" tests to become idle before resolving. diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 04692c5dc81..2c34de5858e 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -27,8 +27,8 @@ import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingC import { canUseProfileWithTest, ITestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService'; import { ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; -import { AmbiguousRunTestsRequest, IMainThreadTestController, ITestService } from 'vs/workbench/contrib/testing/common/testService'; -import { ResolvedTestRunRequest, TestDiffOpType, TestsDiff } from 'vs/workbench/contrib/testing/common/testTypes'; +import { AmbiguousRunTestsRequest, IMainThreadTestController, ITestFollowups, ITestService } from 'vs/workbench/contrib/testing/common/testService'; +import { ResolvedTestRunRequest, TestDiffOpType, TestMessageFollowupRequest, TestsDiff } from 'vs/workbench/contrib/testing/common/testTypes'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; export class TestService extends Disposable implements ITestService { @@ -264,6 +264,32 @@ export class TestService extends Disposable implements ITestService { } } + /** + * @inheritdoc + */ + public async provideTestFollowups(req: TestMessageFollowupRequest, token: CancellationToken): Promise { + const reqs = await Promise.all([...this.testControllers.values()] + .map(async ctrl => ({ ctrl, followups: await ctrl.provideTestFollowups(req, token) }))); + + const followups: ITestFollowups = { + followups: reqs.flatMap(({ ctrl, followups }) => followups.map(f => ({ + message: f.title, + execute: () => ctrl.executeTestFollowup(f.id) + }))), + dispose: () => { + for (const { ctrl, followups } of reqs) { + ctrl.disposeTestFollowups(followups.map(f => f.id)); + } + } + }; + + if (token.isCancellationRequested) { + followups.dispose(); + } + + return followups; + } + /** * @inheritdoc */ diff --git a/src/vs/workbench/contrib/testing/common/testTypes.ts b/src/vs/workbench/contrib/testing/common/testTypes.ts index d1f779a475d..75f7b371362 100644 --- a/src/vs/workbench/contrib/testing/common/testTypes.ts +++ b/src/vs/workbench/contrib/testing/common/testTypes.ts @@ -474,6 +474,20 @@ export const applyTestItemUpdate = (internal: InternalTestItem | ITestItemUpdate } }; +/** Request to an ext host to get followup messages for a test failure. */ +export interface TestMessageFollowupRequest { + resultId: string; + extId: string; + taskIndex: number; + messageIndex: number; +} + +/** Request to an ext host to get followup messages for a test failure. */ +export interface TestMessageFollowupResponse { + id: number; + title: string; +} + /** * Test result item used in the main thread. */ diff --git a/src/vscode-dts/vscode.proposed.testObserver.d.ts b/src/vscode-dts/vscode.proposed.testObserver.d.ts index d4465affbf2..cb8091c887e 100644 --- a/src/vscode-dts/vscode.proposed.testObserver.d.ts +++ b/src/vscode-dts/vscode.proposed.testObserver.d.ts @@ -15,6 +15,11 @@ declare module 'vscode' { */ export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable; + /** + * Registers a provider that can provide follow-up actions for a test failure. + */ + export function registerTestFollowupProvider(provider: TestFollowupProvider): Disposable; + /** * Returns an observer that watches and can request tests. */ @@ -31,6 +36,10 @@ declare module 'vscode' { export const onDidChangeTestResults: Event; } + export interface TestFollowupProvider { + provideFollowup(result: TestRunResult, test: TestResultSnapshot, taskIndex: number, messageIndex: number, token: CancellationToken): ProviderResult; + } + export interface TestObserver { /** * List of tests returned by test provider for files in the workspace.