diff --git a/src/vs/platform/extensions/common/extensionsApiProposals.ts b/src/vs/platform/extensions/common/extensionsApiProposals.ts index dd767bf8a07..0429fdd1510 100644 --- a/src/vs/platform/extensions/common/extensionsApiProposals.ts +++ b/src/vs/platform/extensions/common/extensionsApiProposals.ts @@ -344,6 +344,9 @@ const _allApiProposals = { terminalShellIntegration: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.terminalShellIntegration.d.ts', }, + testMessageStackTrace: { + proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testMessageStackTrace.d.ts', + }, testObserver: { proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.testObserver.d.ts', }, diff --git a/src/vs/workbench/api/common/extHost.api.impl.ts b/src/vs/workbench/api/common/extHost.api.impl.ts index b6b4f5746bb..7847141b2f1 100644 --- a/src/vs/workbench/api/common/extHost.api.impl.ts +++ b/src/vs/workbench/api/common/extHost.api.impl.ts @@ -1686,6 +1686,8 @@ export function createApiFactoryAndRegisterActors(accessor: ServicesAccessor): I TestResultState: extHostTypes.TestResultState, TestRunRequest: extHostTypes.TestRunRequest, TestMessage: extHostTypes.TestMessage, + TestMessage2: extHostTypes.TestMessage, + TestMessageStackFrame: extHostTypes.TestMessageStackFrame, TestTag: extHostTypes.TestTag, TestRunProfileKind: extHostTypes.TestRunProfileKind, TextSearchCompleteMessageType: TextSearchCompleteMessageType, diff --git a/src/vs/workbench/api/common/extHostTypeConverters.ts b/src/vs/workbench/api/common/extHostTypeConverters.ts index 6fad560a437..f3689b50932 100644 --- a/src/vs/workbench/api/common/extHostTypeConverters.ts +++ b/src/vs/workbench/api/common/extHostTypeConverters.ts @@ -1888,6 +1888,11 @@ export namespace TestMessage { actual: message.actualOutput, contextValue: message.contextValue, location: message.location && ({ range: Range.from(message.location.range), uri: message.location.uri }), + stackTrace: (message as vscode.TestMessage2).stackTrace?.map(s => ({ + label: s.label, + position: s.position && Position.from(s.position), + uri: s.file && URI.revive(s.file).toJSON(), + })), }; } diff --git a/src/vs/workbench/api/common/extHostTypes.ts b/src/vs/workbench/api/common/extHostTypes.ts index 18c2eefe79d..b757ae7e060 100644 --- a/src/vs/workbench/api/common/extHostTypes.ts +++ b/src/vs/workbench/api/common/extHostTypes.ts @@ -4069,9 +4069,11 @@ export class TestMessage implements vscode.TestMessage { public expectedOutput?: string; public actualOutput?: string; public location?: vscode.Location; - /** proposed: */ public contextValue?: string; + /** proposed: */ + public stackTrace?: TestMessageStackFrame[]; + public static diff(message: string | vscode.MarkdownString, expected: string, actual: string) { const msg = new TestMessage(message); msg.expectedOutput = expected; @@ -4087,6 +4089,19 @@ export class TestTag implements vscode.TestTag { constructor(public readonly id: string) { } } +export class TestMessageStackFrame { + /** + * @param label The name of the stack frame + * @param file The file URI of the stack frame + * @param position The position of the stack frame within the file + */ + constructor( + public label: string, + public file?: vscode.Uri, + public position?: Position, + ) { } +} + //#endregion //#region Test Coverage diff --git a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts new file mode 100644 index 00000000000..673f895b854 --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput.ts @@ -0,0 +1,545 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; +import { Delayer } from 'vs/base/common/async'; +import { VSBuffer } from 'vs/base/common/buffer'; +import { IMarkdownString } from 'vs/base/common/htmlContent'; +import { Iterable } from 'vs/base/common/iterator'; +import { Lazy } from 'vs/base/common/lazy'; +import { Disposable, IDisposable, IReference, MutableDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { URI } from 'vs/base/common/uri'; +import { ICodeEditor, IDiffEditorConstructionOptions } from 'vs/editor/browser/editorBrowser'; +import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; +import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; +import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; +import { EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/embeddedDiffEditorWidget'; +import { MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer'; +import { IDiffEditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; +import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService'; +import { peekViewResultsBackground } from 'vs/editor/contrib/peekView/browser/peekView'; +import { localize } from 'vs/nls'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { TerminalCapability } from 'vs/platform/terminal/common/capabilities/capabilities'; +import { TerminalCapabilityStore } from 'vs/platform/terminal/common/capabilities/terminalCapabilityStore'; +import { formatMessageForTerminal } from 'vs/platform/terminal/common/terminalStrings'; +import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; +import { EditorModel } from 'vs/workbench/common/editor/editorModel'; +import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; +import { IViewDescriptorService, ViewContainerLocation } from 'vs/workbench/common/views'; +import { DetachedProcessInfo } from 'vs/workbench/contrib/terminal/browser/detachedTerminal'; +import { IDetachedTerminalInstance, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { getXtermScaledDimensions } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal'; +import { TERMINAL_BACKGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; +import { colorizeTestMessageInEditor } from 'vs/workbench/contrib/testing/browser/testMessageColorizer'; +import { InspectSubject, MessageSubject, TaskSubject, TestOutputSubject } from 'vs/workbench/contrib/testing/browser/testResultsView/testResultsSubject'; +import { Testing } from 'vs/workbench/contrib/testing/common/constants'; +import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; +import { ITaskRawOutput, ITestResult, ITestRunTaskResults, LiveTestResult, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; +import { ITestMessage, TestMessageType, getMarkId } from 'vs/workbench/contrib/testing/common/testTypes'; + + +class SimpleDiffEditorModel extends EditorModel { + public readonly original = this._original.object.textEditorModel; + public readonly modified = this._modified.object.textEditorModel; + + constructor( + private readonly _original: IReference, + private readonly _modified: IReference, + ) { + super(); + } + + public override dispose() { + super.dispose(); + this._original.dispose(); + this._modified.dispose(); + } +} + + +export interface IPeekOutputRenderer extends IDisposable { + /** Updates the displayed test. Should clear if it cannot display the test. */ + update(subject: InspectSubject): void; + /** Recalculate content layout. */ + layout(dimension: dom.IDimension): void; + /** Dispose the content provider. */ + dispose(): void; +} + +const commonEditorOptions: IEditorOptions = { + scrollBeyondLastLine: false, + links: true, + lineNumbers: 'off', + scrollbar: { + verticalScrollbarSize: 14, + horizontal: 'auto', + useShadows: true, + verticalHasArrows: false, + horizontalHasArrows: false, + alwaysConsumeMouseWheel: false + }, + fixedOverflowWidgets: true, + readOnly: true, + minimap: { + enabled: false + }, + wordWrap: 'on', +}; + +const diffEditorOptions: IDiffEditorConstructionOptions = { + ...commonEditorOptions, + enableSplitViewResizing: true, + isInEmbeddedEditor: true, + renderOverviewRuler: false, + ignoreTrimWhitespace: false, + renderSideBySide: true, + useInlineViewWhenSpaceIsLimited: false, + originalAriaLabel: localize('testingOutputExpected', 'Expected result'), + modifiedAriaLabel: localize('testingOutputActual', 'Actual result'), + diffAlgorithm: 'advanced', +}; + + +export class DiffContentProvider extends Disposable implements IPeekOutputRenderer { + private readonly widget = this._register(new MutableDisposable()); + private readonly model = this._register(new MutableDisposable()); + private dimension?: dom.IDimension; + + constructor( + private readonly editor: ICodeEditor | undefined, + private readonly container: HTMLElement, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ITextModelService private readonly modelService: ITextModelService, + ) { + super(); + } + + public async update(subject: InspectSubject) { + if (!(subject instanceof MessageSubject)) { + return this.clear(); + } + const message = subject.message; + if (!ITestMessage.isDiffable(message)) { + return this.clear(); + } + + const [original, modified] = await Promise.all([ + this.modelService.createModelReference(subject.expectedUri), + this.modelService.createModelReference(subject.actualUri), + ]); + + const model = this.model.value = new SimpleDiffEditorModel(original, modified); + if (!this.widget.value) { + this.widget.value = this.editor ? this.instantiationService.createInstance( + EmbeddedDiffEditorWidget, + this.container, + diffEditorOptions, + {}, + this.editor, + ) : this.instantiationService.createInstance( + DiffEditorWidget, + this.container, + diffEditorOptions, + {}, + ); + + if (this.dimension) { + this.widget.value.layout(this.dimension); + } + } + + this.widget.value.setModel(model); + this.widget.value.updateOptions(this.getOptions( + isMultiline(message.expected) || isMultiline(message.actual) + )); + } + + private clear() { + this.model.clear(); + this.widget.clear(); + } + + public layout(dimensions: dom.IDimension) { + this.dimension = dimensions; + this.widget.value?.layout(dimensions); + } + + protected getOptions(isMultiline: boolean): IDiffEditorOptions { + return isMultiline + ? { ...diffEditorOptions, lineNumbers: 'on' } + : { ...diffEditorOptions, lineNumbers: 'off' }; + } +} + +class ScrollableMarkdownMessage extends Disposable { + private readonly scrollable: DomScrollableElement; + private readonly element: HTMLElement; + + constructor(container: HTMLElement, markdown: MarkdownRenderer, message: IMarkdownString) { + super(); + + const rendered = this._register(markdown.render(message, {})); + rendered.element.style.height = '100%'; + rendered.element.style.userSelect = 'text'; + container.appendChild(rendered.element); + this.element = rendered.element; + + this.scrollable = this._register(new DomScrollableElement(rendered.element, { + className: 'preview-text', + })); + container.appendChild(this.scrollable.getDomNode()); + + this._register(toDisposable(() => { + this.scrollable.getDomNode().remove(); + })); + + this.scrollable.scanDomNode(); + } + + public layout(height: number, width: number) { + // Remove padding of `.monaco-editor .zone-widget.test-output-peek .preview-text` + this.scrollable.setScrollDimensions({ + width: width - 32, + height: height - 16, + scrollWidth: this.element.scrollWidth, + scrollHeight: this.element.scrollHeight + }); + } +} + +export class MarkdownTestMessagePeek extends Disposable implements IPeekOutputRenderer { + private readonly markdown = new Lazy( + () => this._register(this.instantiationService.createInstance(MarkdownRenderer, {})), + ); + + private readonly textPreview = this._register(new MutableDisposable()); + + constructor(private readonly container: HTMLElement, @IInstantiationService private readonly instantiationService: IInstantiationService) { + super(); + } + + public update(subject: InspectSubject): void { + if (!(subject instanceof MessageSubject)) { + return this.textPreview.clear(); + } + + const message = subject.message; + if (ITestMessage.isDiffable(message) || typeof message.message === 'string') { + return this.textPreview.clear(); + } + + this.textPreview.value = new ScrollableMarkdownMessage( + this.container, + this.markdown.value, + message.message as IMarkdownString, + ); + } + + public layout(dimension: dom.IDimension): void { + this.textPreview.value?.layout(dimension.height, dimension.width); + } +} + +export class PlainTextMessagePeek extends Disposable implements IPeekOutputRenderer { + private readonly widgetDecorations = this._register(new MutableDisposable()); + private readonly widget = this._register(new MutableDisposable()); + private readonly model = this._register(new MutableDisposable()); + private dimension?: dom.IDimension; + + constructor( + private readonly editor: ICodeEditor | undefined, + private readonly container: HTMLElement, + @IInstantiationService private readonly instantiationService: IInstantiationService, + @ITextModelService private readonly modelService: ITextModelService, + ) { + super(); + } + + public async update(subject: InspectSubject) { + if (!(subject instanceof MessageSubject)) { + return this.clear(); + } + + const message = subject.message; + if (ITestMessage.isDiffable(message) || message.type === TestMessageType.Output || typeof message.message !== 'string') { + return this.clear(); + } + + const modelRef = this.model.value = await this.modelService.createModelReference(subject.messageUri); + if (!this.widget.value) { + this.widget.value = this.editor ? this.instantiationService.createInstance( + EmbeddedCodeEditorWidget, + this.container, + commonEditorOptions, + {}, + this.editor, + ) : this.instantiationService.createInstance( + CodeEditorWidget, + this.container, + commonEditorOptions, + { isSimpleWidget: true } + ); + + if (this.dimension) { + this.widget.value.layout(this.dimension); + } + } + + this.widget.value.setModel(modelRef.object.textEditorModel); + this.widget.value.updateOptions(commonEditorOptions); + this.widgetDecorations.value = colorizeTestMessageInEditor(message.message, this.widget.value); + } + + private clear() { + this.widgetDecorations.clear(); + this.widget.clear(); + this.model.clear(); + } + + public layout(dimensions: dom.IDimension) { + this.dimension = dimensions; + this.widget.value?.layout(dimensions); + } +} + +export class TerminalMessagePeek extends Disposable implements IPeekOutputRenderer { + private dimensions?: dom.IDimension; + private readonly terminalCwd = this._register(new MutableObservableValue('')); + private readonly xtermLayoutDelayer = this._register(new Delayer(50)); + + /** Active terminal instance. */ + private readonly terminal = this._register(new MutableDisposable()); + /** Listener for streaming result data */ + private readonly outputDataListener = this._register(new MutableDisposable()); + + constructor( + private readonly container: HTMLElement, + private readonly isInPeekView: boolean, + @ITerminalService private readonly terminalService: ITerminalService, + @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, + @IWorkspaceContextService private readonly workspaceContext: IWorkspaceContextService, + ) { + super(); + } + + private async makeTerminal() { + const prev = this.terminal.value; + if (prev) { + prev.xterm.clearBuffer(); + prev.xterm.clearSearchDecorations(); + // clearBuffer tries to retain the prompt line, but this doesn't exist for tests. + // So clear the screen (J) and move to home (H) to ensure previous data is cleaned up. + prev.xterm.write(`\x1b[2J\x1b[0;0H`); + return prev; + } + + const capabilities = new TerminalCapabilityStore(); + const cwd = this.terminalCwd; + capabilities.add(TerminalCapability.CwdDetection, { + type: TerminalCapability.CwdDetection, + get cwds() { return [cwd.value]; }, + onDidChangeCwd: cwd.onDidChange, + getCwd: () => cwd.value, + updateCwd: () => { }, + }); + + return this.terminal.value = await this.terminalService.createDetachedTerminal({ + rows: 10, + cols: 80, + readonly: true, + capabilities, + processInfo: new DetachedProcessInfo({ initialCwd: cwd.value }), + colorProvider: { + getBackgroundColor: theme => { + const terminalBackground = theme.getColor(TERMINAL_BACKGROUND_COLOR); + if (terminalBackground) { + return terminalBackground; + } + if (this.isInPeekView) { + return theme.getColor(peekViewResultsBackground); + } + const location = this.viewDescriptorService.getViewLocationById(Testing.ResultsViewId); + return location === ViewContainerLocation.Panel + ? theme.getColor(PANEL_BACKGROUND) + : theme.getColor(SIDE_BAR_BACKGROUND); + }, + } + }); + } + + public async update(subject: InspectSubject) { + this.outputDataListener.clear(); + if (subject instanceof TaskSubject) { + await this.updateForTaskSubject(subject); + } else if (subject instanceof TestOutputSubject || (subject instanceof MessageSubject && subject.message.type === TestMessageType.Output)) { + await this.updateForTestSubject(subject); + } else { + this.clear(); + } + } + + private async updateForTestSubject(subject: TestOutputSubject | MessageSubject) { + const that = this; + const testItem = subject instanceof TestOutputSubject ? subject.test.item : subject.test; + const terminal = await this.updateGenerically({ + subject, + noOutputMessage: localize('caseNoOutput', 'The test case did not report any output.'), + getTarget: result => result?.tasks[subject.taskIndex].output, + *doInitialWrite(output, results) { + that.updateCwd(testItem.uri); + const state = subject instanceof TestOutputSubject ? subject.test : results.getStateById(testItem.extId); + if (!state) { + return; + } + + for (const message of state.tasks[subject.taskIndex].messages) { + if (message.type === TestMessageType.Output) { + yield* output.getRangeIter(message.offset, message.length); + } + } + }, + doListenForMoreData: (output, result, write) => result.onChange(e => { + if (e.reason === TestResultItemChangeReason.NewMessage && e.item.item.extId === testItem.extId && e.message.type === TestMessageType.Output) { + for (const chunk of output.getRangeIter(e.message.offset, e.message.length)) { + write(chunk.buffer); + } + } + }), + }); + + if (subject instanceof MessageSubject && subject.message.type === TestMessageType.Output && subject.message.marker !== undefined) { + terminal?.xterm.selectMarkedRange(getMarkId(subject.message.marker, true), getMarkId(subject.message.marker, false), /* scrollIntoView= */ true); + } + } + + private updateForTaskSubject(subject: TaskSubject) { + return this.updateGenerically({ + subject, + noOutputMessage: localize('runNoOutput', 'The test run did not record any output.'), + getTarget: result => result?.tasks[subject.taskIndex], + doInitialWrite: (task, result) => { + // Update the cwd and use the first test to try to hint at the correct cwd, + // but often this will fall back to the first workspace folder. + this.updateCwd(Iterable.find(result.tests, t => !!t.item.uri)?.item.uri); + return task.output.buffers; + }, + doListenForMoreData: (task, _result, write) => task.output.onDidWriteData(e => write(e.buffer)), + }); + } + + private async updateGenerically(opts: { + subject: InspectSubject; + noOutputMessage: string; + getTarget: (result: ITestResult) => T | undefined; + doInitialWrite: (target: T, result: LiveTestResult) => Iterable; + doListenForMoreData: (target: T, result: LiveTestResult, write: (s: Uint8Array) => void) => IDisposable; + }) { + const result = opts.subject.result; + const target = opts.getTarget(result); + if (!target) { + return this.clear(); + } + + const terminal = await this.makeTerminal(); + let didWriteData = false; + + const pendingWrites = new MutableObservableValue(0); + if (result instanceof LiveTestResult) { + for (const chunk of opts.doInitialWrite(target, result)) { + didWriteData ||= chunk.byteLength > 0; + pendingWrites.value++; + terminal.xterm.write(chunk.buffer, () => pendingWrites.value--); + } + } else { + didWriteData = true; + this.writeNotice(terminal, localize('runNoOutputForPast', 'Test output is only available for new test runs.')); + } + + this.attachTerminalToDom(terminal); + this.outputDataListener.clear(); + + if (result instanceof LiveTestResult && !result.completedAt) { + const l1 = result.onComplete(() => { + if (!didWriteData) { + this.writeNotice(terminal, opts.noOutputMessage); + } + }); + const l2 = opts.doListenForMoreData(target, result, data => { + terminal.xterm.write(data); + didWriteData ||= data.byteLength > 0; + }); + + this.outputDataListener.value = combinedDisposable(l1, l2); + } + + if (!this.outputDataListener.value && !didWriteData) { + this.writeNotice(terminal, opts.noOutputMessage); + } + + // Ensure pending writes finish, otherwise the selection in `updateForTestSubject` + // can happen before the markers are processed. + if (pendingWrites.value > 0) { + await new Promise(resolve => { + const l = pendingWrites.onDidChange(() => { + if (pendingWrites.value === 0) { + l.dispose(); + resolve(); + } + }); + }); + } + + return terminal; + } + + private updateCwd(testUri?: URI) { + const wf = (testUri && this.workspaceContext.getWorkspaceFolder(testUri)) + || this.workspaceContext.getWorkspace().folders[0]; + if (wf) { + this.terminalCwd.value = wf.uri.fsPath; + } + } + + private writeNotice(terminal: IDetachedTerminalInstance, str: string) { + terminal.xterm.write(formatMessageForTerminal(str)); + } + + private attachTerminalToDom(terminal: IDetachedTerminalInstance) { + terminal.xterm.write('\x1b[?25l'); // hide cursor + dom.scheduleAtNextAnimationFrame(dom.getWindow(this.container), () => this.layoutTerminal(terminal)); + terminal.attachToElement(this.container, { enableGpu: false }); + } + + private clear() { + this.outputDataListener.clear(); + this.xtermLayoutDelayer.cancel(); + this.terminal.clear(); + } + + public layout(dimensions: dom.IDimension) { + this.dimensions = dimensions; + if (this.terminal.value) { + this.layoutTerminal(this.terminal.value, dimensions.width, dimensions.height); + } + } + + private layoutTerminal( + { xterm }: IDetachedTerminalInstance, + width = this.dimensions?.width ?? this.container.clientWidth, + height = this.dimensions?.height ?? this.container.clientHeight + ) { + width -= 10 + 20; // scrollbar width + margin + this.xtermLayoutDelayer.trigger(() => { + const scaled = getXtermScaledDimensions(dom.getWindow(this.container), xterm.getFont(), width, height); + if (scaled) { + xterm.resize(scaled.cols, scaled.rows); + } + }); + } +} + +const isMultiline = (str: string | undefined) => !!str && str.includes('\n'); diff --git a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsSubject.ts b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsSubject.ts new file mode 100644 index 00000000000..6296138f985 --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsSubject.ts @@ -0,0 +1,94 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + + +import { MarshalledId } from 'vs/base/common/marshallingIds'; +import { URI } from 'vs/base/common/uri'; +import { Range } from 'vs/editor/common/core/range'; +import { ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; +import { IRichLocation, ITestItem, ITestMessage, ITestMessageMenuArgs, ITestRunTask, ITestTaskState, InternalTestItem, TestMessageType, TestResultItem } from 'vs/workbench/contrib/testing/common/testTypes'; +import { TestUriType, buildTestUri } from 'vs/workbench/contrib/testing/common/testingUri'; + +export const getMessageArgs = (test: TestResultItem, message: ITestMessage): ITestMessageMenuArgs => ({ + $mid: MarshalledId.TestMessageMenuArgs, + test: InternalTestItem.serialize(test), + message: ITestMessage.serialize(message), +}); + +export class MessageSubject { + public readonly test: ITestItem; + public readonly message: ITestMessage; + public readonly expectedUri: URI; + public readonly actualUri: URI; + public readonly messageUri: URI; + public readonly revealLocation: IRichLocation | undefined; + public readonly context: ITestMessageMenuArgs | undefined; + + public get isDiffable() { + return this.message.type === TestMessageType.Error && ITestMessage.isDiffable(this.message); + } + + public get contextValue() { + return this.message.type === TestMessageType.Error ? this.message.contextValue : undefined; + } + + constructor(public readonly result: ITestResult, test: TestResultItem, public readonly taskIndex: number, public readonly messageIndex: number) { + this.test = test.item; + const messages = test.tasks[taskIndex].messages; + this.messageIndex = messageIndex; + + const parts = { messageIndex, resultId: result.id, taskIndex, testExtId: test.item.extId }; + this.expectedUri = buildTestUri({ ...parts, type: TestUriType.ResultExpectedOutput }); + this.actualUri = buildTestUri({ ...parts, type: TestUriType.ResultActualOutput }); + this.messageUri = buildTestUri({ ...parts, type: TestUriType.ResultMessage }); + + const message = this.message = messages[this.messageIndex]; + this.context = getMessageArgs(test, message); + this.revealLocation = message.location ?? (test.item.uri && test.item.range ? { uri: test.item.uri, range: Range.lift(test.item.range) } : undefined); + } +} + +export class TaskSubject { + public readonly outputUri: URI; + public readonly revealLocation: undefined; + + constructor(public readonly result: ITestResult, public readonly taskIndex: number) { + this.outputUri = buildTestUri({ resultId: result.id, taskIndex, type: TestUriType.TaskOutput }); + } +} + +export class TestOutputSubject { + public readonly outputUri: URI; + public readonly revealLocation: undefined; + public readonly task: ITestRunTask; + + constructor(public readonly result: ITestResult, public readonly taskIndex: number, public readonly test: TestResultItem) { + this.outputUri = buildTestUri({ resultId: this.result.id, taskIndex: this.taskIndex, testExtId: this.test.item.extId, type: TestUriType.TestOutput }); + this.task = result.tasks[this.taskIndex]; + } +} + +export type InspectSubject = MessageSubject | TaskSubject | TestOutputSubject; + +export const equalsSubject = (a: InspectSubject, b: InspectSubject) => ( + (a instanceof MessageSubject && b instanceof MessageSubject && a.message === b.message) || + (a instanceof TaskSubject && b instanceof TaskSubject && a.result === b.result && a.taskIndex === b.taskIndex) || + (a instanceof TestOutputSubject && b instanceof TestOutputSubject && a.test === b.test && a.taskIndex === b.taskIndex) +); + + +export const mapFindTestMessage = (test: TestResultItem, fn: (task: ITestTaskState, message: ITestMessage, messageIndex: number, taskIndex: number) => T | undefined) => { + for (let taskIndex = 0; taskIndex < test.tasks.length; taskIndex++) { + const task = test.tasks[taskIndex]; + for (let messageIndex = 0; messageIndex < task.messages.length; messageIndex++) { + const r = fn(task, task.messages[messageIndex], messageIndex, taskIndex); + if (r !== undefined) { + return r; + } + } + } + + return undefined; +}; diff --git a/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts new file mode 100644 index 00000000000..f6aa368c619 --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testResultsView/testResultsTree.ts @@ -0,0 +1,853 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as dom from 'vs/base/browser/dom'; +import { ActionBar } from 'vs/base/browser/ui/actionbar/actionbar'; +import { renderLabelWithIcons } from 'vs/base/browser/ui/iconLabel/iconLabels'; +import { IIdentityProvider } from 'vs/base/browser/ui/list/list'; +import { ICompressedTreeElement, ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; +import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; +import { ITreeContextMenuEvent, ITreeNode } from 'vs/base/browser/ui/tree/tree'; +import { Action, IAction, Separator } from 'vs/base/common/actions'; +import { RunOnceScheduler } from 'vs/base/common/async'; +import { Codicon } from 'vs/base/common/codicons'; +import { Emitter, Event } from 'vs/base/common/event'; +import { FuzzyScore } from 'vs/base/common/filters'; +import { Iterable } from 'vs/base/common/iterator'; +import { Disposable, DisposableStore } from 'vs/base/common/lifecycle'; +import { MarshalledId } from 'vs/base/common/marshallingIds'; +import { autorun } from 'vs/base/common/observable'; +import { count } from 'vs/base/common/strings'; +import { ThemeIcon } from 'vs/base/common/themables'; +import { isDefined } from 'vs/base/common/types'; +import { URI } from 'vs/base/common/uri'; +import { localize } from 'vs/nls'; +import { MenuEntryActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; +import { ICommandService } from 'vs/platform/commands/common/commands'; +import { IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; +import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; +import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { WorkbenchCompressibleObjectTree } from 'vs/platform/list/browser/listService'; +import { IProgressService } from 'vs/platform/progress/common/progress'; +import { widgetClose } from 'vs/platform/theme/common/iconRegistry'; +import { getTestItemContextOverlay } from 'vs/workbench/contrib/testing/browser/explorerProjections/testItemContextOverlay'; +import * as icons from 'vs/workbench/contrib/testing/browser/icons'; +import { renderTestMessageAsText } from 'vs/workbench/contrib/testing/browser/testMessageColorizer'; +import { TestOutputSubject, InspectSubject, TaskSubject, MessageSubject, mapFindTestMessage, getMessageArgs } from 'vs/workbench/contrib/testing/browser/testResultsView/testResultsSubject'; +import { Testing } from 'vs/workbench/contrib/testing/common/constants'; +import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; +import { ITestExplorerFilterState } from 'vs/workbench/contrib/testing/common/testExplorerFilterState'; +import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService'; +import { ITestResult, ITestRunTaskResults, LiveTestResult, TestResultItemChangeReason, maxCountPriority } from 'vs/workbench/contrib/testing/common/testResult'; +import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; +import { IRichLocation, ITestItemContext, ITestMessage, ITestMessageMenuArgs, InternalTestItem, TestMessageType, TestResultItem, TestResultState, TestRunProfileBitset, testResultStateToContextValues } from 'vs/workbench/contrib/testing/common/testTypes'; +import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; +import { cmpPriority } from 'vs/workbench/contrib/testing/common/testingStates'; +import { TestUriType, buildTestUri } from 'vs/workbench/contrib/testing/common/testingUri'; +import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; + + +interface ITreeElement { + type: string; + context: unknown; + id: string; + label: string; + onDidChange: Event; + labelWithIcons?: readonly (HTMLSpanElement | string)[]; + icon?: ThemeIcon; + description?: string; + ariaLabel?: string; +} + +interface ITreeElement { + type: string; + context: unknown; + id: string; + label: string; + onDidChange: Event; + labelWithIcons?: readonly (HTMLSpanElement | string)[]; + icon?: ThemeIcon; + description?: string; + ariaLabel?: string; +} + +class TestResultElement implements ITreeElement { + public readonly changeEmitter = new Emitter(); + public readonly onDidChange = this.changeEmitter.event; + public readonly type = 'result'; + public readonly context = this.value.id; + public readonly id = this.value.id; + public readonly label = this.value.name; + + public get icon() { + return icons.testingStatesToIcons.get( + this.value.completedAt === undefined + ? TestResultState.Running + : maxCountPriority(this.value.counts) + ); + } + + constructor(public readonly value: ITestResult) { } +} + +const openCoverageLabel = localize('openTestCoverage', 'View Test Coverage'); +const closeCoverageLabel = localize('closeTestCoverage', 'Close Test Coverage'); + +class CoverageElement implements ITreeElement { + public readonly type = 'coverage'; + public readonly context: undefined; + public readonly id = `coverage-${this.results.id}/${this.task.id}`; + public readonly onDidChange: Event; + + public get label() { + return this.isOpen ? closeCoverageLabel : openCoverageLabel; + } + + public get icon() { + return this.isOpen ? widgetClose : icons.testingCoverageReport; + } + + public get isOpen() { + return this.coverageService.selected.get()?.fromTaskId === this.task.id; + } + + constructor( + private readonly results: ITestResult, + public readonly task: ITestRunTaskResults, + private readonly coverageService: ITestCoverageService, + ) { + this.onDidChange = Event.fromObservableLight(coverageService.selected); + } + +} + +class TestCaseElement implements ITreeElement { + public readonly type = 'test'; + public readonly context: ITestItemContext = { + $mid: MarshalledId.TestItemContext, + tests: [InternalTestItem.serialize(this.test)], + }; + public readonly id = `${this.results.id}/${this.test.item.extId}`; + public readonly description?: string; + + public get onDidChange() { + if (!(this.results instanceof LiveTestResult)) { + return Event.None; + } + + return Event.filter(this.results.onChange, e => e.item.item.extId === this.test.item.extId); + } + + public get state() { + return this.test.tasks[this.taskIndex].state; + } + + public get label() { + return this.test.item.label; + } + + public get labelWithIcons() { + return renderLabelWithIcons(this.label); + } + + public get icon() { + return icons.testingStatesToIcons.get(this.state); + } + + public get outputSubject() { + return new TestOutputSubject(this.results, this.taskIndex, this.test); + } + + + constructor( + public readonly results: ITestResult, + public readonly test: TestResultItem, + public readonly taskIndex: number, + ) { } +} + +class TaskElement implements ITreeElement { + public readonly changeEmitter = new Emitter(); + public readonly onDidChange = this.changeEmitter.event; + public readonly type = 'task'; + public readonly context: string; + public readonly id: string; + public readonly label: string; + public readonly itemsCache = new CreationCache(); + + public get icon() { + return this.results.tasks[this.index].running ? icons.testingStatesToIcons.get(TestResultState.Running) : undefined; + } + + constructor(public readonly results: ITestResult, public readonly task: ITestRunTaskResults, public readonly index: number) { + this.id = `${results.id}/${index}`; + this.task = results.tasks[index]; + this.context = String(index); + this.label = this.task.name ?? localize('testUnnamedTask', 'Unnamed Task'); + } +} + +class TestMessageElement implements ITreeElement { + public readonly type = 'message'; + public readonly id: string; + public readonly label: string; + public readonly uri: URI; + public readonly location?: IRichLocation; + public readonly description?: string; + public readonly contextValue?: string; + public readonly message: ITestMessage; + + public get onDidChange() { + if (!(this.result instanceof LiveTestResult)) { + return Event.None; + } + + // rerender when the test case changes so it gets retired events + return Event.filter(this.result.onChange, e => e.item.item.extId === this.test.item.extId); + } + + public get context(): ITestMessageMenuArgs { + return getMessageArgs(this.test, this.message); + } + + public get outputSubject() { + return new TestOutputSubject(this.result, this.taskIndex, this.test); + } + + constructor( + public readonly result: ITestResult, + public readonly test: TestResultItem, + public readonly taskIndex: number, + public readonly messageIndex: number, + ) { + const m = this.message = test.tasks[taskIndex].messages[messageIndex]; + + this.location = m.location; + this.contextValue = m.type === TestMessageType.Error ? m.contextValue : undefined; + this.uri = buildTestUri({ + type: TestUriType.ResultMessage, + messageIndex, + resultId: result.id, + taskIndex, + testExtId: test.item.extId + }); + + this.id = this.uri.toString(); + + const asPlaintext = renderTestMessageAsText(m.message); + const lines = count(asPlaintext.trimEnd(), '\n'); + this.label = firstLine(asPlaintext); + if (lines > 0) { + this.description = lines > 1 + ? localize('messageMoreLinesN', '+ {0} more lines', lines) + : localize('messageMoreLines1', '+ 1 more line'); + } + } +} + +type TreeElement = TestResultElement | TestCaseElement | TestMessageElement | TaskElement | CoverageElement; + +export class OutputPeekTree extends Disposable { + private disposed = false; + private readonly tree: WorkbenchCompressibleObjectTree; + private readonly treeActions: TreeActionsProvider; + private readonly requestReveal = this._register(new Emitter()); + + public readonly onDidRequestReview = this.requestReveal.event; + + constructor( + container: HTMLElement, + onDidReveal: Event<{ subject: InspectSubject; preserveFocus: boolean }>, + options: { showRevealLocationOnMessages: boolean; locationForProgress: string }, + @IContextMenuService private readonly contextMenuService: IContextMenuService, + @ITestResultService results: ITestResultService, + @IInstantiationService instantiationService: IInstantiationService, + @ITestExplorerFilterState explorerFilter: ITestExplorerFilterState, + @ITestCoverageService coverageService: ITestCoverageService, + @IProgressService progressService: IProgressService, + ) { + super(); + + this.treeActions = instantiationService.createInstance(TreeActionsProvider, options.showRevealLocationOnMessages, this.requestReveal,); + const diffIdentityProvider: IIdentityProvider = { + getId(e: TreeElement) { + return e.id; + } + }; + + this.tree = this._register(instantiationService.createInstance( + WorkbenchCompressibleObjectTree, + 'Test Output Peek', + container, + { + getHeight: () => 22, + getTemplateId: () => TestRunElementRenderer.ID, + }, + [instantiationService.createInstance(TestRunElementRenderer, this.treeActions)], + { + compressionEnabled: true, + hideTwistiesOfChildlessElements: true, + identityProvider: diffIdentityProvider, + sorter: { + compare(a, b) { + if (a instanceof TestCaseElement && b instanceof TestCaseElement) { + return cmpPriority(a.state, b.state); + } + + return 0; + }, + }, + accessibilityProvider: { + getAriaLabel(element: ITreeElement) { + return element.ariaLabel || element.label; + }, + getWidgetAriaLabel() { + return localize('testingPeekLabel', 'Test Result Messages'); + } + } + }, + )) as WorkbenchCompressibleObjectTree; + + const cc = new CreationCache(); + const getTaskChildren = (taskElem: TaskElement): Iterable> => { + const { results, index, itemsCache, task } = taskElem; + const tests = Iterable.filter(results.tests, test => test.tasks[index].state >= TestResultState.Running || test.tasks[index].messages.length > 0); + let result: Iterable> = Iterable.map(tests, test => ({ + element: itemsCache.getOrCreate(test, () => new TestCaseElement(results, test, index)), + incompressible: true, + children: getTestChildren(results, test, index), + })); + + if (task.coverage.get()) { + result = Iterable.concat( + Iterable.single>({ + element: new CoverageElement(results, task, coverageService), + collapsible: true, + incompressible: true, + }), + result, + ); + } + + return result; + }; + + const getTestChildren = (result: ITestResult, test: TestResultItem, taskIndex: number): Iterable> => { + return test.tasks[taskIndex].messages + .map((m, messageIndex) => + m.type === TestMessageType.Error + ? { element: cc.getOrCreate(m, () => new TestMessageElement(result, test, taskIndex, messageIndex)), incompressible: false } + : undefined + ) + .filter(isDefined); + }; + + const getResultChildren = (result: ITestResult): Iterable> => { + return result.tasks.map((task, taskIndex) => { + const taskElem = cc.getOrCreate(task, () => new TaskElement(result, task, taskIndex)); + return ({ + element: taskElem, + incompressible: false, + collapsible: true, + children: getTaskChildren(taskElem), + }); + }); + }; + + const getRootChildren = () => results.results.map(result => { + const element = cc.getOrCreate(result, () => new TestResultElement(result)); + return { + element, + incompressible: true, + collapsible: true, + collapsed: this.tree.hasElement(element) ? this.tree.isCollapsed(element) : true, + children: getResultChildren(result) + }; + }); + + // Queued result updates to prevent spamming CPU when lots of tests are + // completing and messaging quickly (#142514) + const taskChildrenToUpdate = new Set(); + const taskChildrenUpdate = this._register(new RunOnceScheduler(() => { + for (const taskNode of taskChildrenToUpdate) { + if (this.tree.hasElement(taskNode)) { + this.tree.setChildren(taskNode, getTaskChildren(taskNode), { diffIdentityProvider }); + } + } + taskChildrenToUpdate.clear(); + }, 300)); + + const queueTaskChildrenUpdate = (taskNode: TaskElement) => { + taskChildrenToUpdate.add(taskNode); + if (!taskChildrenUpdate.isScheduled()) { + taskChildrenUpdate.schedule(); + } + }; + + const attachToResults = (result: LiveTestResult) => { + const resultNode = cc.get(result)! as TestResultElement; + const disposable = new DisposableStore(); + disposable.add(result.onNewTask(i => { + if (result.tasks.length === 1) { + this.requestReveal.fire(new TaskSubject(result, 0)); // reveal the first task in new runs + } + + if (this.tree.hasElement(resultNode)) { + this.tree.setChildren(resultNode, getResultChildren(result), { diffIdentityProvider }); + } + + // note: tasks are bounded and their lifetime is equivalent to that of + // the test result, so this doesn't leak indefinitely. + const task = result.tasks[i]; + disposable.add(autorun(reader => { + task.coverage.read(reader); // add it to the autorun + queueTaskChildrenUpdate(cc.get(task) as TaskElement); + })); + })); + disposable.add(result.onEndTask(index => { + (cc.get(result.tasks[index]) as TaskElement | undefined)?.changeEmitter.fire(); + })); + + disposable.add(result.onChange(e => { + // try updating the item in each of its tasks + for (const [index, task] of result.tasks.entries()) { + const taskNode = cc.get(task) as TaskElement; + if (!this.tree.hasElement(taskNode)) { + continue; + } + + const itemNode = taskNode.itemsCache.get(e.item); + if (itemNode && this.tree.hasElement(itemNode)) { + if (e.reason === TestResultItemChangeReason.NewMessage && e.message.type === TestMessageType.Error) { + this.tree.setChildren(itemNode, getTestChildren(result, e.item, index), { diffIdentityProvider }); + } + return; + } + + queueTaskChildrenUpdate(taskNode); + } + })); + + disposable.add(result.onComplete(() => { + resultNode.changeEmitter.fire(); + disposable.dispose(); + })); + + return resultNode; + }; + + this._register(results.onResultsChanged(e => { + // little hack here: a result change can cause the peek to be disposed, + // but this listener will still be queued. Doing stuff with the tree + // will cause errors. + if (this.disposed) { + return; + } + + if ('completed' in e) { + (cc.get(e.completed) as TestResultElement | undefined)?.changeEmitter.fire(); + return; + } + + this.tree.setChildren(null, getRootChildren(), { diffIdentityProvider }); + + // done after setChildren intentionally so that the ResultElement exists in the cache. + if ('started' in e) { + for (const child of this.tree.getNode(null).children) { + this.tree.collapse(child.element, false); + } + + this.tree.expand(attachToResults(e.started), true); + } + })); + + const revealItem = (element: TreeElement, preserveFocus: boolean) => { + this.tree.setFocus([element]); + this.tree.setSelection([element]); + if (!preserveFocus) { + this.tree.domFocus(); + } + }; + + this._register(onDidReveal(async ({ subject, preserveFocus = false }) => { + if (subject instanceof TaskSubject) { + const resultItem = this.tree.getNode(null).children.find(c => { + if (c.element instanceof TaskElement) { + return c.element.results.id === subject.result.id && c.element.index === subject.taskIndex; + } + if (c.element instanceof TestResultElement) { + return c.element.id === subject.result.id; + } + return false; + }); + + if (resultItem) { + revealItem(resultItem.element!, preserveFocus); + } + return; + } + + const revealElement = subject instanceof TestOutputSubject + ? cc.get(subject.task)?.itemsCache.get(subject.test) + : cc.get(subject.message); + if (!revealElement || !this.tree.hasElement(revealElement)) { + return; + } + + const parents: TreeElement[] = []; + for (let parent = this.tree.getParentElement(revealElement); parent; parent = this.tree.getParentElement(parent)) { + parents.unshift(parent); + } + + for (const parent of parents) { + this.tree.expand(parent); + } + + if (this.tree.getRelativeTop(revealElement) === null) { + this.tree.reveal(revealElement, 0.5); + } + + revealItem(revealElement, preserveFocus); + })); + + this._register(this.tree.onDidOpen(async e => { + if (e.element instanceof TestMessageElement) { + this.requestReveal.fire(new MessageSubject(e.element.result, e.element.test, e.element.taskIndex, e.element.messageIndex)); + } else if (e.element instanceof TestCaseElement) { + const t = e.element; + const message = mapFindTestMessage(e.element.test, (_t, _m, mesasgeIndex, taskIndex) => + new MessageSubject(t.results, t.test, taskIndex, mesasgeIndex)); + this.requestReveal.fire(message || new TestOutputSubject(t.results, 0, t.test)); + } else if (e.element instanceof CoverageElement) { + const task = e.element.task; + if (e.element.isOpen) { + return coverageService.closeCoverage(); + } + progressService.withProgress( + { location: options.locationForProgress }, + () => coverageService.openCoverage(task, true) + ); + } + })); + + this._register(this.tree.onDidChangeSelection(evt => { + for (const element of evt.elements) { + if (element && 'test' in element) { + explorerFilter.reveal.value = element.test.item.extId; + break; + } + } + })); + + + this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); + + this.tree.setChildren(null, getRootChildren()); + for (const result of results.results) { + if (!result.completedAt && result instanceof LiveTestResult) { + attachToResults(result); + } + } + } + + public layout(height: number, width: number) { + this.tree.layout(height, width); + } + + private onContextMenu(evt: ITreeContextMenuEvent) { + if (!evt.element) { + return; + } + + const actions = this.treeActions.provideActionBar(evt.element); + this.contextMenuService.showContextMenu({ + getAnchor: () => evt.anchor, + getActions: () => actions.secondary.length + ? [...actions.primary, new Separator(), ...actions.secondary] + : actions.primary, + getActionsContext: () => evt.element?.context + }); + } + + public override dispose() { + super.dispose(); + this.disposed = true; + } +} + +interface TemplateData { + label: HTMLElement; + icon: HTMLElement; + actionBar: ActionBar; + elementDisposable: DisposableStore; + templateDisposable: DisposableStore; +} + +class TestRunElementRenderer implements ICompressibleTreeRenderer { + public static readonly ID = 'testRunElementRenderer'; + public readonly templateId = TestRunElementRenderer.ID; + + constructor( + private readonly treeActions: TreeActionsProvider, + @IInstantiationService private readonly instantiationService: IInstantiationService, + ) { } + + /** @inheritdoc */ + public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: TemplateData): void { + const chain = node.element.elements; + const lastElement = chain[chain.length - 1]; + if ((lastElement instanceof TaskElement || lastElement instanceof TestMessageElement) && chain.length >= 2) { + this.doRender(chain[chain.length - 2], templateData, lastElement); + } else { + this.doRender(lastElement, templateData); + } + } + + /** @inheritdoc */ + public renderTemplate(container: HTMLElement): TemplateData { + const templateDisposable = new DisposableStore(); + const wrapper = dom.append(container, dom.$('.test-peek-item')); + const icon = dom.append(wrapper, dom.$('.state')); + const label = dom.append(wrapper, dom.$('.name')); + + const actionBar = new ActionBar(wrapper, { + actionViewItemProvider: (action, options) => + action instanceof MenuItemAction + ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }) + : undefined + }); + + const elementDisposable = new DisposableStore(); + templateDisposable.add(elementDisposable); + templateDisposable.add(actionBar); + + return { + icon, + label, + actionBar, + elementDisposable, + templateDisposable, + }; + } + + /** @inheritdoc */ + public renderElement(element: ITreeNode, _index: number, templateData: TemplateData): void { + this.doRender(element.element, templateData); + } + + /** @inheritdoc */ + public disposeTemplate(templateData: TemplateData): void { + templateData.templateDisposable.dispose(); + } + + /** Called to render a new element */ + private doRender(element: ITreeElement, templateData: TemplateData, subjectElement?: ITreeElement) { + templateData.elementDisposable.clear(); + templateData.elementDisposable.add( + element.onDidChange(() => this.doRender(element, templateData, subjectElement)), + ); + this.doRenderInner(element, templateData, subjectElement); + } + + /** Called, and may be re-called, to render or re-render an element */ + private doRenderInner(element: ITreeElement, templateData: TemplateData, subjectElement: ITreeElement | undefined) { + let { label, labelWithIcons, description } = element; + if (subjectElement instanceof TestMessageElement) { + description = subjectElement.label; + } + + const descriptionElement = description ? dom.$('span.test-label-description', {}, description) : ''; + if (labelWithIcons) { + dom.reset(templateData.label, ...labelWithIcons, descriptionElement); + } else { + dom.reset(templateData.label, label, descriptionElement); + } + + const icon = element.icon; + templateData.icon.className = `computed-state ${icon ? ThemeIcon.asClassName(icon) : ''}`; + + const actions = this.treeActions.provideActionBar(element); + templateData.actionBar.clear(); + templateData.actionBar.context = element.context; + templateData.actionBar.push(actions.primary, { icon: true, label: false }); + } +} + +class TreeActionsProvider { + constructor( + private readonly showRevealLocationOnMessages: boolean, + private readonly requestReveal: Emitter, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IMenuService private readonly menuService: IMenuService, + @ICommandService private readonly commandService: ICommandService, + @ITestProfileService private readonly testProfileService: ITestProfileService, + @IEditorService private readonly editorService: IEditorService, + ) { } + + public provideActionBar(element: ITreeElement) { + const test = element instanceof TestCaseElement ? element.test : undefined; + const capabilities = test ? this.testProfileService.capabilitiesForTest(test) : 0; + + const contextKeys: [string, unknown][] = [ + ['peek', Testing.OutputPeekContributionId], + [TestingContextKeys.peekItemType.key, element.type], + ]; + + let id = MenuId.TestPeekElement; + const primary: IAction[] = []; + const secondary: IAction[] = []; + + if (element instanceof TaskElement) { + primary.push(new Action( + 'testing.outputPeek.showResultOutput', + localize('testing.showResultOutput', "Show Result Output"), + ThemeIcon.asClassName(Codicon.terminal), + undefined, + () => this.requestReveal.fire(new TaskSubject(element.results, element.index)), + )); + } + + if (element instanceof TestResultElement) { + // only show if there are no collapsed test nodes that have more specific choices + if (element.value.tasks.length === 1) { + primary.push(new Action( + 'testing.outputPeek.showResultOutput', + localize('testing.showResultOutput', "Show Result Output"), + ThemeIcon.asClassName(Codicon.terminal), + undefined, + () => this.requestReveal.fire(new TaskSubject(element.value, 0)), + )); + } + + primary.push(new Action( + 'testing.outputPeek.reRunLastRun', + localize('testing.reRunLastRun', "Rerun Test Run"), + ThemeIcon.asClassName(icons.testingRunIcon), + undefined, + () => this.commandService.executeCommand('testing.reRunLastRun', element.value.id), + )); + + if (capabilities & TestRunProfileBitset.Debug) { + primary.push(new Action( + 'testing.outputPeek.debugLastRun', + localize('testing.debugLastRun', "Debug Test Run"), + ThemeIcon.asClassName(icons.testingDebugIcon), + undefined, + () => this.commandService.executeCommand('testing.debugLastRun', element.value.id), + )); + } + } + + if (element instanceof TestCaseElement || element instanceof TestMessageElement) { + contextKeys.push( + [TestingContextKeys.testResultOutdated.key, element.test.retired], + [TestingContextKeys.testResultState.key, testResultStateToContextValues[element.test.ownComputedState]], + ...getTestItemContextOverlay(element.test, capabilities), + ); + + const extId = element.test.item.extId; + if (element.test.tasks[element.taskIndex].messages.some(m => m.type === TestMessageType.Output)) { + primary.push(new Action( + 'testing.outputPeek.showResultOutput', + localize('testing.showResultOutput', "Show Result Output"), + ThemeIcon.asClassName(Codicon.terminal), + undefined, + () => this.requestReveal.fire(element.outputSubject), + )); + } + + secondary.push(new Action( + 'testing.outputPeek.revealInExplorer', + localize('testing.revealInExplorer', "Reveal in Test Explorer"), + ThemeIcon.asClassName(Codicon.listTree), + undefined, + () => this.commandService.executeCommand('_revealTestInExplorer', extId), + )); + + if (capabilities & TestRunProfileBitset.Run) { + primary.push(new Action( + 'testing.outputPeek.runTest', + localize('run test', 'Run Test'), + ThemeIcon.asClassName(icons.testingRunIcon), + undefined, + () => this.commandService.executeCommand('vscode.runTestsById', TestRunProfileBitset.Run, extId), + )); + } + + if (capabilities & TestRunProfileBitset.Debug) { + primary.push(new Action( + 'testing.outputPeek.debugTest', + localize('debug test', 'Debug Test'), + ThemeIcon.asClassName(icons.testingDebugIcon), + undefined, + () => this.commandService.executeCommand('vscode.runTestsById', TestRunProfileBitset.Debug, extId), + )); + } + + } + + if (element instanceof TestMessageElement) { + primary.push(new Action( + 'testing.outputPeek.goToFile', + localize('testing.goToFile', "Go to Source"), + ThemeIcon.asClassName(Codicon.goToFile), + undefined, + () => this.commandService.executeCommand('vscode.revealTest', element.test.item.extId), + )); + } + + if (element instanceof TestMessageElement) { + id = MenuId.TestMessageContext; + contextKeys.push([TestingContextKeys.testMessageContext.key, element.contextValue]); + if (this.showRevealLocationOnMessages && element.location) { + primary.push(new Action( + 'testing.outputPeek.goToError', + localize('testing.goToError', "Go to Source"), + ThemeIcon.asClassName(Codicon.goToFile), + undefined, + () => this.editorService.openEditor({ + resource: element.location!.uri, + options: { + selection: element.location!.range, + preserveFocus: true, + } + }), + )); + } + } + + + const contextOverlay = this.contextKeyService.createOverlay(contextKeys); + const result = { primary, secondary }; + const menu = this.menuService.getMenuActions(id, contextOverlay, { arg: element.context }); + createAndFillInActionBarActions(menu, result, 'inline'); + return result; + } +} + +class CreationCache { + private readonly v = new WeakMap(); + + public get(key: object): T2 | undefined { + return this.v.get(key) as T2 | undefined; + } + + public getOrCreate(ref: object, factory: () => T2): T2 { + const existing = this.v.get(ref); + if (existing) { + return existing as T2; + } + + const fresh = factory(); + this.v.set(ref, fresh); + return fresh; + } +} + +const firstLine = (str: string) => { + const index = str.indexOf('\n'); + return index === -1 ? str : str.slice(0, index); +}; diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index ec21581a9a6..0012ae4a1c5 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -5,56 +5,39 @@ 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'; -import { IIdentityProvider } from 'vs/base/browser/ui/list/list'; -import { DomScrollableElement } from 'vs/base/browser/ui/scrollbar/scrollableElement'; import { Orientation, Sizing, SplitView } from 'vs/base/browser/ui/splitview/splitview'; -import { ICompressedTreeElement, ICompressedTreeNode } from 'vs/base/browser/ui/tree/compressedObjectTreeModel'; -import { ICompressibleTreeRenderer } from 'vs/base/browser/ui/tree/objectTree'; -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 { IAction } from 'vs/base/common/actions'; +import { Limiter } from 'vs/base/common/async'; 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'; -import { FuzzyScore } from 'vs/base/common/filters'; -import { IMarkdownString } from 'vs/base/common/htmlContent'; import { stripIcons } from 'vs/base/common/iconLabels'; import { Iterable } from 'vs/base/common/iterator'; import { KeyCode, KeyMod } from 'vs/base/common/keyCodes'; import { Lazy } from 'vs/base/common/lazy'; -import { Disposable, DisposableStore, IDisposable, IReference, MutableDisposable, combinedDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { MarshalledId } from 'vs/base/common/marshallingIds'; -import { autorun } from 'vs/base/common/observable'; +import { Disposable, DisposableStore, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { count } from 'vs/base/common/strings'; -import { ThemeIcon } from 'vs/base/common/themables'; -import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import 'vs/css!./testingOutputPeek'; -import { ICodeEditor, IDiffEditorConstructionOptions, isCodeEditor } from 'vs/editor/browser/editorBrowser'; +import { ICodeEditor, isCodeEditor } from 'vs/editor/browser/editorBrowser'; import { EditorAction2 } from 'vs/editor/browser/editorExtensions'; import { ICodeEditorService } from 'vs/editor/browser/services/codeEditorService'; -import { CodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/codeEditorWidget'; import { EmbeddedCodeEditorWidget } from 'vs/editor/browser/widget/codeEditor/embeddedCodeEditorWidget'; -import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/diffEditorWidget'; import { EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/diffEditor/embeddedDiffEditorWidget'; -import { MarkdownRenderer } from 'vs/editor/browser/widget/markdownRenderer/browser/markdownRenderer'; -import { IDiffEditorOptions, IEditorOptions } from 'vs/editor/common/config/editorOptions'; import { Position } from 'vs/editor/common/core/position'; import { Range } from 'vs/editor/common/core/range'; import { IEditor, IEditorContribution, ScrollType } from 'vs/editor/common/editorCommon'; import { EditorContextKeys } from 'vs/editor/common/editorContextKeys'; -import { IResolvedTextEditorModel, ITextModelService } from 'vs/editor/common/services/resolverService'; -import { IPeekViewService, PeekViewWidget, peekViewResultsBackground, peekViewTitleForeground, peekViewTitleInfoForeground } from 'vs/editor/contrib/peekView/browser/peekView'; +import { ITextModelService } from 'vs/editor/common/services/resolverService'; +import { IPeekViewService, PeekViewWidget, peekViewTitleForeground, peekViewTitleInfoForeground } from 'vs/editor/contrib/peekView/browser/peekView'; import { localize, localize2 } from 'vs/nls'; import { Categories } from 'vs/platform/action/common/actionCommonCategories'; import { FloatingClickMenu } from 'vs/platform/actions/browser/floatingMenu'; -import { MenuEntryActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; -import { Action2, IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; +import { createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { Action2, IMenuService, MenuId } from 'vs/platform/actions/common/actions'; import { ICommandService } from 'vs/platform/commands/common/commands'; import { IConfigurationService } from 'vs/platform/configuration/common/configuration'; import { ContextKeyExpr, IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; @@ -65,116 +48,35 @@ import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiati import { ServiceCollection } from 'vs/platform/instantiation/common/serviceCollection'; import { IKeybindingService } from 'vs/platform/keybinding/common/keybinding'; import { KeybindingWeight } from 'vs/platform/keybinding/common/keybindingsRegistry'; -import { WorkbenchCompressibleObjectTree } from 'vs/platform/list/browser/listService'; 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'; -import { TerminalCapabilityStore } from 'vs/platform/terminal/common/capabilities/terminalCapabilityStore'; -import { formatMessageForTerminal } from 'vs/platform/terminal/common/terminalStrings'; import { editorBackground } from 'vs/platform/theme/common/colorRegistry'; -import { widgetClose } from 'vs/platform/theme/common/iconRegistry'; import { IThemeService } from 'vs/platform/theme/common/themeService'; -import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; -import { EditorModel } from 'vs/workbench/common/editor/editorModel'; -import { PANEL_BACKGROUND, SIDE_BAR_BACKGROUND } from 'vs/workbench/common/theme'; -import { IViewDescriptorService, ViewContainerLocation } from 'vs/workbench/common/views'; -import { DetachedProcessInfo } from 'vs/workbench/contrib/terminal/browser/detachedTerminal'; -import { IDetachedTerminalInstance, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; -import { getXtermScaledDimensions } from 'vs/workbench/contrib/terminal/browser/xterm/xtermTerminal'; -import { TERMINAL_BACKGROUND_COLOR } from 'vs/workbench/contrib/terminal/common/terminalColorRegistry'; -import { getTestItemContextOverlay } from 'vs/workbench/contrib/testing/browser/explorerProjections/testItemContextOverlay'; -import * as icons from 'vs/workbench/contrib/testing/browser/icons'; -import { colorizeTestMessageInEditor, renderTestMessageAsText } from 'vs/workbench/contrib/testing/browser/testMessageColorizer'; +import { IViewDescriptorService } from 'vs/workbench/common/views'; +import { renderTestMessageAsText } from 'vs/workbench/contrib/testing/browser/testMessageColorizer'; +import { DiffContentProvider, IPeekOutputRenderer, MarkdownTestMessagePeek, PlainTextMessagePeek, TerminalMessagePeek } from 'vs/workbench/contrib/testing/browser/testResultsView/testResultsOutput'; +import { InspectSubject, MessageSubject, TaskSubject, TestOutputSubject, equalsSubject, mapFindTestMessage } from 'vs/workbench/contrib/testing/browser/testResultsView/testResultsSubject'; +import { OutputPeekTree } from 'vs/workbench/contrib/testing/browser/testResultsView/testResultsTree'; import { testingMessagePeekBorder, testingPeekBorder, testingPeekHeaderBackground, testingPeekMessageHeaderBackground } from 'vs/workbench/contrib/testing/browser/theme'; import { AutoOpenPeekViewWhen, TestingConfigKeys, getTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration'; import { Testing } from 'vs/workbench/contrib/testing/common/constants'; import { IObservableValue, MutableObservableValue, staticObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; -import { ITestCoverageService } from 'vs/workbench/contrib/testing/common/testCoverageService'; -import { ITestExplorerFilterState } from 'vs/workbench/contrib/testing/common/testExplorerFilterState'; -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 { ITestResult, LiveTestResult, TestResultItemChange, TestResultItemChangeReason, resultItemParents } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService, ResultChangeEvent } from 'vs/workbench/contrib/testing/common/testResultService'; 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 { IRichLocation, ITestMessage, TestMessageType, TestResultItem } 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'; -import { cmpPriority, isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; +import { isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; import { ParsedTestUri, TestUriType, buildTestUri, parseTestUri } from 'vs/workbench/contrib/testing/common/testingUri'; import { IEditorService } from 'vs/workbench/services/editor/common/editorService'; import { IViewsService } from 'vs/workbench/services/views/common/viewsService'; -const getMessageArgs = (test: TestResultItem, message: ITestMessage): ITestMessageMenuArgs => ({ - $mid: MarshalledId.TestMessageMenuArgs, - test: InternalTestItem.serialize(test), - message: ITestMessage.serialize(message), -}); - -class MessageSubject { - public readonly test: ITestItem; - public readonly message: ITestMessage; - public readonly expectedUri: URI; - public readonly actualUri: URI; - public readonly messageUri: URI; - public readonly revealLocation: IRichLocation | undefined; - public readonly context: ITestMessageMenuArgs | undefined; - - public get isDiffable() { - return this.message.type === TestMessageType.Error && isDiffable(this.message); - } - - public get contextValue() { - return this.message.type === TestMessageType.Error ? this.message.contextValue : undefined; - } - - constructor(public readonly result: ITestResult, test: TestResultItem, public readonly taskIndex: number, public readonly messageIndex: number) { - this.test = test.item; - const messages = test.tasks[taskIndex].messages; - this.messageIndex = messageIndex; - - const parts = { messageIndex, resultId: result.id, taskIndex, testExtId: test.item.extId }; - this.expectedUri = buildTestUri({ ...parts, type: TestUriType.ResultExpectedOutput }); - this.actualUri = buildTestUri({ ...parts, type: TestUriType.ResultActualOutput }); - this.messageUri = buildTestUri({ ...parts, type: TestUriType.ResultMessage }); - - const message = this.message = messages[this.messageIndex]; - this.context = getMessageArgs(test, message); - this.revealLocation = message.location ?? (test.item.uri && test.item.range ? { uri: test.item.uri, range: Range.lift(test.item.range) } : undefined); - } -} - -class TaskSubject { - public readonly outputUri: URI; - public readonly revealLocation: undefined; - - constructor(public readonly result: ITestResult, public readonly taskIndex: number) { - this.outputUri = buildTestUri({ resultId: result.id, taskIndex, type: TestUriType.TaskOutput }); - } -} - -class TestOutputSubject { - public readonly outputUri: URI; - public readonly revealLocation: undefined; - public readonly task: ITestRunTask; - - constructor(public readonly result: ITestResult, public readonly taskIndex: number, public readonly test: TestResultItem) { - this.outputUri = buildTestUri({ resultId: this.result.id, taskIndex: this.taskIndex, testExtId: this.test.item.extId, type: TestUriType.TestOutput }); - this.task = result.tasks[this.taskIndex]; - } -} - -type InspectSubject = MessageSubject | TaskSubject | TestOutputSubject; - -const equalsSubject = (a: InspectSubject, b: InspectSubject) => ( - (a instanceof MessageSubject && b instanceof MessageSubject && a.message === b.message) || - (a instanceof TaskSubject && b instanceof TaskSubject && a.result === b.result && a.taskIndex === b.taskIndex) || - (a instanceof TestOutputSubject && b instanceof TestOutputSubject && a.test === b.test && a.taskIndex === b.taskIndex) -); /** Iterates through every message in every result */ function* allMessages(results: readonly ITestResult[]) { @@ -501,19 +403,6 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener } } -const mapFindTestMessage = (test: TestResultItem, fn: (task: ITestTaskState, message: ITestMessage, messageIndex: number, taskIndex: number) => T | undefined) => { - for (let taskIndex = 0; taskIndex < test.tasks.length; taskIndex++) { - const task = test.tasks[taskIndex]; - for (let messageIndex = 0; messageIndex < task.messages.length; messageIndex++) { - const r = fn(task, task.messages[messageIndex], messageIndex, taskIndex); - if (r !== undefined) { - return r; - } - } - } - - return undefined; -}; /** * Adds output/message peek functionality to code editors. @@ -1245,491 +1134,8 @@ export class TestResultsView extends ViewPane { } } -interface IPeekOutputRenderer extends IDisposable { - /** Updates the displayed test. Should clear if it cannot display the test. */ - update(subject: InspectSubject): void; - /** Recalculate content layout. */ - layout(dimension: dom.IDimension): void; - /** Dispose the content provider. */ - dispose(): void; -} - -const commonEditorOptions: IEditorOptions = { - scrollBeyondLastLine: false, - links: true, - lineNumbers: 'off', - scrollbar: { - verticalScrollbarSize: 14, - horizontal: 'auto', - useShadows: true, - verticalHasArrows: false, - horizontalHasArrows: false, - alwaysConsumeMouseWheel: false - }, - fixedOverflowWidgets: true, - readOnly: true, - minimap: { - enabled: false - }, - wordWrap: 'on', -}; - -const diffEditorOptions: IDiffEditorConstructionOptions = { - ...commonEditorOptions, - enableSplitViewResizing: true, - isInEmbeddedEditor: true, - renderOverviewRuler: false, - ignoreTrimWhitespace: false, - renderSideBySide: true, - useInlineViewWhenSpaceIsLimited: false, - originalAriaLabel: localize('testingOutputExpected', 'Expected result'), - modifiedAriaLabel: localize('testingOutputActual', 'Actual result'), - diffAlgorithm: 'advanced', -}; - -const isDiffable = (message: ITestMessage): message is ITestErrorMessage & { actual: string; expected: string } => - message.type === TestMessageType.Error && message.actual !== undefined && message.expected !== undefined; - -class DiffContentProvider extends Disposable implements IPeekOutputRenderer { - private readonly widget = this._register(new MutableDisposable()); - private readonly model = this._register(new MutableDisposable()); - private dimension?: dom.IDimension; - - constructor( - private readonly editor: ICodeEditor | undefined, - private readonly container: HTMLElement, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @ITextModelService private readonly modelService: ITextModelService, - ) { - super(); - } - - public async update(subject: InspectSubject) { - if (!(subject instanceof MessageSubject)) { - return this.clear(); - } - const message = subject.message; - if (!isDiffable(message)) { - return this.clear(); - } - - const [original, modified] = await Promise.all([ - this.modelService.createModelReference(subject.expectedUri), - this.modelService.createModelReference(subject.actualUri), - ]); - - const model = this.model.value = new SimpleDiffEditorModel(original, modified); - if (!this.widget.value) { - this.widget.value = this.editor ? this.instantiationService.createInstance( - EmbeddedDiffEditorWidget, - this.container, - diffEditorOptions, - {}, - this.editor, - ) : this.instantiationService.createInstance( - DiffEditorWidget, - this.container, - diffEditorOptions, - {}, - ); - - if (this.dimension) { - this.widget.value.layout(this.dimension); - } - } - - this.widget.value.setModel(model); - this.widget.value.updateOptions(this.getOptions( - isMultiline(message.expected) || isMultiline(message.actual) - )); - } - - private clear() { - this.model.clear(); - this.widget.clear(); - } - - public layout(dimensions: dom.IDimension) { - this.dimension = dimensions; - this.widget.value?.layout(dimensions); - } - - protected getOptions(isMultiline: boolean): IDiffEditorOptions { - return isMultiline - ? { ...diffEditorOptions, lineNumbers: 'on' } - : { ...diffEditorOptions, lineNumbers: 'off' }; - } -} - -class ScrollableMarkdownMessage extends Disposable { - private readonly scrollable: DomScrollableElement; - private readonly element: HTMLElement; - - constructor(container: HTMLElement, markdown: MarkdownRenderer, message: IMarkdownString) { - super(); - - const rendered = this._register(markdown.render(message, {})); - rendered.element.style.height = '100%'; - rendered.element.style.userSelect = 'text'; - container.appendChild(rendered.element); - this.element = rendered.element; - - this.scrollable = this._register(new DomScrollableElement(rendered.element, { - className: 'preview-text', - })); - container.appendChild(this.scrollable.getDomNode()); - - this._register(toDisposable(() => { - this.scrollable.getDomNode().remove(); - })); - - this.scrollable.scanDomNode(); - } - - public layout(height: number, width: number) { - // Remove padding of `.monaco-editor .zone-widget.test-output-peek .preview-text` - this.scrollable.setScrollDimensions({ - width: width - 32, - height: height - 16, - scrollWidth: this.element.scrollWidth, - scrollHeight: this.element.scrollHeight - }); - } -} - -class MarkdownTestMessagePeek extends Disposable implements IPeekOutputRenderer { - private readonly markdown = new Lazy( - () => this._register(this.instantiationService.createInstance(MarkdownRenderer, {})), - ); - - private readonly textPreview = this._register(new MutableDisposable()); - - constructor(private readonly container: HTMLElement, @IInstantiationService private readonly instantiationService: IInstantiationService) { - super(); - } - - public update(subject: InspectSubject): void { - if (!(subject instanceof MessageSubject)) { - return this.textPreview.clear(); - } - - const message = subject.message; - if (isDiffable(message) || typeof message.message === 'string') { - return this.textPreview.clear(); - } - - this.textPreview.value = new ScrollableMarkdownMessage( - this.container, - this.markdown.value, - message.message as IMarkdownString, - ); - } - - public layout(dimension: dom.IDimension): void { - this.textPreview.value?.layout(dimension.height, dimension.width); - } -} - -class PlainTextMessagePeek extends Disposable implements IPeekOutputRenderer { - private readonly widgetDecorations = this._register(new MutableDisposable()); - private readonly widget = this._register(new MutableDisposable()); - private readonly model = this._register(new MutableDisposable()); - private dimension?: dom.IDimension; - - constructor( - private readonly editor: ICodeEditor | undefined, - private readonly container: HTMLElement, - @IInstantiationService private readonly instantiationService: IInstantiationService, - @ITextModelService private readonly modelService: ITextModelService, - ) { - super(); - } - - public async update(subject: InspectSubject) { - if (!(subject instanceof MessageSubject)) { - return this.clear(); - } - - const message = subject.message; - if (isDiffable(message) || message.type === TestMessageType.Output || typeof message.message !== 'string') { - return this.clear(); - } - - const modelRef = this.model.value = await this.modelService.createModelReference(subject.messageUri); - if (!this.widget.value) { - this.widget.value = this.editor ? this.instantiationService.createInstance( - EmbeddedCodeEditorWidget, - this.container, - commonEditorOptions, - {}, - this.editor, - ) : this.instantiationService.createInstance( - CodeEditorWidget, - this.container, - commonEditorOptions, - { isSimpleWidget: true } - ); - - if (this.dimension) { - this.widget.value.layout(this.dimension); - } - } - - this.widget.value.setModel(modelRef.object.textEditorModel); - this.widget.value.updateOptions(commonEditorOptions); - this.widgetDecorations.value = colorizeTestMessageInEditor(message.message, this.widget.value); - } - - private clear() { - this.widgetDecorations.clear(); - this.widget.clear(); - this.model.clear(); - } - - public layout(dimensions: dom.IDimension) { - this.dimension = dimensions; - this.widget.value?.layout(dimensions); - } -} - -class TerminalMessagePeek extends Disposable implements IPeekOutputRenderer { - private dimensions?: dom.IDimension; - private readonly terminalCwd = this._register(new MutableObservableValue('')); - private readonly xtermLayoutDelayer = this._register(new Delayer(50)); - - /** Active terminal instance. */ - private readonly terminal = this._register(new MutableDisposable()); - /** Listener for streaming result data */ - private readonly outputDataListener = this._register(new MutableDisposable()); - - constructor( - private readonly container: HTMLElement, - private readonly isInPeekView: boolean, - @ITerminalService private readonly terminalService: ITerminalService, - @IViewDescriptorService private readonly viewDescriptorService: IViewDescriptorService, - @IWorkspaceContextService private readonly workspaceContext: IWorkspaceContextService, - ) { - super(); - } - - private async makeTerminal() { - const prev = this.terminal.value; - if (prev) { - prev.xterm.clearBuffer(); - prev.xterm.clearSearchDecorations(); - // clearBuffer tries to retain the prompt line, but this doesn't exist for tests. - // So clear the screen (J) and move to home (H) to ensure previous data is cleaned up. - prev.xterm.write(`\x1b[2J\x1b[0;0H`); - return prev; - } - - const capabilities = new TerminalCapabilityStore(); - const cwd = this.terminalCwd; - capabilities.add(TerminalCapability.CwdDetection, { - type: TerminalCapability.CwdDetection, - get cwds() { return [cwd.value]; }, - onDidChangeCwd: cwd.onDidChange, - getCwd: () => cwd.value, - updateCwd: () => { }, - }); - - return this.terminal.value = await this.terminalService.createDetachedTerminal({ - rows: 10, - cols: 80, - readonly: true, - capabilities, - processInfo: new DetachedProcessInfo({ initialCwd: cwd.value }), - colorProvider: { - getBackgroundColor: theme => { - const terminalBackground = theme.getColor(TERMINAL_BACKGROUND_COLOR); - if (terminalBackground) { - return terminalBackground; - } - if (this.isInPeekView) { - return theme.getColor(peekViewResultsBackground); - } - const location = this.viewDescriptorService.getViewLocationById(Testing.ResultsViewId); - return location === ViewContainerLocation.Panel - ? theme.getColor(PANEL_BACKGROUND) - : theme.getColor(SIDE_BAR_BACKGROUND); - }, - } - }); - } - - public async update(subject: InspectSubject) { - this.outputDataListener.clear(); - if (subject instanceof TaskSubject) { - await this.updateForTaskSubject(subject); - } else if (subject instanceof TestOutputSubject || (subject instanceof MessageSubject && subject.message.type === TestMessageType.Output)) { - await this.updateForTestSubject(subject); - } else { - this.clear(); - } - } - - private async updateForTestSubject(subject: TestOutputSubject | MessageSubject) { - const that = this; - const testItem = subject instanceof TestOutputSubject ? subject.test.item : subject.test; - const terminal = await this.updateGenerically({ - subject, - noOutputMessage: localize('caseNoOutput', 'The test case did not report any output.'), - getTarget: result => result?.tasks[subject.taskIndex].output, - *doInitialWrite(output, results) { - that.updateCwd(testItem.uri); - const state = subject instanceof TestOutputSubject ? subject.test : results.getStateById(testItem.extId); - if (!state) { - return; - } - - for (const message of state.tasks[subject.taskIndex].messages) { - if (message.type === TestMessageType.Output) { - yield* output.getRangeIter(message.offset, message.length); - } - } - }, - doListenForMoreData: (output, result, write) => result.onChange(e => { - if (e.reason === TestResultItemChangeReason.NewMessage && e.item.item.extId === testItem.extId && e.message.type === TestMessageType.Output) { - for (const chunk of output.getRangeIter(e.message.offset, e.message.length)) { - write(chunk.buffer); - } - } - }), - }); - - if (subject instanceof MessageSubject && subject.message.type === TestMessageType.Output && subject.message.marker !== undefined) { - terminal?.xterm.selectMarkedRange(getMarkId(subject.message.marker, true), getMarkId(subject.message.marker, false), /* scrollIntoView= */ true); - } - } - - private updateForTaskSubject(subject: TaskSubject) { - return this.updateGenerically({ - subject, - noOutputMessage: localize('runNoOutput', 'The test run did not record any output.'), - getTarget: result => result?.tasks[subject.taskIndex], - doInitialWrite: (task, result) => { - // Update the cwd and use the first test to try to hint at the correct cwd, - // but often this will fall back to the first workspace folder. - this.updateCwd(Iterable.find(result.tests, t => !!t.item.uri)?.item.uri); - return task.output.buffers; - }, - doListenForMoreData: (task, _result, write) => task.output.onDidWriteData(e => write(e.buffer)), - }); - } - - private async updateGenerically(opts: { - subject: InspectSubject; - noOutputMessage: string; - getTarget: (result: ITestResult) => T | undefined; - doInitialWrite: (target: T, result: LiveTestResult) => Iterable; - doListenForMoreData: (target: T, result: LiveTestResult, write: (s: Uint8Array) => void) => IDisposable; - }) { - const result = opts.subject.result; - const target = opts.getTarget(result); - if (!target) { - return this.clear(); - } - - const terminal = await this.makeTerminal(); - let didWriteData = false; - - const pendingWrites = new MutableObservableValue(0); - if (result instanceof LiveTestResult) { - for (const chunk of opts.doInitialWrite(target, result)) { - didWriteData ||= chunk.byteLength > 0; - pendingWrites.value++; - terminal.xterm.write(chunk.buffer, () => pendingWrites.value--); - } - } else { - didWriteData = true; - this.writeNotice(terminal, localize('runNoOutputForPast', 'Test output is only available for new test runs.')); - } - - this.attachTerminalToDom(terminal); - this.outputDataListener.clear(); - - if (result instanceof LiveTestResult && !result.completedAt) { - const l1 = result.onComplete(() => { - if (!didWriteData) { - this.writeNotice(terminal, opts.noOutputMessage); - } - }); - const l2 = opts.doListenForMoreData(target, result, data => { - terminal.xterm.write(data); - didWriteData ||= data.byteLength > 0; - }); - - this.outputDataListener.value = combinedDisposable(l1, l2); - } - - if (!this.outputDataListener.value && !didWriteData) { - this.writeNotice(terminal, opts.noOutputMessage); - } - - // Ensure pending writes finish, otherwise the selection in `updateForTestSubject` - // can happen before the markers are processed. - if (pendingWrites.value > 0) { - await new Promise(resolve => { - const l = pendingWrites.onDidChange(() => { - if (pendingWrites.value === 0) { - l.dispose(); - resolve(); - } - }); - }); - } - - return terminal; - } - - private updateCwd(testUri?: URI) { - const wf = (testUri && this.workspaceContext.getWorkspaceFolder(testUri)) - || this.workspaceContext.getWorkspace().folders[0]; - if (wf) { - this.terminalCwd.value = wf.uri.fsPath; - } - } - - private writeNotice(terminal: IDetachedTerminalInstance, str: string) { - terminal.xterm.write(formatMessageForTerminal(str)); - } - - private attachTerminalToDom(terminal: IDetachedTerminalInstance) { - terminal.xterm.write('\x1b[?25l'); // hide cursor - dom.scheduleAtNextAnimationFrame(dom.getWindow(this.container), () => this.layoutTerminal(terminal)); - terminal.attachToElement(this.container, { enableGpu: false }); - } - - private clear() { - this.outputDataListener.clear(); - this.xtermLayoutDelayer.cancel(); - this.terminal.clear(); - } - - public layout(dimensions: dom.IDimension) { - this.dimensions = dimensions; - if (this.terminal.value) { - this.layoutTerminal(this.terminal.value, dimensions.width, dimensions.height); - } - } - - private layoutTerminal( - { xterm }: IDetachedTerminalInstance, - width = this.dimensions?.width ?? this.container.clientWidth, - height = this.dimensions?.height ?? this.container.clientHeight - ) { - width -= 10 + 20; // scrollbar width + margin - this.xtermLayoutDelayer.trigger(() => { - const scaled = getXtermScaledDimensions(dom.getWindow(this.container), xterm.getFont(), width, height); - if (scaled) { - xterm.resize(scaled.cols, scaled.rows); - } - }); - } -} - const hintMessagePeekHeight = (msg: ITestMessage) => { - const msgHeight = isDiffable(msg) + const msgHeight = ITestMessage.isDiffable(msg) ? Math.max(hintPeekStrHeight(msg.actual), hintPeekStrHeight(msg.expected)) : hintPeekStrHeight(typeof msg.message === 'string' ? msg.message : msg.message.value); @@ -1742,28 +1148,9 @@ const firstLine = (str: string) => { return index === -1 ? str : str.slice(0, index); }; -const isMultiline = (str: string | undefined) => !!str && str.includes('\n'); const hintPeekStrHeight = (str: string) => Math.min(count(str, '\n'), 24); -class SimpleDiffEditorModel extends EditorModel { - public readonly original = this._original.object.textEditorModel; - public readonly modified = this._modified.object.textEditorModel; - - constructor( - private readonly _original: IReference, - private readonly _modified: IReference, - ) { - super(); - } - - public override dispose() { - super.dispose(); - this._original.dispose(); - this._modified.dispose(); - } -} - function getOuterEditorFromDiffEditor(codeEditorService: ICodeEditorService): ICodeEditor | null { const diffEditors = codeEditorService.listDiffEditors(); @@ -1797,771 +1184,6 @@ export class CloseTestPeek extends EditorAction2 { } } -interface ITreeElement { - type: string; - context: unknown; - id: string; - label: string; - onDidChange: Event; - labelWithIcons?: readonly (HTMLSpanElement | string)[]; - icon?: ThemeIcon; - description?: string; - ariaLabel?: string; -} - -class TestResultElement implements ITreeElement { - public readonly changeEmitter = new Emitter(); - public readonly onDidChange = this.changeEmitter.event; - public readonly type = 'result'; - public readonly context = this.value.id; - public readonly id = this.value.id; - public readonly label = this.value.name; - - public get icon() { - return icons.testingStatesToIcons.get( - this.value.completedAt === undefined - ? TestResultState.Running - : maxCountPriority(this.value.counts) - ); - } - - constructor(public readonly value: ITestResult) { } -} - -const openCoverageLabel = localize('openTestCoverage', 'View Test Coverage'); -const closeCoverageLabel = localize('closeTestCoverage', 'Close Test Coverage'); - -class CoverageElement implements ITreeElement { - public readonly type = 'coverage'; - public readonly context: undefined; - public readonly id = `coverage-${this.results.id}/${this.task.id}`; - public readonly onDidChange: Event; - - public get label() { - return this.isOpen ? closeCoverageLabel : openCoverageLabel; - } - - public get icon() { - return this.isOpen ? widgetClose : icons.testingCoverageReport; - } - - public get isOpen() { - return this.coverageService.selected.get()?.fromTaskId === this.task.id; - } - - constructor( - private readonly results: ITestResult, - public readonly task: ITestRunTaskResults, - private readonly coverageService: ITestCoverageService, - ) { - this.onDidChange = Event.fromObservableLight(coverageService.selected); - } - -} - -class TestCaseElement implements ITreeElement { - public readonly type = 'test'; - public readonly context: ITestItemContext = { - $mid: MarshalledId.TestItemContext, - tests: [InternalTestItem.serialize(this.test)], - }; - public readonly id = `${this.results.id}/${this.test.item.extId}`; - public readonly description?: string; - - public get onDidChange() { - if (!(this.results instanceof LiveTestResult)) { - return Event.None; - } - - return Event.filter(this.results.onChange, e => e.item.item.extId === this.test.item.extId); - } - - public get state() { - return this.test.tasks[this.taskIndex].state; - } - - public get label() { - return this.test.item.label; - } - - public get labelWithIcons() { - return renderLabelWithIcons(this.label); - } - - public get icon() { - return icons.testingStatesToIcons.get(this.state); - } - - public get outputSubject() { - return new TestOutputSubject(this.results, this.taskIndex, this.test); - } - - - constructor( - public readonly results: ITestResult, - public readonly test: TestResultItem, - public readonly taskIndex: number, - ) { } -} - -class TaskElement implements ITreeElement { - public readonly changeEmitter = new Emitter(); - public readonly onDidChange = this.changeEmitter.event; - public readonly type = 'task'; - public readonly context: string; - public readonly id: string; - public readonly label: string; - public readonly itemsCache = new CreationCache(); - - public get icon() { - return this.results.tasks[this.index].running ? icons.testingStatesToIcons.get(TestResultState.Running) : undefined; - } - - constructor(public readonly results: ITestResult, public readonly task: ITestRunTaskResults, public readonly index: number) { - this.id = `${results.id}/${index}`; - this.task = results.tasks[index]; - this.context = String(index); - this.label = this.task.name ?? localize('testUnnamedTask', 'Unnamed Task'); - } -} - -class TestMessageElement implements ITreeElement { - public readonly type = 'message'; - public readonly id: string; - public readonly label: string; - public readonly uri: URI; - public readonly location?: IRichLocation; - public readonly description?: string; - public readonly contextValue?: string; - public readonly message: ITestMessage; - - public get onDidChange() { - if (!(this.result instanceof LiveTestResult)) { - return Event.None; - } - - // rerender when the test case changes so it gets retired events - return Event.filter(this.result.onChange, e => e.item.item.extId === this.test.item.extId); - } - - public get context(): ITestMessageMenuArgs { - return getMessageArgs(this.test, this.message); - } - - public get outputSubject() { - return new TestOutputSubject(this.result, this.taskIndex, this.test); - } - - constructor( - public readonly result: ITestResult, - public readonly test: TestResultItem, - public readonly taskIndex: number, - public readonly messageIndex: number, - ) { - const m = this.message = test.tasks[taskIndex].messages[messageIndex]; - - this.location = m.location; - this.contextValue = m.type === TestMessageType.Error ? m.contextValue : undefined; - this.uri = buildTestUri({ - type: TestUriType.ResultMessage, - messageIndex, - resultId: result.id, - taskIndex, - testExtId: test.item.extId - }); - - this.id = this.uri.toString(); - - const asPlaintext = renderTestMessageAsText(m.message); - const lines = count(asPlaintext.trimEnd(), '\n'); - this.label = firstLine(asPlaintext); - if (lines > 0) { - this.description = lines > 1 - ? localize('messageMoreLinesN', '+ {0} more lines', lines) - : localize('messageMoreLines1', '+ 1 more line'); - } - } -} - -type TreeElement = TestResultElement | TestCaseElement | TestMessageElement | TaskElement | CoverageElement; - -class OutputPeekTree extends Disposable { - private disposed = false; - private readonly tree: WorkbenchCompressibleObjectTree; - private readonly treeActions: TreeActionsProvider; - private readonly requestReveal = this._register(new Emitter()); - - public readonly onDidRequestReview = this.requestReveal.event; - - constructor( - container: HTMLElement, - onDidReveal: Event<{ subject: InspectSubject; preserveFocus: boolean }>, - options: { showRevealLocationOnMessages: boolean; locationForProgress: string }, - @IContextMenuService private readonly contextMenuService: IContextMenuService, - @ITestResultService results: ITestResultService, - @IInstantiationService instantiationService: IInstantiationService, - @ITestExplorerFilterState explorerFilter: ITestExplorerFilterState, - @ITestCoverageService coverageService: ITestCoverageService, - @IProgressService progressService: IProgressService, - ) { - super(); - - this.treeActions = instantiationService.createInstance(TreeActionsProvider, options.showRevealLocationOnMessages, this.requestReveal,); - const diffIdentityProvider: IIdentityProvider = { - getId(e: TreeElement) { - return e.id; - } - }; - - this.tree = this._register(instantiationService.createInstance( - WorkbenchCompressibleObjectTree, - 'Test Output Peek', - container, - { - getHeight: () => 22, - getTemplateId: () => TestRunElementRenderer.ID, - }, - [instantiationService.createInstance(TestRunElementRenderer, this.treeActions)], - { - compressionEnabled: true, - hideTwistiesOfChildlessElements: true, - identityProvider: diffIdentityProvider, - sorter: { - compare(a, b) { - if (a instanceof TestCaseElement && b instanceof TestCaseElement) { - return cmpPriority(a.state, b.state); - } - - return 0; - }, - }, - accessibilityProvider: { - getAriaLabel(element: ITreeElement) { - return element.ariaLabel || element.label; - }, - getWidgetAriaLabel() { - return localize('testingPeekLabel', 'Test Result Messages'); - } - } - }, - )) as WorkbenchCompressibleObjectTree; - - const cc = new CreationCache(); - const getTaskChildren = (taskElem: TaskElement): Iterable> => { - const { results, index, itemsCache, task } = taskElem; - const tests = Iterable.filter(results.tests, test => test.tasks[index].state >= TestResultState.Running || test.tasks[index].messages.length > 0); - let result: Iterable> = Iterable.map(tests, test => ({ - element: itemsCache.getOrCreate(test, () => new TestCaseElement(results, test, index)), - incompressible: true, - children: getTestChildren(results, test, index), - })); - - if (task.coverage.get()) { - result = Iterable.concat( - Iterable.single>({ - element: new CoverageElement(results, task, coverageService), - collapsible: true, - incompressible: true, - }), - result, - ); - } - - return result; - }; - - const getTestChildren = (result: ITestResult, test: TestResultItem, taskIndex: number): Iterable> => { - return test.tasks[taskIndex].messages - .map((m, messageIndex) => - m.type === TestMessageType.Error - ? { element: cc.getOrCreate(m, () => new TestMessageElement(result, test, taskIndex, messageIndex)), incompressible: false } - : undefined - ) - .filter(isDefined); - }; - - const getResultChildren = (result: ITestResult): Iterable> => { - return result.tasks.map((task, taskIndex) => { - const taskElem = cc.getOrCreate(task, () => new TaskElement(result, task, taskIndex)); - return ({ - element: taskElem, - incompressible: false, - collapsible: true, - children: getTaskChildren(taskElem), - }); - }); - }; - - const getRootChildren = () => results.results.map(result => { - const element = cc.getOrCreate(result, () => new TestResultElement(result)); - return { - element, - incompressible: true, - collapsible: true, - collapsed: this.tree.hasElement(element) ? this.tree.isCollapsed(element) : true, - children: getResultChildren(result) - }; - }); - - // Queued result updates to prevent spamming CPU when lots of tests are - // completing and messaging quickly (#142514) - const taskChildrenToUpdate = new Set(); - const taskChildrenUpdate = this._register(new RunOnceScheduler(() => { - for (const taskNode of taskChildrenToUpdate) { - if (this.tree.hasElement(taskNode)) { - this.tree.setChildren(taskNode, getTaskChildren(taskNode), { diffIdentityProvider }); - } - } - taskChildrenToUpdate.clear(); - }, 300)); - - const queueTaskChildrenUpdate = (taskNode: TaskElement) => { - taskChildrenToUpdate.add(taskNode); - if (!taskChildrenUpdate.isScheduled()) { - taskChildrenUpdate.schedule(); - } - }; - - const attachToResults = (result: LiveTestResult) => { - const resultNode = cc.get(result)! as TestResultElement; - const disposable = new DisposableStore(); - disposable.add(result.onNewTask(i => { - if (result.tasks.length === 1) { - this.requestReveal.fire(new TaskSubject(result, 0)); // reveal the first task in new runs - } - - if (this.tree.hasElement(resultNode)) { - this.tree.setChildren(resultNode, getResultChildren(result), { diffIdentityProvider }); - } - - // note: tasks are bounded and their lifetime is equivalent to that of - // the test result, so this doesn't leak indefinitely. - const task = result.tasks[i]; - disposable.add(autorun(reader => { - task.coverage.read(reader); // add it to the autorun - queueTaskChildrenUpdate(cc.get(task) as TaskElement); - })); - })); - disposable.add(result.onEndTask(index => { - (cc.get(result.tasks[index]) as TaskElement | undefined)?.changeEmitter.fire(); - })); - - disposable.add(result.onChange(e => { - // try updating the item in each of its tasks - for (const [index, task] of result.tasks.entries()) { - const taskNode = cc.get(task) as TaskElement; - if (!this.tree.hasElement(taskNode)) { - continue; - } - - const itemNode = taskNode.itemsCache.get(e.item); - if (itemNode && this.tree.hasElement(itemNode)) { - if (e.reason === TestResultItemChangeReason.NewMessage && e.message.type === TestMessageType.Error) { - this.tree.setChildren(itemNode, getTestChildren(result, e.item, index), { diffIdentityProvider }); - } - return; - } - - queueTaskChildrenUpdate(taskNode); - } - })); - - disposable.add(result.onComplete(() => { - resultNode.changeEmitter.fire(); - disposable.dispose(); - })); - - return resultNode; - }; - - this._register(results.onResultsChanged(e => { - // little hack here: a result change can cause the peek to be disposed, - // but this listener will still be queued. Doing stuff with the tree - // will cause errors. - if (this.disposed) { - return; - } - - if ('completed' in e) { - (cc.get(e.completed) as TestResultElement | undefined)?.changeEmitter.fire(); - return; - } - - this.tree.setChildren(null, getRootChildren(), { diffIdentityProvider }); - - // done after setChildren intentionally so that the ResultElement exists in the cache. - if ('started' in e) { - for (const child of this.tree.getNode(null).children) { - this.tree.collapse(child.element, false); - } - - this.tree.expand(attachToResults(e.started), true); - } - })); - - const revealItem = (element: TreeElement, preserveFocus: boolean) => { - this.tree.setFocus([element]); - this.tree.setSelection([element]); - if (!preserveFocus) { - this.tree.domFocus(); - } - }; - - this._register(onDidReveal(async ({ subject, preserveFocus = false }) => { - if (subject instanceof TaskSubject) { - const resultItem = this.tree.getNode(null).children.find(c => { - if (c.element instanceof TaskElement) { - return c.element.results.id === subject.result.id && c.element.index === subject.taskIndex; - } - if (c.element instanceof TestResultElement) { - return c.element.id === subject.result.id; - } - return false; - }); - - if (resultItem) { - revealItem(resultItem.element!, preserveFocus); - } - return; - } - - const revealElement = subject instanceof TestOutputSubject - ? cc.get(subject.task)?.itemsCache.get(subject.test) - : cc.get(subject.message); - if (!revealElement || !this.tree.hasElement(revealElement)) { - return; - } - - const parents: TreeElement[] = []; - for (let parent = this.tree.getParentElement(revealElement); parent; parent = this.tree.getParentElement(parent)) { - parents.unshift(parent); - } - - for (const parent of parents) { - this.tree.expand(parent); - } - - if (this.tree.getRelativeTop(revealElement) === null) { - this.tree.reveal(revealElement, 0.5); - } - - revealItem(revealElement, preserveFocus); - })); - - this._register(this.tree.onDidOpen(async e => { - if (e.element instanceof TestMessageElement) { - this.requestReveal.fire(new MessageSubject(e.element.result, e.element.test, e.element.taskIndex, e.element.messageIndex)); - } else if (e.element instanceof TestCaseElement) { - const t = e.element; - const message = mapFindTestMessage(e.element.test, (_t, _m, mesasgeIndex, taskIndex) => - new MessageSubject(t.results, t.test, taskIndex, mesasgeIndex)); - this.requestReveal.fire(message || new TestOutputSubject(t.results, 0, t.test)); - } else if (e.element instanceof CoverageElement) { - const task = e.element.task; - if (e.element.isOpen) { - return coverageService.closeCoverage(); - } - progressService.withProgress( - { location: options.locationForProgress }, - () => coverageService.openCoverage(task, true) - ); - } - })); - - this._register(this.tree.onDidChangeSelection(evt => { - for (const element of evt.elements) { - if (element && 'test' in element) { - explorerFilter.reveal.value = element.test.item.extId; - break; - } - } - })); - - - this._register(this.tree.onContextMenu(e => this.onContextMenu(e))); - - this.tree.setChildren(null, getRootChildren()); - for (const result of results.results) { - if (!result.completedAt && result instanceof LiveTestResult) { - attachToResults(result); - } - } - } - - public layout(height: number, width: number) { - this.tree.layout(height, width); - } - - private onContextMenu(evt: ITreeContextMenuEvent) { - if (!evt.element) { - return; - } - - const actions = this.treeActions.provideActionBar(evt.element); - this.contextMenuService.showContextMenu({ - getAnchor: () => evt.anchor, - getActions: () => actions.secondary.length - ? [...actions.primary, new Separator(), ...actions.secondary] - : actions.primary, - getActionsContext: () => evt.element?.context - }); - } - - public override dispose() { - super.dispose(); - this.disposed = true; - } -} - -interface TemplateData { - label: HTMLElement; - icon: HTMLElement; - actionBar: ActionBar; - elementDisposable: DisposableStore; - templateDisposable: DisposableStore; -} - -class TestRunElementRenderer implements ICompressibleTreeRenderer { - public static readonly ID = 'testRunElementRenderer'; - public readonly templateId = TestRunElementRenderer.ID; - - constructor( - private readonly treeActions: TreeActionsProvider, - @IInstantiationService private readonly instantiationService: IInstantiationService, - ) { } - - /** @inheritdoc */ - public renderCompressedElements(node: ITreeNode, FuzzyScore>, _index: number, templateData: TemplateData): void { - const chain = node.element.elements; - const lastElement = chain[chain.length - 1]; - if ((lastElement instanceof TaskElement || lastElement instanceof TestMessageElement) && chain.length >= 2) { - this.doRender(chain[chain.length - 2], templateData, lastElement); - } else { - this.doRender(lastElement, templateData); - } - } - - /** @inheritdoc */ - public renderTemplate(container: HTMLElement): TemplateData { - const templateDisposable = new DisposableStore(); - const wrapper = dom.append(container, dom.$('.test-peek-item')); - const icon = dom.append(wrapper, dom.$('.state')); - const label = dom.append(wrapper, dom.$('.name')); - - const actionBar = new ActionBar(wrapper, { - actionViewItemProvider: (action, options) => - action instanceof MenuItemAction - ? this.instantiationService.createInstance(MenuEntryActionViewItem, action, { hoverDelegate: options.hoverDelegate }) - : undefined - }); - - const elementDisposable = new DisposableStore(); - templateDisposable.add(elementDisposable); - templateDisposable.add(actionBar); - - return { - icon, - label, - actionBar, - elementDisposable, - templateDisposable, - }; - } - - /** @inheritdoc */ - public renderElement(element: ITreeNode, _index: number, templateData: TemplateData): void { - this.doRender(element.element, templateData); - } - - /** @inheritdoc */ - public disposeTemplate(templateData: TemplateData): void { - templateData.templateDisposable.dispose(); - } - - /** Called to render a new element */ - private doRender(element: ITreeElement, templateData: TemplateData, subjectElement?: ITreeElement) { - templateData.elementDisposable.clear(); - templateData.elementDisposable.add( - element.onDidChange(() => this.doRender(element, templateData, subjectElement)), - ); - this.doRenderInner(element, templateData, subjectElement); - } - - /** Called, and may be re-called, to render or re-render an element */ - private doRenderInner(element: ITreeElement, templateData: TemplateData, subjectElement: ITreeElement | undefined) { - let { label, labelWithIcons, description } = element; - if (subjectElement instanceof TestMessageElement) { - description = subjectElement.label; - } - - const descriptionElement = description ? dom.$('span.test-label-description', {}, description) : ''; - if (labelWithIcons) { - dom.reset(templateData.label, ...labelWithIcons, descriptionElement); - } else { - dom.reset(templateData.label, label, descriptionElement); - } - - const icon = element.icon; - templateData.icon.className = `computed-state ${icon ? ThemeIcon.asClassName(icon) : ''}`; - - const actions = this.treeActions.provideActionBar(element); - templateData.actionBar.clear(); - templateData.actionBar.context = element.context; - templateData.actionBar.push(actions.primary, { icon: true, label: false }); - } -} - -class TreeActionsProvider { - constructor( - private readonly showRevealLocationOnMessages: boolean, - private readonly requestReveal: Emitter, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IMenuService private readonly menuService: IMenuService, - @ICommandService private readonly commandService: ICommandService, - @ITestProfileService private readonly testProfileService: ITestProfileService, - @IEditorService private readonly editorService: IEditorService, - ) { } - - public provideActionBar(element: ITreeElement) { - const test = element instanceof TestCaseElement ? element.test : undefined; - const capabilities = test ? this.testProfileService.capabilitiesForTest(test) : 0; - - const contextKeys: [string, unknown][] = [ - ['peek', Testing.OutputPeekContributionId], - [TestingContextKeys.peekItemType.key, element.type], - ]; - - let id = MenuId.TestPeekElement; - const primary: IAction[] = []; - const secondary: IAction[] = []; - - if (element instanceof TaskElement) { - primary.push(new Action( - 'testing.outputPeek.showResultOutput', - localize('testing.showResultOutput', "Show Result Output"), - ThemeIcon.asClassName(Codicon.terminal), - undefined, - () => this.requestReveal.fire(new TaskSubject(element.results, element.index)), - )); - } - - if (element instanceof TestResultElement) { - // only show if there are no collapsed test nodes that have more specific choices - if (element.value.tasks.length === 1) { - primary.push(new Action( - 'testing.outputPeek.showResultOutput', - localize('testing.showResultOutput', "Show Result Output"), - ThemeIcon.asClassName(Codicon.terminal), - undefined, - () => this.requestReveal.fire(new TaskSubject(element.value, 0)), - )); - } - - primary.push(new Action( - 'testing.outputPeek.reRunLastRun', - localize('testing.reRunLastRun', "Rerun Test Run"), - ThemeIcon.asClassName(icons.testingRunIcon), - undefined, - () => this.commandService.executeCommand('testing.reRunLastRun', element.value.id), - )); - - if (capabilities & TestRunProfileBitset.Debug) { - primary.push(new Action( - 'testing.outputPeek.debugLastRun', - localize('testing.debugLastRun', "Debug Test Run"), - ThemeIcon.asClassName(icons.testingDebugIcon), - undefined, - () => this.commandService.executeCommand('testing.debugLastRun', element.value.id), - )); - } - } - - if (element instanceof TestCaseElement || element instanceof TestMessageElement) { - contextKeys.push( - [TestingContextKeys.testResultOutdated.key, element.test.retired], - [TestingContextKeys.testResultState.key, testResultStateToContextValues[element.test.ownComputedState]], - ...getTestItemContextOverlay(element.test, capabilities), - ); - - const extId = element.test.item.extId; - if (element.test.tasks[element.taskIndex].messages.some(m => m.type === TestMessageType.Output)) { - primary.push(new Action( - 'testing.outputPeek.showResultOutput', - localize('testing.showResultOutput', "Show Result Output"), - ThemeIcon.asClassName(Codicon.terminal), - undefined, - () => this.requestReveal.fire(element.outputSubject), - )); - } - - secondary.push(new Action( - 'testing.outputPeek.revealInExplorer', - localize('testing.revealInExplorer', "Reveal in Test Explorer"), - ThemeIcon.asClassName(Codicon.listTree), - undefined, - () => this.commandService.executeCommand('_revealTestInExplorer', extId), - )); - - if (capabilities & TestRunProfileBitset.Run) { - primary.push(new Action( - 'testing.outputPeek.runTest', - localize('run test', 'Run Test'), - ThemeIcon.asClassName(icons.testingRunIcon), - undefined, - () => this.commandService.executeCommand('vscode.runTestsById', TestRunProfileBitset.Run, extId), - )); - } - - if (capabilities & TestRunProfileBitset.Debug) { - primary.push(new Action( - 'testing.outputPeek.debugTest', - localize('debug test', 'Debug Test'), - ThemeIcon.asClassName(icons.testingDebugIcon), - undefined, - () => this.commandService.executeCommand('vscode.runTestsById', TestRunProfileBitset.Debug, extId), - )); - } - - } - - if (element instanceof TestMessageElement) { - primary.push(new Action( - 'testing.outputPeek.goToFile', - localize('testing.goToFile', "Go to Source"), - ThemeIcon.asClassName(Codicon.goToFile), - undefined, - () => this.commandService.executeCommand('vscode.revealTest', element.test.item.extId), - )); - } - - if (element instanceof TestMessageElement) { - id = MenuId.TestMessageContext; - contextKeys.push([TestingContextKeys.testMessageContext.key, element.contextValue]); - if (this.showRevealLocationOnMessages && element.location) { - primary.push(new Action( - 'testing.outputPeek.goToError', - localize('testing.goToError', "Go to Source"), - ThemeIcon.asClassName(Codicon.goToFile), - undefined, - () => this.editorService.openEditor({ - resource: element.location!.uri, - options: { - selection: element.location!.range, - preserveFocus: true, - } - }), - )); - } - } - - - const contextOverlay = this.contextKeyService.createOverlay(contextKeys); - const result = { primary, secondary }; - const menu = this.menuService.getMenuActions(id, contextOverlay, { arg: element.context }); - createAndFillInActionBarActions(menu, result, 'inline'); - return result; - } -} const navWhen = ContextKeyExpr.and( EditorContextKeys.focus, @@ -2717,22 +1339,3 @@ export class ToggleTestingPeekHistory extends Action2 { opener.historyVisible.value = !opener.historyVisible.value; } } - -class CreationCache { - private readonly v = new WeakMap(); - - public get(key: object): T2 | undefined { - return this.v.get(key) as T2 | undefined; - } - - public getOrCreate(ref: object, factory: () => T2): T2 { - const existing = this.v.get(ref); - if (existing) { - return existing as T2; - } - - const fresh = factory(); - this.v.set(ref, fresh); - return fresh; - } -} diff --git a/src/vs/workbench/contrib/testing/common/testTypes.ts b/src/vs/workbench/contrib/testing/common/testTypes.ts index 5a90948fc68..f551217600e 100644 --- a/src/vs/workbench/contrib/testing/common/testTypes.ts +++ b/src/vs/workbench/contrib/testing/common/testTypes.ts @@ -169,6 +169,32 @@ export const enum TestMessageType { Output } +export interface ITestMessageStackTrace { + label: string; + uri: URI | undefined; + position: Position | undefined; +} + +export namespace ITestMessageStackTrace { + export interface Serialized { + label: string; + uri: UriComponents | undefined; + position: IPosition | undefined; + } + + export const serialize = (stack: Readonly): Serialized => ({ + label: stack.label, + uri: stack.uri?.toJSON(), + position: stack.position?.toJSON(), + }); + + export const deserialize = (uriIdentity: ITestUriCanonicalizer, stack: Serialized): ITestMessageStackTrace => ({ + label: stack.label, + uri: stack.uri ? uriIdentity.asCanonicalUri(URI.revive(stack.uri)) : undefined, + position: stack.position ? Position.lift(stack.position) : undefined, + }); +} + export interface ITestErrorMessage { message: string | IMarkdownString; type: TestMessageType.Error; @@ -176,6 +202,7 @@ export interface ITestErrorMessage { actual: string | undefined; contextValue: string | undefined; location: IRichLocation | undefined; + stackTrace: undefined | ITestMessageStackTrace[]; } export namespace ITestErrorMessage { @@ -186,6 +213,7 @@ export namespace ITestErrorMessage { actual: string | undefined; contextValue: string | undefined; location: IRichLocation.Serialize | undefined; + stackTrace: undefined | ITestMessageStackTrace.Serialized[]; } export const serialize = (message: Readonly): Serialized => ({ @@ -195,6 +223,7 @@ export namespace ITestErrorMessage { actual: message.actual, contextValue: message.contextValue, location: message.location && IRichLocation.serialize(message.location), + stackTrace: message.stackTrace?.map(ITestMessageStackTrace.serialize), }); export const deserialize = (uriIdentity: ITestUriCanonicalizer, message: Serialized): ITestErrorMessage => ({ @@ -204,6 +233,7 @@ export namespace ITestErrorMessage { actual: message.actual, contextValue: message.contextValue, location: message.location && IRichLocation.deserialize(uriIdentity, message.location), + stackTrace: message.stackTrace && message.stackTrace.map(s => ITestMessageStackTrace.deserialize(uriIdentity, s)), }); } @@ -258,6 +288,9 @@ export namespace ITestMessage { export const deserialize = (uriIdentity: ITestUriCanonicalizer, message: Serialized): ITestMessage => message.type === TestMessageType.Error ? ITestErrorMessage.deserialize(uriIdentity, message) : ITestOutputMessage.deserialize(uriIdentity, message); + + export const isDiffable = (message: ITestMessage): message is ITestErrorMessage & { actual: string; expected: string } => + message.type === TestMessageType.Error && message.actual !== undefined && message.expected !== undefined; } export interface ITestTaskState { diff --git a/src/vscode-dts/vscode.proposed.testMessageStackTrace.d.ts b/src/vscode-dts/vscode.proposed.testMessageStackTrace.d.ts new file mode 100644 index 00000000000..b3f09b92835 --- /dev/null +++ b/src/vscode-dts/vscode.proposed.testMessageStackTrace.d.ts @@ -0,0 +1,38 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + export class TestMessage2 extends TestMessage { + /** + * The stack trace associated with the message or failure. + */ + stackTrace?: TestMessageStackFrame[]; + } + + export class TestMessageStackFrame { + /** + * The location of this stack frame. This should be provided as a URI if the + * location of the call frame can be accessed by the editor. + */ + file?: Uri; + + /** + * Position of the stack frame within the file. + */ + position?: Position; + + /** + * The name of the stack frame, typically a method or function name. + */ + label: string; + + /** + * @param label The name of the stack frame + * @param file The file URI of the stack frame + * @param position The position of the stack frame within the file + */ + constructor(label: string, file?: Uri, position?: Position); + } +}