diff --git a/extensions/testing-editor-contributions/package.json b/extensions/testing-editor-contributions/package.json index 03f0ecf5c6b..1576d3c0f38 100644 --- a/extensions/testing-editor-contributions/package.json +++ b/extensions/testing-editor-contributions/package.json @@ -32,7 +32,7 @@ "testing.enableProblemDiagnostics": { "description": "%config.enableProblemDiagnostics%", "type": "boolean", - "default": true + "default": false } } } diff --git a/extensions/testing-editor-contributions/src/extension.ts b/extensions/testing-editor-contributions/src/extension.ts index d1fc266ddcd..fc6ab30b1ff 100644 --- a/extensions/testing-editor-contributions/src/extension.ts +++ b/extensions/testing-editor-contributions/src/extension.ts @@ -45,7 +45,7 @@ class TestingConfig implements IDisposable { } public get diagnostics() { - return this.section.get(Constants.EnableDiagnosticsConfig, true); + return this.section.get(Constants.EnableDiagnosticsConfig, false); } public get isEnabled() { diff --git a/src/vs/editor/contrib/peekView/peekView.ts b/src/vs/editor/contrib/peekView/peekView.ts index a18a32bf05c..36db5b2b3d8 100644 --- a/src/vs/editor/contrib/peekView/peekView.ts +++ b/src/vs/editor/contrib/peekView/peekView.ts @@ -106,6 +106,7 @@ export abstract class PeekViewWidget extends ZoneWidget { private readonly _onDidClose = new Emitter(); readonly onDidClose = this._onDidClose.event; + private disposed?: true; protected _headElement?: HTMLDivElement; protected _primaryHeading?: HTMLElement; @@ -124,8 +125,11 @@ export abstract class PeekViewWidget extends ZoneWidget { } dispose(): void { - super.dispose(); - this._onDidClose.fire(this); + if (!this.disposed) { + this.disposed = true; // prevent consumers who dispose on onDidClose from looping + super.dispose(); + this._onDidClose.fire(this); + } } style(styles: IPeekViewStyles): void { diff --git a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts index 0effb45c4ba..65d81d0741d 100644 --- a/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts +++ b/src/vs/workbench/contrib/debug/browser/breakpointEditorContribution.ts @@ -172,6 +172,24 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi this.setDecorationsScheduler.schedule(); } + /** + * Returns context menu actions at the line number if breakpoints can be + * set. This is used by the {@link TestingDecorations} to allow breakpoint + * setting on lines where breakpoint "run" actions are present. + */ + public getContextMenuActionsAtPosition(lineNumber: number, model: ITextModel) { + if (!this.debugService.getAdapterManager().hasDebuggers()) { + return []; + } + + if (!this.debugService.canSetBreakpointsIn(model)) { + return []; + } + + const breakpoints = this.debugService.getModel().getBreakpoints({ lineNumber, uri: model.uri }); + return this.getContextMenuActions(breakpoints, model.uri, lineNumber); + } + private registerListeners(): void { this.toDispose.push(this.editor.onMouseDown(async (e: IEditorMouseEvent) => { if (!this.debugService.getAdapterManager().hasDebuggers()) { @@ -376,7 +394,8 @@ export class BreakpointEditorContribution implements IBreakpointEditorContributi const decorations = this.editor.getLineDecorations(line); if (decorations) { for (const { options } of decorations) { - if (options.glyphMarginClassName && options.glyphMarginClassName.indexOf('codicon-') === -1) { + const clz = options.glyphMarginClassName; + if (clz && (!clz.includes('codicon-') || clz.includes('codicon-testing-'))) { return false; } } diff --git a/src/vs/workbench/contrib/debug/common/debug.ts b/src/vs/workbench/contrib/debug/common/debug.ts index 835ee034693..f028a0ecb58 100644 --- a/src/vs/workbench/contrib/debug/common/debug.ts +++ b/src/vs/workbench/contrib/debug/common/debug.ts @@ -25,6 +25,7 @@ import { IConfigurationService, ConfigurationTarget } from 'vs/platform/configur import { CancellationToken } from 'vs/base/common/cancellation'; import { DebugConfigurationProviderTriggerKind } from 'vs/workbench/api/common/extHostTypes'; import { DebugCompoundRoot } from 'vs/workbench/contrib/debug/common/debugCompoundRoot'; +import { IAction } from 'vs/base/common/actions'; export const VIEWLET_ID = 'workbench.view.debug'; @@ -951,6 +952,7 @@ export interface IDebugEditorContribution extends editorCommon.IEditorContributi export interface IBreakpointEditorContribution extends editorCommon.IEditorContribution { showBreakpointWidget(lineNumber: number, column: number | undefined, context?: BreakpointWidgetContext): void; closeBreakpointWidget(): void; + getContextMenuActionsAtPosition(lineNumber: number, model: EditorIModel): IAction[]; } // temporary debug helper service diff --git a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts index 106e3315f76..e1858ddb9ee 100644 --- a/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts +++ b/src/vs/workbench/contrib/testing/browser/explorerProjections/hierarchalByLocation.ts @@ -46,14 +46,8 @@ export class HierarchicalByLocationProjection extends Disposable implements ITes this._register(listener.onFolderChange(this.applyFolderChange, this)); for (const [folder, collection] of listener.workspaceFolderCollections) { - const queue = [collection.rootIds]; - while (queue.length) { - for (const id of queue.pop()!) { - const node = collection.getNodeById(id)!; - const item = this.createItem(node, folder.folder); - this.storeItem(item); - queue.push(node.children); - } + for (const node of collection.all) { + this.storeItem(this.createItem(node, folder.folder)); } } diff --git a/src/vs/workbench/contrib/testing/browser/icons.ts b/src/vs/workbench/contrib/testing/browser/icons.ts index 52a2064b916..62df586f8d3 100644 --- a/src/vs/workbench/contrib/testing/browser/icons.ts +++ b/src/vs/workbench/contrib/testing/browser/icons.ts @@ -8,11 +8,11 @@ import { localize } from 'vs/nls'; import { registerIcon } from 'vs/platform/theme/common/iconRegistry'; import { registerThemingParticipant, ThemeIcon } from 'vs/platform/theme/common/themeService'; import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; -import { testStatesToIconColors } from 'vs/workbench/contrib/testing/browser/theme'; +import { testingColorRunAction, testStatesToIconColors } from 'vs/workbench/contrib/testing/browser/theme'; export const testingViewIcon = registerIcon('test-view-icon', Codicon.beaker, localize('testViewIcon', 'View icon of the test view.')); export const testingRunIcon = registerIcon('testing-run-icon', Codicon.run, localize('testingRunIcon', 'Icon of the "run test" action.')); -export const testingRunAllIcon = registerIcon('testing-run-icon', Codicon.runAll, localize('testingRunAllIcon', 'Icon of the "run all tests" action.')); +export const testingRunAllIcon = registerIcon('testing-run-all-icon', Codicon.runAll, localize('testingRunAllIcon', 'Icon of the "run all tests" action.')); export const testingDebugIcon = registerIcon('testing-debug-icon', Codicon.debugAlt, localize('testingDebugIcon', 'Icon of the "debug test" action.')); export const testingCancelIcon = registerIcon('testing-cancel-icon', Codicon.close, localize('testingCancelIcon', 'Icon to cancel ongoing test runs.')); @@ -24,7 +24,7 @@ export const testingStatesToIcons = new Map([ [TestRunState.Failed, registerIcon('testing-failed-icon', Codicon.close, localize('testingFailedIcon', 'Icon shown for tests that failed.'))], [TestRunState.Passed, registerIcon('testing-passed-icon', Codicon.pass, localize('testingPassedIcon', 'Icon shown for tests that passed.'))], [TestRunState.Queued, registerIcon('testing-queued-icon', Codicon.watch, localize('testingQueuedIcon', 'Icon shown for tests that are queued.'))], - [TestRunState.Running, Codicon.loading], + [TestRunState.Running, ThemeIcon.modify(Codicon.loading, 'spin')], [TestRunState.Skipped, registerIcon('testing-skipped-icon', Codicon.debugStepOver, localize('testingSkippedIcon', 'Icon shown for tests that are skipped.'))], [TestRunState.Unset, registerIcon('testing-unset-icon', Codicon.circleOutline, localize('testingUnsetIcon', 'Icon shown for tests that are in an unset state.'))], ]); @@ -39,4 +39,11 @@ registerThemingParticipant((theme, collector) => { color: ${theme.getColor(color)} !important; }`); } + + collector.addRule(` + .monaco-editor ${ThemeIcon.asCSSSelector(testingRunIcon)}, + .monaco-editor ${ThemeIcon.asCSSSelector(testingRunAllIcon)} { + color: ${theme.getColor(testingColorRunAction)}; + } + `); }); diff --git a/src/vs/workbench/contrib/testing/browser/media/testing.css b/src/vs/workbench/contrib/testing/browser/media/testing.css index d775c55a26d..610743b06ea 100644 --- a/src/vs/workbench/contrib/testing/browser/media/testing.css +++ b/src/vs/workbench/contrib/testing/browser/media/testing.css @@ -71,7 +71,6 @@ max-height: 29px; } - /** -- filter */ .testing-filter-action-bar { flex-shrink: 0; @@ -92,3 +91,13 @@ .testing-filter-action-item .testing-filter-wrapper { flex-grow: 1; } + +/** -- decorations */ + +.monaco-editor .testing-run-glyph { + cursor: pointer; +} + +.monaco-editor .testing-inline-message { + cursor: pointer; +} diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index b8838be3b26..bb5cb0a976d 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -10,6 +10,7 @@ import { localize } from 'vs/nls'; import { Action2, MenuId } from 'vs/platform/actions/common/actions'; import { ContextKeyAndExpr, ContextKeyEqualsExpr } from 'vs/platform/contextkey/common/contextkey'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +import { INotificationService } from 'vs/platform/notification/common/notification'; import { ThemeIcon } from 'vs/platform/theme/common/themeService'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; @@ -20,10 +21,9 @@ import * as icons from 'vs/workbench/contrib/testing/browser/icons'; import { TestingExplorerView, TestingExplorerViewModel } from 'vs/workbench/contrib/testing/browser/testingExplorerView'; import { TestExplorerViewGrouping, TestExplorerViewMode, Testing } from 'vs/workbench/contrib/testing/common/constants'; import { EMPTY_TEST_RESULT, InternalTestItem, RunTestsResult, TestIdWithProvider } from 'vs/workbench/contrib/testing/common/testCollection'; -import { IWorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestService, waitForAllRoots } from 'vs/workbench/contrib/testing/common/testService'; -import { INotificationService } from 'vs/platform/notification/common/notification'; +import { IWorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; const category = localize('testing.category', 'Test'); @@ -197,18 +197,18 @@ abstract class RunOrDebugAllAllAction extends Action2 { const tests: TestIdWithProvider[] = []; await Promise.all(workspace.getWorkspace().folders.map(async (folder) => { - const handle = testService.subscribeToDiffs(ExtHostTestingResource.Workspace, folder.uri); + const ref = testService.subscribeToDiffs(ExtHostTestingResource.Workspace, folder.uri); try { - await waitForAllRoots(handle.collection); + await waitForAllRoots(ref.object); - for (const root of handle.collection.rootIds) { - const node = handle.collection.getNodeById(root); + for (const root of ref.object.rootIds) { + const node = ref.object.getNodeById(root); if (node && (this.debug ? node.item.debuggable : node.item.runnable)) { tests.push({ testId: node.id, providerId: node.providerId }); } } } finally { - handle.dispose(); + ref.dispose(); } })); diff --git a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts index be0927ed41a..2a81ba3e41d 100644 --- a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts +++ b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts @@ -5,8 +5,7 @@ import { Codicon } from 'vs/base/common/codicons'; import { KeyCode } from 'vs/base/common/keyCodes'; -import { URI } from 'vs/base/common/uri'; -import { ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorAction2, registerEditorContribution } from 'vs/editor/browser/editorExtensions'; import { localize } from 'vs/nls'; import { registerAction2 } from 'vs/platform/actions/common/actions'; @@ -20,6 +19,7 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; import { Extensions as ViewContainerExtensions, IViewContainersRegistry, IViewsRegistry, ViewContainerLocation } from 'vs/workbench/common/views'; import { testingViewIcon } from 'vs/workbench/contrib/testing/browser/icons'; +import { TestingDecorations } from 'vs/workbench/contrib/testing/browser/testingDecorations'; import { ITestExplorerFilterState, TestExplorerFilterState } from 'vs/workbench/contrib/testing/browser/testingExplorerFilter'; import { TestingExplorerView } from 'vs/workbench/contrib/testing/browser/testingExplorerView'; import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browser/testingOutputPeek'; @@ -32,7 +32,6 @@ import { ITestResultService, TestResultService } from 'vs/workbench/contrib/test import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { TestService } from 'vs/workbench/contrib/testing/common/testServiceImpl'; import { IWorkspaceTestCollectionService, WorkspaceTestCollectionService } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; -import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; import * as Action from './testExplorerActions'; @@ -99,6 +98,7 @@ registerAction2(Action.DebugAllAction); Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingContentProvider, LifecyclePhase.Eventually); registerEditorContribution(Testing.OutputPeekContributionId, TestingOutputPeekController); +registerEditorContribution(Testing.DecorationsContributionId, TestingDecorations); CommandsRegistry.registerCommand({ id: 'vscode.runTests', @@ -117,28 +117,10 @@ CommandsRegistry.registerCommand({ }); CommandsRegistry.registerCommand({ - id: 'vscode.revealTestMessage', - handler: async (accessor: ServicesAccessor, testRef: TestIdWithProvider, messageIndex: number) => { - const editorService = accessor.get(IEditorService); - const testService = accessor.get(ITestService); - - const test = await testService.lookupTest(testRef); - const message = test?.item.state.messages[messageIndex]; - if (!test || !message?.location) { - return; - } - - const pane = await editorService.openEditor({ - resource: URI.revive(message.location.uri), - options: { selection: message.location.range } - }); - - const control = pane?.getControl(); - if (!isCodeEditor(control)) { - return; - } - - TestingOutputPeekController.get(control).show(test, messageIndex); + id: 'vscode.revealTestInExplorer', + handler: async (accessor: ServicesAccessor, path: string[]) => { + accessor.get(ITestExplorerFilterState).reveal = path; + await new Action.ShowTestView().run(accessor); } }); diff --git a/src/vs/workbench/contrib/testing/browser/testingDecorations.ts b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts new file mode 100644 index 00000000000..a87ddb1adec --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testingDecorations.ts @@ -0,0 +1,328 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Action, IAction, Separator } from 'vs/base/common/actions'; +import { MarkdownString } from 'vs/base/common/htmlContent'; +import { Disposable, dispose, IDisposable, IReference, MutableDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { generateUuid } from 'vs/base/common/uuid'; +import { ICodeEditor, IEditorMouseEvent, MouseTargetType } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; +import { EditorOption } from 'vs/editor/common/config/editorOptions'; +import { IRange } from 'vs/editor/common/core/range'; +import { IEditorContribution } from 'vs/editor/common/editorCommon'; +import { IModelDeltaDecoration, OverviewRulerLane, TrackedRangeStickiness } from 'vs/editor/common/model'; +import { Location as ModeLocation } from 'vs/editor/common/modes'; +import { overviewRulerError, overviewRulerInfo, overviewRulerWarning } from 'vs/editor/common/view/editorColorRegistry'; +import { localize } from 'vs/nls'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { IThemeService, themeColorFromId, ThemeIcon } from 'vs/platform/theme/common/themeService'; +import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; +import { TestMessageSeverity, TestRunState } from 'vs/workbench/api/common/extHostTypes'; +import { BREAKPOINT_EDITOR_CONTRIBUTION_ID, IBreakpointEditorContribution } from 'vs/workbench/contrib/debug/common/debug'; +import { testingRunAllIcon, testingRunIcon, testingStatesToIcons } from 'vs/workbench/contrib/testing/browser/icons'; +import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browser/testingOutputPeek'; +import { testMessageSeverityColors } from 'vs/workbench/contrib/testing/browser/theme'; +import { IncrementalTestCollectionItem, ITestMessage } from 'vs/workbench/contrib/testing/common/testCollection'; +import { maxPriority } from 'vs/workbench/contrib/testing/common/testingStates'; +import { buildTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri'; +import { IMainThreadTestCollection, ITestService } from 'vs/workbench/contrib/testing/common/testService'; + +export class TestingDecorations extends Disposable implements IEditorContribution { + private collection = this._register(new MutableDisposable>()); + private lastDecorations: ITestDecoration[] = []; + + constructor( + private readonly editor: ICodeEditor, + @ITestService private readonly testService: ITestService, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { + super(); + this.attachModel(editor.getModel()?.uri); + this._register(this.editor.onDidChangeModel(e => this.attachModel(e.newModelUrl || undefined))); + this._register(this.editor.onMouseDown(e => { + for (const decoration of this.lastDecorations) { + if (decoration.click(e)) { + e.event.stopPropagation(); + return; + } + } + })); + } + + private attachModel(uri?: URI) { + if (!uri) { + this.collection.value = undefined; + this.clearDecorations(); + return; + } + + this.collection.value = this.testService.subscribeToDiffs(ExtHostTestingResource.TextDocument, uri, () => this.setDecorations(uri)); + this.setDecorations(uri); + } + + private setDecorations(uri: URI): void { + const ref = this.collection.value; + if (!ref) { + return; + } + + this.editor.changeDecorations(accessor => { + const newDecorations: ITestDecoration[] = []; + for (const test of ref.object.all) { + if (hasValidLocation(uri, test.item)) { + newDecorations.push(this.instantiationService.createInstance( + RunTestDecoration, test, ref.object, test.item.location, this.editor)); + } + + for (let i = 0; i < test.item.state.messages.length; i++) { + const m = test.item.state.messages[i]; + if (hasValidLocation(uri, m)) { + const uri = buildTestUri({ + type: TestUriType.LiveMessage, + messageIndex: i, + providerId: test.providerId, + testId: test.id, + }); + + newDecorations.push(this.instantiationService.createInstance(TestMessageDecoration, m, uri, m.location, this.editor)); + } + } + } + + accessor + .deltaDecorations(this.lastDecorations.map(d => d.id), newDecorations.map(d => d.editorDecoration)) + .forEach((id, i) => newDecorations[i].id = id); + + this.lastDecorations = newDecorations; + }); + } + + private clearDecorations(): void { + this.editor.changeDecorations(accessor => { + for (const decoration of this.lastDecorations) { + accessor.removeDecoration(decoration.id); + } + + this.lastDecorations = []; + }); + } +} + +interface ITestDecoration extends IDisposable { + /** + * ID of the decoration after being added to the editor, set after the + * decoration is applied. + */ + id: string; + + readonly editorDecoration: IModelDeltaDecoration; + + /** + * Handles a click event, returns true if it was handled. + */ + click(e: IEditorMouseEvent): boolean; +} + +const hasValidLocation = (editorUri: URI, t: T): t is T & { location: ModeLocation } => + t.location?.uri.toString() === editorUri.toString(); + +const firstLineRange = (editor: ICodeEditor, originalRange: IRange) => { + const model = editor.getModel(); + const endColumn = model?.getLineMaxColumn(originalRange.startLineNumber); + return { + startLineNumber: originalRange.startLineNumber, + endLineNumber: originalRange.startLineNumber, + startColumn: 0, + endColumn: endColumn || 1, + }; +}; + +class RunTestDecoration implements ITestDecoration { + /** + * @inheritdoc + */ + id = ''; + + /** + * @inheritdoc + */ + public readonly editorDecoration: IModelDeltaDecoration; + + private line: number; + + constructor( + private readonly test: IncrementalTestCollectionItem, + private readonly collection: IMainThreadTestCollection, + private readonly location: ModeLocation, + private readonly editor: ICodeEditor, + @ITestService private readonly testService: ITestService, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @ICommandService private readonly commandService: ICommandService, + ) { + this.line = location.range.startLineNumber; + + const queue = [test.children]; + let state = this.test.item.state.runState; + while (queue.length) { + for (const child of queue.pop()!) { + const node = collection.getNodeById(child); + if (node) { + state = maxPriority(node.item.state.runState, state); + } + } + } + + const icon = state !== TestRunState.Unset + ? testingStatesToIcons.get(state)! + : test.children.size > 0 ? testingRunAllIcon : testingRunIcon; + + this.editorDecoration = { + range: firstLineRange(this.editor, this.location.range), + options: { + isWholeLine: true, + glyphMarginClassName: ThemeIcon.asClassName(icon) + ' testing-run-glyph', + stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges, + glyphMarginHoverMessage: new MarkdownString().appendText(localize('testing.clickToRun', 'Click to run tests, right click for more options')), + } + }; + } + + /** + * @inheritdoc + */ + public click(e: IEditorMouseEvent): boolean { + if (e.target.position?.lineNumber !== this.line || e.target.type !== MouseTargetType.GUTTER_GLYPH_MARGIN) { + return false; + } + + if (e.event.rightButton) { + const actions = this.getContextMenu(); + this.contextMenuService.showContextMenu({ + getAnchor: () => ({ x: e.event.posx, y: e.event.posy }), + getActions: () => actions, + onHide: () => dispose(actions), + }); + } else { + // todo: customize click behavior + this.testService.runTests({ tests: [{ testId: this.test.id, providerId: this.test.providerId }], debug: false }); + } + + return true; + } + + public dispose() { + // no-op + } + + private getContextMenu() { + const model = this.editor.getModel(); + if (!model) { + return []; + } + + const testActions: IAction[] = []; + if (this.test.item.runnable) { + testActions.push(new Action('testing.run', localize('run test', 'Run Test'), undefined, undefined, () => this.testService.runTests({ + debug: false, + tests: [{ providerId: this.test.providerId, testId: this.test.id }], + }))); + } + + if (this.test.item.debuggable) { + testActions.push(new Action('testing.debug', localize('debug test', 'Debug Test'), undefined, undefined, () => this.testService.runTests({ + debug: true, + tests: [{ providerId: this.test.providerId, testId: this.test.id }], + }))); + } + + testActions.push(new Action('testing.reveal', localize('reveal test', 'Reveal in Test Explorer'), undefined, undefined, async () => { + const path = []; + for (let id: string | null = this.test.id; id;) { + const node = this.collection.getNodeById(id); + if (!node) { + break; + } + + path.unshift(node.item.label); + id = node.parent; + } + + await this.commandService.executeCommand('vscode.revealTestInExplorer', this.test); + })); + + const breakpointActions = this.editor + .getContribution(BREAKPOINT_EDITOR_CONTRIBUTION_ID) + .getContextMenuActionsAtPosition(this.line, model); + + return breakpointActions.length ? [...testActions, new Separator(), ...breakpointActions] : testActions; + } +} + +class TestMessageDecoration implements ITestDecoration { + public id = ''; + + public readonly editorDecoration: IModelDeltaDecoration; + private readonly decorationId = `testmessage-${generateUuid()}`; + + constructor( + { message, severity }: ITestMessage, + private readonly messageUri: URI, + location: ModeLocation, + private readonly editor: ICodeEditor, + @ICodeEditorService private readonly editorService: ICodeEditorService, + @IThemeService themeService: IThemeService, + ) { + severity = severity || TestMessageSeverity.Error; + + const colorTheme = themeService.getColorTheme(); + editorService.registerDecorationType(this.decorationId, { + after: { + contentText: message.toString(), + color: `${colorTheme.getColor(testMessageSeverityColors[severity].decorationForeground)}`, + fontSize: `${editor.getOption(EditorOption.fontSize)}px`, + fontFamily: editor.getOption(EditorOption.fontFamily), + padding: `0px 12px`, + }, + }, undefined, editor); + + const options = editorService.resolveDecorationOptions(this.decorationId, true); + options.hoverMessage = typeof message === 'string' ? new MarkdownString().appendText(message) : message; + options.afterContentClassName = `${options.afterContentClassName} testing-inline-message`; + options.zIndex = 10; // todo: in spite of the z-index, this appears behind gitlens + + const rulerColor = severity === TestMessageSeverity.Error + ? overviewRulerError + : severity === TestMessageSeverity.Warning + ? overviewRulerWarning + : severity === TestMessageSeverity.Information + ? overviewRulerInfo + : undefined; + + if (rulerColor) { + options.overviewRuler = { color: themeColorFromId(rulerColor), position: OverviewRulerLane.Right }; + } + + this.editorDecoration = { range: firstLineRange(editor, location.range), options }; + } + + click(e: IEditorMouseEvent): boolean { + if (e.event.rightButton) { + return false; + } + + if (e.target.element?.className.includes(this.decorationId)) { + TestingOutputPeekController.get(this.editor).show(this.messageUri); + } + + return false; + } + + dispose(): void { + this.editorService.removeDecorationType(this.decorationId); + } +} diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts index 285b1608d92..69adeb763d7 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts @@ -27,19 +27,34 @@ import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingC export interface ITestExplorerFilterState { _serviceBrand: undefined; readonly onDidChange: Event; + readonly onDidRequestReveal: Event; value: string; + reveal: string[] | undefined; } export const ITestExplorerFilterState = createDecorator('testingFilterState'); export class TestExplorerFilterState implements ITestExplorerFilterState { declare _serviceBrand: undefined; - + private readonly revealRequest = new Emitter(); private readonly changeEmitter = new Emitter(); private _value = ''; + private _reveal?: string[]; + public readonly onDidRequestReveal = this.revealRequest.event; public readonly onDidChange = this.changeEmitter.event; + public get reveal() { + return this._reveal; + } + + public set reveal(v: string[] | undefined) { + this._reveal = v; + if (v !== undefined) { + this.revealRequest.fire(v); + } + } + public get value() { return this._value; } diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts index 47d9690bdef..be3344549aa 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerView.ts @@ -56,6 +56,7 @@ import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browse import { TestExplorerViewGrouping, TestExplorerViewMode, Testing } from 'vs/workbench/contrib/testing/common/constants'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { cmpPriority, isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; +import { buildTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri'; import { ITestResultService, sumCounts, TestStateCount } from 'vs/workbench/contrib/testing/common/testResultService'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { IWorkspaceTestCollectionService, TestSubscriptionListener } from 'vs/workbench/contrib/testing/common/workspaceTestCollectionService'; @@ -388,7 +389,13 @@ export class TestingExplorerViewModel extends Disposable { return false; } - TestingOutputPeekController.get(control).show(item.test, index); + TestingOutputPeekController.get(control).show(buildTestUri({ + type: TestUriType.LiveMessage, + messageIndex: index, + providerId: item.test.providerId, + testId: item.test.id, + })); + return true; } @@ -648,10 +655,6 @@ class TestsRenderer implements ITreeRenderer(); /** * Context key updated when the peek is visible/hidden. @@ -45,6 +56,8 @@ export class TestingOutputPeekController implements IEditorContribution { constructor( private readonly editor: ICodeEditor, @IInstantiationService private readonly instantiationService: IInstantiationService, + @ITestResultService private readonly testResults: ITestResultService, + @ITestService private readonly testService: ITestService, @IContextKeyService contextKeyService: IContextKeyService, ) { this.visible = TestingContextKeys.peekVisible.bindTo(contextKeyService); @@ -60,54 +73,92 @@ export class TestingOutputPeekController implements IEditorContribution { /** * Shows a peek for the message in th editor. */ - public async show(test: InternalTestItem, messageIndex: number) { - const message = test?.item.state.messages[messageIndex]; - if (!test || !message?.location) { + public async show(uri: URI) { + const dto = await this.retrieveTest(uri); + if (!dto) { return; } - if (!this.peek) { - this.peek = this.instantiationService.createInstance(TestingOutputPeek, this.editor); + const message = dto.test.item.state.messages[dto.messageIndex]; + if (!message?.location) { + return; } - this.visible.set(true); - this.peek.setModel(test, messageIndex); - this.peek.onDidClose(() => { - this.visible.set(false); - this.peek = undefined; - }); + const ctor = message.actualOutput !== undefined && message.expectedOutput !== undefined + ? TestingDiffOutputPeek : TestingMessageOutputPeek; + const isNew = !(this.peek.value instanceof ctor); + if (isNew) { + this.peek.value = this.instantiationService.createInstance(ctor, this.editor); + this.peek.value.onDidClose(() => { + this.visible.set(false); + this.peek.value = undefined; + }); + } + + if (isNew) { + this.visible.set(true); + this.peek.value!.create(); + } + + this.peek.value!.setModel(dto); } /** * Disposes the peek view, if any. */ public removePeek() { - if (this.peek) { - this.peek.dispose(); - this.peek = undefined; + this.peek.value = undefined; + } + + private async retrieveTest(uri: URI): Promise { + const parts = parseTestUri(uri); + if (!parts) { + return undefined; } + + if ('resultId' in parts) { + const test = this.testResults.lookup(parts.resultId)?.tests.find(t => t.id === parts.testId); + return test && { + test, + messageIndex: parts.messageIndex, + expectedUri: buildTestUri({ ...parts, type: TestUriType.ResultExpectedOutput }), + actualUri: buildTestUri({ ...parts, type: TestUriType.ResultActualOutput }), + messageUri: buildTestUri({ ...parts, type: TestUriType.ResultMessage }), + }; + } + + const test = await this.testService.lookupTest({ providerId: parts.providerId, testId: parts.testId }); + if (!test) { + return; + } + + return { + test, + messageIndex: parts.messageIndex, + expectedUri: buildTestUri({ ...parts, type: TestUriType.LiveActualOutput }), + actualUri: buildTestUri({ ...parts, type: TestUriType.LiveExpectedOutput }), + messageUri: buildTestUri({ ...parts, type: TestUriType.LiveMessage }), + }; } } -export class TestingOutputPeek extends PeekViewWidget { - private readonly disposable = new DisposableStore(); - private diff?: EmbeddedDiffEditorWidget; - private model?: IDisposable; - private dimension?: dom.Dimension; +abstract class TestingOutputPeek extends PeekViewWidget { + protected model = new MutableDisposable(); + protected dimension?: dom.Dimension; constructor( editor: ICodeEditor, @IThemeService themeService: IThemeService, @IPeekViewService peekViewService: IPeekViewService, @IInstantiationService protected readonly instantiationService: IInstantiationService, - @ITextModelService private readonly modelService: ITextModelService, + @ITextModelService protected readonly modelService: ITextModelService, ) { super(editor, { showFrame: false, showArrow: true, isResizeable: true, isAccessible: true, className: 'test-output-peek' }, instantiationService); this._disposables.add(themeService.onDidColorThemeChange(this.applyTheme, this)); + this._disposables.add(this.model); this.applyTheme(themeService.getColorTheme()); peekViewService.addExclusiveWidget(editor, this); - this.create(); } private applyTheme(theme: IColorTheme) { @@ -121,67 +172,83 @@ export class TestingOutputPeek extends PeekViewWidget { }); } + /** + * Updates the test to be shown. + */ + public abstract setModel(dto: ITestDto): Promise; + /** * @override */ - public dispose() { - super.dispose(); - this.model?.dispose(); - this.disposable.dispose(); + protected _doLayoutBody(height: number, width: number) { + super._doLayoutBody(height, width); + this.dimension = new dom.Dimension(width, height); } +} + +const commonEditorOptions: IEditorOptions = { + scrollBeyondLastLine: false, + scrollbar: { + verticalScrollbarSize: 14, + horizontal: 'auto', + useShadows: true, + verticalHasArrows: false, + horizontalHasArrows: false, + alwaysConsumeMouseWheel: false + }, + fixedOverflowWidgets: true, + readOnly: true, + minimap: { + enabled: false + }, +}; + +const diffEditorOptions: IDiffEditorOptions = { + ...commonEditorOptions, + enableSplitViewResizing: true, + isInEmbeddedEditor: true, + renderOverviewRuler: false, + ignoreTrimWhitespace: false, + renderSideBySide: true, +}; + +class TestingDiffOutputPeek extends TestingOutputPeek { + private readonly diff = this._disposables.add(new MutableDisposable()); /** * @override */ protected _fillBody(containerElement: HTMLElement): void { const diffContainer = dom.append(containerElement, dom.$('div.preview.inline')); - let options: IDiffEditorOptions = { - scrollBeyondLastLine: false, - scrollbar: { - verticalScrollbarSize: 14, - horizontal: 'auto', - useShadows: true, - verticalHasArrows: false, - horizontalHasArrows: false, - alwaysConsumeMouseWheel: false - }, - fixedOverflowWidgets: true, - readOnly: true, - minimap: { - enabled: false - }, - enableSplitViewResizing: true, - isInEmbeddedEditor: true, - renderOverviewRuler: false, - ignoreTrimWhitespace: false, - renderSideBySide: true, - }; - - const preview = this.diff = this.instantiationService.createInstance(EmbeddedDiffEditorWidget, diffContainer, options, this.editor); - this.disposable.add(preview); + const preview = this.diff.value = this.instantiationService.createInstance(EmbeddedDiffEditorWidget, diffContainer, diffEditorOptions, this.editor); if (this.dimension) { preview.layout(this.dimension); } } - public async setModel(test: InternalTestItem, messageIndex: number) { + /** + * @override + */ + public async setModel({ test, messageIndex, expectedUri, actualUri }: ITestDto) { const message = test.item.state.messages[messageIndex]; if (!message?.location) { return; } - this.show(message.location.range, hintPeekHeight(message)); - - if (this.model) { - this.model.dispose(); - } - + this.show(message.location.range, hintDiffPeekHeight(message)); this.setTitle(message.message.toString(), test.item.label); - if (message.actualOutput !== undefined && message.expectedOutput !== undefined) { - await this.showDiffInEditor(test, messageIndex); + + const [original, modified] = await Promise.all([ + this.modelService.createModelReference(expectedUri), + this.modelService.createModelReference(actualUri), + ]); + + const model = this.model.value = new SimpleDiffEditorModel(original, modified); + if (!this.diff.value) { + this.model.value = undefined; } else { - await this.showMessageInEditor(test, messageIndex); + this.diff.value.setModel(model); } } @@ -190,37 +257,58 @@ export class TestingOutputPeek extends PeekViewWidget { */ protected _doLayoutBody(height: number, width: number) { super._doLayoutBody(height, width); - this.dimension = new dom.Dimension(width, height); - this.diff?.layout(this.dimension); - } - - private async showMessageInEditor(test: InternalTestItem, messageIndex: number) { - // todo? not sure if this is a useful experience - this.model?.dispose(); - this.diff?.setModel(null); - } - - private async showDiffInEditor(test: InternalTestItem, messageIndex: number) { - const uriParts = { messageIndex, testId: test.id, providerId: test.providerId }; - const [original, modified] = await Promise.all([ - this.modelService.createModelReference(buildTestUri({ ...uriParts, type: TestUriType.ExpectedOutput })), - this.modelService.createModelReference(buildTestUri({ ...uriParts, type: TestUriType.ActualOutput })), - ]); - - this.model?.dispose(); - const model = this.model = new SimpleDiffEditorModel(original, modified); - if (!this.diff) { - model.dispose(); - } else { - this.diff.setModel(model); - } + this.diff.value?.layout(this.dimension); } } -const hintPeekHeight = (message: ITestMessage) => { - const lines = Math.max(count(message.actualOutput || '', '\n'), count(message.expectedOutput || '', '\n')); - return clamp(lines, 5, 20); -}; +class TestingMessageOutputPeek extends TestingOutputPeek { + private readonly preview = this._disposables.add(new MutableDisposable()); + + /** + * @override + */ + protected _fillBody(containerElement: HTMLElement): void { + const diffContainer = dom.append(containerElement, dom.$('div.preview.inline')); + const preview = this.preview.value = this.instantiationService.createInstance(EmbeddedCodeEditorWidget, diffContainer, commonEditorOptions, this.editor); + + if (this.dimension) { + preview.layout(this.dimension); + } + } + + /** + * @override + */ + public async setModel({ test, messageIndex, messageUri }: ITestDto) { + const message = test.item.state.messages[messageIndex]; + if (!message?.location) { + return; + } + + this.show(message.location.range, hintPeekStrHeight(message.message.toString())); + this.setTitle(message.message.toString(), test.item.label); + + const modelRef = this.model.value = await this.modelService.createModelReference(messageUri); + if (this.preview.value) { + this.preview.value.setModel(modelRef.object.textEditorModel); + } else { + this.model.value = undefined; + } + } + + /** + * @override + */ + protected _doLayoutBody(height: number, width: number) { + super._doLayoutBody(height, width); + this.preview.value?.layout(this.dimension); + } +} + +const hintDiffPeekHeight = (message: ITestMessage) => + Math.max(hintPeekStrHeight(message.actualOutput), hintPeekStrHeight(message.expectedOutput)); + +const hintPeekStrHeight = (str: string | undefined) => clamp(count(str || '', '\n'), 5, 20); class SimpleDiffEditorModel extends EditorModel { public readonly original = this._original.object.textEditorModel; diff --git a/src/vs/workbench/contrib/testing/browser/theme.ts b/src/vs/workbench/contrib/testing/browser/theme.ts index be142f697d9..b124f75819f 100644 --- a/src/vs/workbench/contrib/testing/browser/theme.ts +++ b/src/vs/workbench/contrib/testing/browser/theme.ts @@ -4,8 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { localize } from 'vs/nls'; -import { editorErrorForeground, registerColor } from 'vs/platform/theme/common/colorRegistry'; -import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; +import { editorErrorForeground, editorForeground, editorHintForeground, editorInfoForeground, editorWarningForeground, registerColor } from 'vs/platform/theme/common/colorRegistry'; +import { TestMessageSeverity, TestRunState } from 'vs/workbench/api/common/extHostTypes'; export const testingColorIconFailed = registerColor('testing.iconFailed', { dark: '#f14c4c', @@ -19,13 +19,18 @@ export const testingColorIconErrored = registerColor('testing.iconErrored', { hc: '#000000' }, localize('testing.iconErrored', "Color for the 'Errored' icon in the test explorer.")); - export const testingColorIconPassed = registerColor('testing.iconPassed', { dark: '#73c991', light: '#73c991', hc: '#000000' }, localize('testing.iconPassed', "Color for the 'passed' icon in the test explorer.")); +export const testingColorRunAction = registerColor('testing.runAction', { + dark: testingColorIconPassed, + light: testingColorIconPassed, + hc: testingColorIconPassed +}, localize('testing.runAction', "Color for 'run' icons in the editor.")); + export const testingColorIconQueued = registerColor('testing.iconQueued', { dark: '#cca700', light: '#cca700', @@ -50,6 +55,41 @@ export const testingPeekBorder = registerColor('testing.peekBorder', { hc: editorErrorForeground, }, localize('testing.peekBorder', 'Color of the peek view borders and arrow.')); +export const testMessageSeverityColors: { + [K in TestMessageSeverity]: { + decorationForeground: string, + }; +} = { + [TestMessageSeverity.Error]: { + decorationForeground: registerColor( + 'testing.message.error.decorationForeground', + { dark: editorErrorForeground, light: editorErrorForeground, hc: editorForeground }, + localize('testing.message.error.decorationForeground', 'Text color of test error messages shown inline in the editor.') + ), + }, + [TestMessageSeverity.Warning]: { + decorationForeground: registerColor( + 'testing.message.warning.decorationForeground', + { dark: editorWarningForeground, light: editorWarningForeground, hc: editorForeground }, + localize('testing.message.warning.decorationForeground', 'Text color of test warning messages shown inline in the editor.') + ), + }, + [TestMessageSeverity.Information]: { + decorationForeground: registerColor( + 'testing.message.info.decorationForeground', + { dark: editorInfoForeground, light: editorInfoForeground, hc: editorForeground }, + localize('testing.message.info.decorationForeground', 'Text color of test info messages shown inline in the editor.') + ), + }, + [TestMessageSeverity.Hint]: { + decorationForeground: registerColor( + 'testing.message.hint.decorationForeground', + { dark: editorHintForeground, light: editorHintForeground, hc: editorForeground }, + localize('testing.message.hint.decorationForeground', 'Text color of test hint messages shown inline in the editor.') + ), + }, +}; + export const testStatesToIconColors: { [K in TestRunState]?: string } = { [TestRunState.Errored]: testingColorIconErrored, [TestRunState.Failed]: testingColorIconFailed, diff --git a/src/vs/workbench/contrib/testing/common/constants.ts b/src/vs/workbench/contrib/testing/common/constants.ts index 1e0eab6ce37..d828393d01c 100644 --- a/src/vs/workbench/contrib/testing/common/constants.ts +++ b/src/vs/workbench/contrib/testing/common/constants.ts @@ -11,6 +11,7 @@ export const enum Testing { ViewletId = 'workbench.view.extension.test', ExplorerViewId = 'workbench.view.testing', OutputPeekContributionId = 'editor.contrib.testingOutputPeek', + DecorationsContributionId = 'editor.contrib.testingDecorations', FilterActionId = 'workbench.actions.treeView.testExplorer.filter', } diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index 0c8cbd4c0ab..d2235b4bcfc 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter, Event } from 'vs/base/common/event'; +import { generateUuid } from 'vs/base/common/uuid'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { TestRunState } from 'vs/workbench/api/common/extHostTypes'; @@ -88,6 +89,11 @@ export class TestResult { public onChange = this.changeEmitter.event; public onComplete = this.completeEmitter.event; + /** + * Unique ID for referring to this set of test results. + */ + public readonly id = generateUuid(); + /** * Gets whether the test run has finished. */ @@ -175,11 +181,16 @@ export interface ITestResultService { * Adds a new test result to the collection. */ push(result: TestResult): TestResult; + + /** + * Looks up a set of test results by ID. + */ + lookup(resultId: string): TestResult | undefined; } export const ITestResultService = createDecorator('testResultService'); -const RETAIN_LAST_RESULTS = 10; +const RETAIN_LAST_RESULTS = 16; export class TestResultService implements ITestResultService { declare _serviceBrand: undefined; @@ -216,6 +227,13 @@ export class TestResultService implements ITestResultService { return result; } + /** + * @inheritdoc + */ + public lookup(id: string) { + return this.results.find(r => r.id === id); + } + private onComplete() { // move the complete test run down behind any still-running ones for (let i = 0; i < this.results.length - 2; i++) { diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts index c922f3e6156..d9fbf494aad 100644 --- a/src/vs/workbench/contrib/testing/common/testService.ts +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -5,7 +5,7 @@ import { CancellationToken } from 'vs/base/common/cancellation'; import { Event } from 'vs/base/common/event'; -import { IDisposable } from 'vs/base/common/lifecycle'; +import { IDisposable, IReference } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; @@ -39,6 +39,11 @@ export interface IMainThreadTestCollection extends AbstractIncrementalTestCollec */ rootIds: ReadonlySet; + /** + * Iterates over every test in the collection. + */ + all: Iterable; + /** * Gets a node in the collection by ID. */ @@ -82,10 +87,7 @@ export interface ITestService { runTests(req: RunTestsRequest, token?: CancellationToken): Promise; cancelTestRun(req: RunTestsRequest): void; publishDiff(resource: ExtHostTestingResource, uri: URI, diff: TestsDiff): void; - subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): { - collection: IMainThreadTestCollection; - dispose(): void; - }; + subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): IReference; /** * Updates the number of sources who provide test roots when subscription diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index 288540017a9..7ff73345c35 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -7,7 +7,7 @@ import { groupBy } from 'vs/base/common/arrays'; import { disposableTimeout } from 'vs/base/common/async'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; -import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, IReference } from 'vs/base/common/lifecycle'; import { isDefined } from 'vs/base/common/types'; import { URI, UriComponents } from 'vs/base/common/uri'; import { localize } from 'vs/nls'; @@ -131,7 +131,7 @@ export class TestService extends Disposable implements ITestService { const subscriptions = [...this.testSubscriptions.values()] .filter(v => req.tests.some(t => v.collection.getNodeById(t.testId))) .map(s => this.subscribeToDiffs(s.ident.resource, s.ident.uri, () => result?.notifyChanged())); - result = this.testResults.push(TestResult.from(subscriptions.map(s => s.collection), req.tests)); + result = this.testResults.push(TestResult.from(subscriptions.map(s => s.object), req.tests)); try { const tests = groupBy(req.tests, (a, b) => a.providerId === b.providerId ? 0 : 1); @@ -175,7 +175,7 @@ export class TestService extends Disposable implements ITestService { /** * @inheritdoc */ - public subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener) { + public subscribeToDiffs(resource: ExtHostTestingResource, uri: URI, acceptDiff?: TestDiffListener): IReference { const subscriptionKey = getTestSubscriptionKey(resource, uri); let subscription = this.testSubscriptions.get(subscriptionKey); if (!subscription) { @@ -200,7 +200,7 @@ export class TestService extends Disposable implements ITestService { const listener = acceptDiff && subscription.onDiff.event(acceptDiff); return { - collection: subscription.collection, + object: subscription.collection, dispose: () => { listener?.dispose(); @@ -277,6 +277,14 @@ class MainThreadTestCollection extends AbstractIncrementalTestCollection t.id === parsed.testId); + if (!test) { return null; } let text: string | undefined; switch (parsed.type) { - case TestUriType.ActualOutput: + case TestUriType.ResultActualOutput: + case TestUriType.LiveActualOutput: text = test.item.state.messages[parsed.messageIndex]?.actualOutput; break; - case TestUriType.ExpectedOutput: + case TestUriType.ResultExpectedOutput: + case TestUriType.LiveExpectedOutput: text = test.item.state.messages[parsed.messageIndex]?.expectedOutput; break; - case TestUriType.Message: + case TestUriType.ResultMessage: + case TestUriType.LiveMessage: text = test.item.state.messages[parsed.messageIndex]?.message.toString(); break; } diff --git a/src/vs/workbench/contrib/testing/common/testingStates.ts b/src/vs/workbench/contrib/testing/common/testingStates.ts index 3664745ea7e..59aa0b93b17 100644 --- a/src/vs/workbench/contrib/testing/common/testingStates.ts +++ b/src/vs/workbench/contrib/testing/common/testingStates.ts @@ -22,7 +22,6 @@ export const statePriority: { [K in TestRunState]: number } = { [TestRunState.Unset]: 0, }; - export const isFailedState = (s: TestRunState) => s === TestRunState.Errored || s === TestRunState.Failed; export const stateNodes = Object.entries(statePriority).reduce( diff --git a/src/vs/workbench/contrib/testing/common/testingUri.ts b/src/vs/workbench/contrib/testing/common/testingUri.ts index dac64916d03..e66995a46cf 100644 --- a/src/vs/workbench/contrib/testing/common/testingUri.ts +++ b/src/vs/workbench/contrib/testing/common/testingUri.ts @@ -8,29 +8,54 @@ import { URI } from 'vs/base/common/uri'; export const TEST_DATA_SCHEME = 'vscode-test-data'; export const enum TestUriType { - Message, - ActualOutput, - ExpectedOutput, + LiveMessage, + LiveActualOutput, + LiveExpectedOutput, + ResultMessage, + ResultActualOutput, + ResultExpectedOutput, } -interface IGenericTestUri { +interface ILiveTestUri { providerId: string; testId: string; } -interface ITestMessageReference extends IGenericTestUri { - type: TestUriType.Message; +interface ILiveTestMessageReference extends ILiveTestUri { + type: TestUriType.LiveMessage; messageIndex: number; } -interface ITestOutputReference extends IGenericTestUri { - type: TestUriType.ActualOutput | TestUriType.ExpectedOutput; +interface ILiveTestOutputReference extends ILiveTestUri { + type: TestUriType.LiveActualOutput | TestUriType.LiveExpectedOutput; messageIndex: number; } -export type ParsedTestUri = ITestMessageReference | ITestOutputReference; +interface IResultTestUri { + resultId: string; + testId: string; +} + +interface IResultTestMessageReference extends IResultTestUri { + type: TestUriType.ResultMessage; + messageIndex: number; +} + +interface IResultTestOutputReference extends IResultTestUri { + type: TestUriType.ResultActualOutput | TestUriType.ResultExpectedOutput; + messageIndex: number; +} + +export type ParsedTestUri = + | IResultTestMessageReference + | IResultTestOutputReference + | ILiveTestMessageReference + | ILiveTestOutputReference; const enum TestUriParts { + Results = 'results', + Live = 'live', + Messages = 'message', Text = 'text', ActualOutput = 'actualOutput', @@ -38,21 +63,30 @@ const enum TestUriParts { } export const parseTestUri = (uri: URI): ParsedTestUri | undefined => { - const providerId = uri.authority; - const [testId, ...request] = uri.path.slice(1).split('/'); + const type = uri.authority; + const [locationId, testId, ...request] = uri.path.slice(1).split('/'); if (request[0] === TestUriParts.Messages) { const index = Number(request[1]); const part = request[2]; - switch (part) { - case TestUriParts.Text: - return { providerId, testId, messageIndex: index, type: TestUriType.Message }; - case TestUriParts.ActualOutput: - return { providerId, testId, messageIndex: index, type: TestUriType.ActualOutput }; - case TestUriParts.ExpectedOutput: - return { providerId, testId, messageIndex: index, type: TestUriType.ExpectedOutput }; - default: - return undefined; + if (type === TestUriParts.Results) { + switch (part) { + case TestUriParts.Text: + return { resultId: locationId, testId, messageIndex: index, type: TestUriType.ResultMessage }; + case TestUriParts.ActualOutput: + return { resultId: locationId, testId, messageIndex: index, type: TestUriType.ResultActualOutput }; + case TestUriParts.ExpectedOutput: + return { resultId: locationId, testId, messageIndex: index, type: TestUriType.ResultExpectedOutput }; + } + } else if (type === TestUriParts.Live) { + switch (part) { + case TestUriParts.Text: + return { providerId: locationId, testId, messageIndex: index, type: TestUriType.LiveMessage }; + case TestUriParts.ActualOutput: + return { providerId: locationId, testId, messageIndex: index, type: TestUriType.LiveActualOutput }; + case TestUriParts.ExpectedOutput: + return { providerId: locationId, testId, messageIndex: index, type: TestUriType.LiveExpectedOutput }; + } } } @@ -60,17 +94,29 @@ export const parseTestUri = (uri: URI): ParsedTestUri | undefined => { }; export const buildTestUri = (parsed: ParsedTestUri): URI => { - const uriParts = { scheme: TEST_DATA_SCHEME, authority: parsed.testId }; - const msgRef = (index: number, ...remaining: string[]) => - URI.from({ ...uriParts, path: ['', parsed.testId, TestUriParts.Messages, index, ...remaining].join('/') }); + const uriParts = { + scheme: TEST_DATA_SCHEME, + authority: 'resultId' in parsed ? TestUriParts.Results : TestUriParts.Live + }; + const msgRef = (locationId: string, index: number, ...remaining: string[]) => + URI.from({ + ...uriParts, + path: ['', locationId, parsed.testId, TestUriParts.Messages, index, ...remaining].join('/'), + }); switch (parsed.type) { - case TestUriType.ActualOutput: - return msgRef(parsed.messageIndex, TestUriParts.ActualOutput); - case TestUriType.ExpectedOutput: - return msgRef(parsed.messageIndex, TestUriParts.ExpectedOutput); - case TestUriType.Message: - return msgRef(parsed.messageIndex, TestUriParts.Text); + case TestUriType.ResultActualOutput: + return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.ActualOutput); + case TestUriType.ResultExpectedOutput: + return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.ExpectedOutput); + case TestUriType.ResultMessage: + return msgRef(parsed.resultId, parsed.messageIndex, TestUriParts.Text); + case TestUriType.LiveActualOutput: + return msgRef(parsed.providerId, parsed.messageIndex, TestUriParts.ActualOutput); + case TestUriType.LiveExpectedOutput: + return msgRef(parsed.providerId, parsed.messageIndex, TestUriParts.ExpectedOutput); + case TestUriType.LiveMessage: + return msgRef(parsed.providerId, parsed.messageIndex, TestUriParts.Text); default: throw new Error('Invalid test uri'); } diff --git a/src/vs/workbench/contrib/testing/common/workspaceTestCollectionService.ts b/src/vs/workbench/contrib/testing/common/workspaceTestCollectionService.ts index 02a7a14f824..e38fb6d1ae6 100644 --- a/src/vs/workbench/contrib/testing/common/workspaceTestCollectionService.ts +++ b/src/vs/workbench/contrib/testing/common/workspaceTestCollectionService.ts @@ -195,8 +195,8 @@ class TestSubscription extends Disposable { const folderNode: ITestSubscriptionFolder = { folder, getChildren: function* () { - for (const rootId of listener.collection.rootIds) { - const node = listener.collection.getNodeById(rootId); + for (const rootId of ref.object.rootIds) { + const node = ref.object.getNodeById(rootId); if (node) { yield node; } @@ -204,7 +204,7 @@ class TestSubscription extends Disposable { }, }; - const listener = this.testService.subscribeToDiffs( + const ref = this.testService.subscribeToDiffs( ExtHostTestingResource.Workspace, folder.uri, diff => { @@ -215,16 +215,15 @@ class TestSubscription extends Disposable { ); const disposable = new DisposableStore(); - disposable.add(listener); - disposable.add(listener.collection.onBusyProvidersChange( + disposable.add(ref); + disposable.add(ref.object.onBusyProvidersChange( () => this.pendingRootChangeEmitter.fire(this.pendingRootProviders))); - disposable.add(listener.collection.onBusyProvidersChange( + disposable.add(ref.object.onBusyProvidersChange( () => this.busyProvidersChangeEmitter.fire(this.busyProviders))); - this.collectionsForWorkspaces.set(folder.uri.toString(), { listener: disposable, - collection: listener.collection, + collection: ref.object, folder: folderNode, }); } diff --git a/src/vs/workbench/contrib/testing/test/common/testingUri.test.ts b/src/vs/workbench/contrib/testing/test/common/testingUri.test.ts new file mode 100644 index 00000000000..d28b37b0063 --- /dev/null +++ b/src/vs/workbench/contrib/testing/test/common/testingUri.test.ts @@ -0,0 +1,25 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as assert from 'assert'; +import { buildTestUri, ParsedTestUri, parseTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri'; + +suite('Workbench - Testing URIs', () => { + test('round trip', () => { + const uris: ParsedTestUri[] = [ + { type: TestUriType.LiveActualOutput, messageIndex: 42, providerId: 'p', testId: 't' }, + { type: TestUriType.LiveExpectedOutput, messageIndex: 42, providerId: 'p', testId: 't' }, + { type: TestUriType.LiveMessage, messageIndex: 42, providerId: 'p', testId: 't' }, + { type: TestUriType.ResultActualOutput, messageIndex: 42, resultId: 'r', testId: 't' }, + { type: TestUriType.ResultExpectedOutput, messageIndex: 42, resultId: 'r', testId: 't' }, + { type: TestUriType.ResultMessage, messageIndex: 42, resultId: 'r', testId: 't' }, + ]; + + for (const uri of uris) { + const serialized = buildTestUri(uri); + assert.deepStrictEqual(uri, parseTestUri(serialized)); + } + }); +});