testing: exploratory UI for followup actions

This adds an API that extensions can use to contribute 'followup'
actions around test messages. Here just a dummy hello world using
copilot, but extensions could have any action here, such as actions to
update snapshots if a test failed:

![](https://memes.peet.io/img/24-05-c1f3e073-a2da-4f16-a033-c8f7e5cd4864.png)

Implemented using a simple provider API.
This commit is contained in:
Connor Peet 2024-05-21 16:09:08 -07:00
parent 71e25d9b3c
commit e1dfc911ce
No known key found for this signature in database
GPG key ID: CF8FD2EA0DBC61BD
12 changed files with 315 additions and 34 deletions

View file

@ -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<FileChangeEvent>();
// 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[] = []
) => {

View file

@ -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 {

View file

@ -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<string, {
@ -233,6 +233,9 @@ 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)));

View file

@ -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);

View file

@ -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<void>;
/** Requests coverage details for a test run. Errors if not available. */
@ -2719,6 +2717,17 @@ export interface ExtHostTestingShape {
$syncTests(): Promise<void>;
/** Sets the active test run profiles */
$setDefaultRunProfiles(profiles: Record</* controller id */string, /* profile id */ number[]>): 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<TestMessageFollowupResponse[]>;
/** Actions a followup actions for a test (failure) message */
$executeTestFollowup(id: number): Promise<void>;
/** Disposes followup actions for a test (failure) message */
$disposeTestFollowups(id: number[]): void;
}
export interface ExtHostLocalizationShape {

View file

@ -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</* controllerId */ string, Map< /* profileId */number, boolean>>;
let followupCounter = 0;
const testResultInternalIDs = new WeakMap<vscode.TestRunResult, string>();
export class ExtHostTesting extends Disposable implements ExtHostTestingShape {
private readonly resultsChangedEmitter = this._register(new Emitter<void>());
protected readonly controllers = new Map</* controller ID */ string, ControllerInfo>();
@ -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<DefaultProfileChangeEvent>());
private readonly followupProviders = new Set<vscode.TestFollowupProvider>();
private readonly testFollowups = new Map<number, vscode.Command>();
public onResultsChanged = this.resultsChangedEmitter.event;
public results: ReadonlyArray<vscode.TestRunResult> = [];
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<TestMessageFollowupResponse[]> {
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<void> {
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<IStartControllerTestsResult> {
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<vscode.TestResultSnapshot>[]) {
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;
}

View file

@ -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;

View file

@ -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<string>;
private contextKeyResultOutdated!: IContextKey<boolean>;
@ -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);
});

View file

@ -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<void>;
startContinuousRun(request: ICallProfileRunHandler[], token: CancellationToken): Promise<IStartControllerTestsResult[]>;
runTests(request: IStartControllerTests[], token: CancellationToken): Promise<IStartControllerTestsResult[]>;
provideTestFollowups(req: TestMessageFollowupRequest, token: CancellationToken): Promise<TestMessageFollowupResponse[]>;
executeTestFollowup(id: number): Promise<void>;
disposeTestFollowups(ids: number[]): void;
}
export interface IMainThreadTestCollection extends AbstractIncrementalTestCollection<IncrementalTestCollectionItem> {
@ -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<void>;
}
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<ITestResult>;
/**
* Provides followup actions for a test run.
*/
provideTestFollowups(req: TestMessageFollowupRequest, token: CancellationToken): Promise<ITestFollowups>;
/**
* Ensures the test diff from the remote ext host is flushed and waits for
* any "busy" tests to become idle before resolving.

View file

@ -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<ITestFollowups> {
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
*/

View file

@ -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.
*/

View file

@ -15,6 +15,11 @@ declare module 'vscode' {
*/
export function runTests(run: TestRunRequest, token?: CancellationToken): Thenable<void>;
/**
* 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<void>;
}
export interface TestFollowupProvider {
provideFollowup(result: TestRunResult, test: TestResultSnapshot, taskIndex: number, messageIndex: number, token: CancellationToken): ProviderResult<Command[]>;
}
export interface TestObserver {
/**
* List of tests returned by test provider for files in the workspace.