diff --git a/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts b/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts index d22cb023c67..960dbcf634e 100644 --- a/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts +++ b/.vscode/extensions/vscode-selfhost-test-provider/src/extension.ts @@ -41,16 +41,15 @@ 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' - // }]; - // }, - // })); + context.subscriptions.push(vscode.tests.registerTestFollowupProvider({ + async provideFollowup(_result, test, taskIndex, messageIndex, _token) { + return [{ + title: '$(sparkle) Ask copilot for help', + command: 'github.copilot.tests.fixTestFailure', + arguments: [{ source: 'peekFollowup', test, message: test.taskStates[taskIndex].messages[messageIndex] }] + }]; + }, + })); ctrl.resolveHandler = async test => { diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index 0402df1b5fb..a3641b6687a 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -44,6 +44,12 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh super(); this.proxy = extHostContext.getProxy(ExtHostContext.ExtHostTesting); + this._register(this.testService.registerExtHost({ + provideTestFollowups: (req, token) => this.proxy.$provideTestFollowups(req, token), + executeTestFollowup: id => this.proxy.$executeTestFollowup(id), + disposeTestFollowups: ids => this.proxy.$disposeTestFollowups(ids), + })); + this._register(this.testService.onDidCancelTestRun(({ runId }) => { this.proxy.$cancelExtensionTestRun(runId); })); @@ -233,9 +239,6 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh runTests: (reqs, token) => 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/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index e37cf2c6815..5fa30f14ebd 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -277,6 +277,9 @@ overflow: hidden; pointer-events: none; background: linear-gradient(transparent, var(--vscode-peekViewEditor-background) 50%); + display: flex; + align-items: center; + gap: 14px; &.animated { animation: fadeIn 150ms ease-out; @@ -289,6 +292,7 @@ cursor: pointer; pointer-events: auto; width: fit-content; + flex-shrink: 0; &, .codicon { color: var(--vscode-textLink-foreground); diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index afbb0811a1c..a05b7d50fe0 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -69,6 +69,7 @@ import { WorkbenchCompressibleObjectTree } from 'vs/platform/list/browser/listSe import { INotificationService } from 'vs/platform/notification/common/notification'; import { IOpenerService } from 'vs/platform/opener/common/opener'; import { IProgressService } from 'vs/platform/progress/common/progress'; +import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities'; @@ -780,6 +781,7 @@ class FollowupActionWidget extends Disposable { constructor( private readonly container: HTMLElement, @ITestService private readonly testService: ITestService, + @IQuickInputService private readonly quickInput: IQuickInputService, ) { super(); } @@ -794,6 +796,12 @@ class FollowupActionWidget extends Disposable { private async showMessage(subject: MessageSubject) { const cts = this.visibleStore.add(new CancellationTokenSource()); const start = Date.now(); + + // Wait for completion otherwise results will not be available to the ext host: + if (subject.result instanceof LiveTestResult && !subject.result.completedAt) { + await new Promise(r => Event.once((subject.result as LiveTestResult).onComplete)(r)); + } + const followups = await this.testService.provideTestFollowups({ extId: subject.test.extId, messageIndex: subject.messageIndex, @@ -811,20 +819,10 @@ class FollowupActionWidget extends Disposable { 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.el.root.appendChild(this.makeFollowupLink(followups.followups[0])); + if (followups.followups.length > 1) { + this.el.root.appendChild(this.makeMoreLink(followups.followups)); } this.container.appendChild(this.el.root); @@ -833,6 +831,42 @@ class FollowupActionWidget extends Disposable { })); } + private makeFollowupLink(first: ITestFollowup) { + const link = this.makeLink(() => this.actionFollowup(link, first)); + dom.reset(link, ...renderLabelWithIcons(first.message)); + return link; + } + + private makeMoreLink(followups: ITestFollowup[]) { + const link = this.makeLink(() => + this.quickInput.pick(followups.map((f, i) => ({ + label: f.message, + index: i + }))).then(picked => { + if (picked?.length) { + followups[picked[0].index].execute(); + } + }) + ); + + link.innerText = localize('testFollowup.more', '+{0} More...', followups.length - 1); + return link; + } + + private makeLink(onClick: () => void) { + const link = document.createElement('a'); + link.tabIndex = 0; + this.visibleStore.add(dom.addDisposableListener(link, 'click', onClick)); + this.visibleStore.add(dom.addDisposableListener(link, 'keydown', e => { + const event = new StandardKeyboardEvent(e); + if (event.equals(KeyCode.Space) || event.equals(KeyCode.Enter)) { + onClick(); + } + })); + + return link; + } + private actionFollowup(link: HTMLAnchorElement, fu: ITestFollowup) { if (link.ariaDisabled !== 'true') { link.ariaDisabled = 'true'; diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts index 09f008fa4fe..d3026db22eb 100644 --- a/src/vs/workbench/contrib/testing/common/testService.ts +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -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; +} + +export interface IMainThreadTestHostProxy { provideTestFollowups(req: TestMessageFollowupRequest, token: CancellationToken): Promise; executeTestFollowup(id: number): Promise; disposeTestFollowups(ids: number[]): void; @@ -273,6 +276,11 @@ export interface ITestService { */ readonly showInlineOutput: MutableObservableValue; + /** + * Registers an interface that represents an extension host.. + */ + registerExtHost(controller: IMainThreadTestHostProxy): IDisposable; + /** * Registers an interface that runs tests for the given provider ID. */ diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 2c34de5858e..fd3ffcd0999 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -27,13 +27,14 @@ 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, ITestFollowups, ITestService } from 'vs/workbench/contrib/testing/common/testService'; +import { AmbiguousRunTestsRequest, IMainThreadTestController, IMainThreadTestHostProxy, 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 { declare readonly _serviceBrand: undefined; private testControllers = new Map(); + private testExtHosts = new Set(); private readonly cancelExtensionTestRunEmitter = new Emitter<{ runId: string | undefined }>(); private readonly willProcessDiffEmitter = new Emitter(); @@ -268,8 +269,8 @@ 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 reqs = await Promise.all([...this.testExtHosts].map(async ctrl => + ({ ctrl, followups: await ctrl.provideTestFollowups(req, token) }))); const followups: ITestFollowups = { followups: reqs.flatMap(({ ctrl, followups }) => followups.map(f => ({ @@ -351,6 +352,14 @@ export class TestService extends Disposable implements ITestService { this.isRefreshingTests.set(false); } + /** + * @inheritdoc + */ + registerExtHost(controller: IMainThreadTestHostProxy): IDisposable { + this.testExtHosts.add(controller); + return toDisposable(() => this.testExtHosts.delete(controller)); + } + /** * @inheritdoc */