From e63e687ce005061ad724701fdd0d89028df51508 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 24 Feb 2023 22:03:51 -0800 Subject: [PATCH] testing: add a test results panel Previously, as in the issue this fixes #175377, messages without location were treated a little strangely, and messages for tests without a location were impossible to view. Them transient nature of the peek has been a longstanding overall experience papercut. This PR adds a Test Results panel which users can keep around as a persistent results view. It shares the code used to generate the peek view. With this PR, I've also tweaked it slightly so that the test 'history' is by default (unless toggled on) shown only in the panel, aligning the test errors peek more towards the diagnostics view, where the peek is a lighter, local view, and the panel shows everything. The only real 'addition' to the previously-peek content in this PR is that test output is shown when the top-level run node is selected. _Ideally_ this would be in a terminal where full ANSI codes and colors are supported, but it seems terminal land does not support this very well, and I want to eventually get ANSI color support in text mode anyway (#151964). --- .../contrib/testing/browser/icons.ts | 1 + .../testing/browser/testExplorerActions.ts | 5 + .../testing/browser/testing.contribution.ts | 26 +- .../testing/browser/testingOutputPeek.ts | 673 ++++++++++++------ .../browser/testingProgressUiService.ts | 1 - .../contrib/testing/common/constants.ts | 3 + .../testing/common/testingContentProvider.ts | 36 +- .../testing/common/testingPeekOpener.ts | 19 +- .../contrib/testing/common/testingUri.ts | 23 +- 9 files changed, 552 insertions(+), 235 deletions(-) diff --git a/src/vs/workbench/contrib/testing/browser/icons.ts b/src/vs/workbench/contrib/testing/browser/icons.ts index 7d7c9c2c76e24..b41cb5733335e 100644 --- a/src/vs/workbench/contrib/testing/browser/icons.ts +++ b/src/vs/workbench/contrib/testing/browser/icons.ts @@ -12,6 +12,7 @@ import { testingColorRunAction, testStatesToIconColors } from 'vs/workbench/cont import { TestResultState } from 'vs/workbench/contrib/testing/common/testTypes'; export const testingViewIcon = registerIcon('test-view-icon', Codicon.beaker, localize('testViewIcon', 'View icon of the test view.')); +export const testingResultsIcon = registerIcon('test-results-icon', Codicon.checklist, localize('testingResultsIcon', 'Icons for test results.')); export const testingRunIcon = registerIcon('testing-run-icon', Codicon.run, localize('testingRunIcon', 'Icon of the "run test" action.')); export const testingRunAllIcon = registerIcon('testing-run-all-icon', Codicon.runAll, localize('testingRunAllIcon', 'Icon of the "run all tests" action.')); // todo: https://github.com/microsoft/vscode-codicons/issues/72 diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index 1271a41394553..aff4157518f77 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -742,6 +742,11 @@ export class ClearTestResultsAction extends Action2 { order: ActionOrder.ClearResults, group: 'displayAction', when: ContextKeyExpr.equals('view', Testing.ExplorerViewId) + }, { + id: MenuId.ViewTitle, + order: ActionOrder.ClearResults, + group: 'navigation', + when: ContextKeyExpr.equals('view', Testing.ResultsViewId) }], }); } diff --git a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts index cc76910d35eca..4ed5a0057708f 100644 --- a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts +++ b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts @@ -19,10 +19,10 @@ import { Registry } from 'vs/platform/registry/common/platform'; import { Extensions as WorkbenchExtensions, IWorkbenchContributionsRegistry } from 'vs/workbench/common/contributions'; import { Extensions as ViewContainerExtensions, IViewContainersRegistry, IViewsRegistry, IViewsService, ViewContainerLocation } from 'vs/workbench/common/views'; import { REVEAL_IN_EXPLORER_COMMAND_ID } from 'vs/workbench/contrib/files/browser/fileConstants'; -import { testingViewIcon } from 'vs/workbench/contrib/testing/browser/icons'; +import { testingResultsIcon, testingViewIcon } from 'vs/workbench/contrib/testing/browser/icons'; import { TestingDecorations, TestingDecorationService } from 'vs/workbench/contrib/testing/browser/testingDecorations'; import { TestingExplorerView } from 'vs/workbench/contrib/testing/browser/testingExplorerView'; -import { CloseTestPeek, GoToNextMessageAction, GoToPreviousMessageAction, OpenMessageInEditorAction, TestingOutputPeekController, TestingPeekOpener, ToggleTestingPeekHistory } from 'vs/workbench/contrib/testing/browser/testingOutputPeek'; +import { CloseTestPeek, GoToNextMessageAction, GoToPreviousMessageAction, OpenMessageInEditorAction, TestResultsView, TestingOutputPeekController, TestingPeekOpener, ToggleTestingPeekHistory } from 'vs/workbench/contrib/testing/browser/testingOutputPeek'; import { ITestingOutputTerminalService, TestingOutputTerminalService } from 'vs/workbench/contrib/testing/browser/testingOutputTerminalService'; import { ITestingProgressUiService, TestingProgressTrigger, TestingProgressUiService } from 'vs/workbench/contrib/testing/browser/testingProgressUiService'; import { TestingViewPaneContainer } from 'vs/workbench/contrib/testing/browser/testingViewPaneContainer'; @@ -44,6 +44,7 @@ import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle import { allTestActions, discoverAndRunTests } from './testExplorerActions'; import './testingConfigurationUi'; import { ITestingContinuousRunService, TestingContinuousRunService } from 'vs/workbench/contrib/testing/common/testingContinuousRunService'; +import { ViewPaneContainer } from 'vs/workbench/browser/parts/views/viewPaneContainer'; registerSingleton(ITestService, TestService, InstantiationType.Delayed); registerSingleton(ITestResultStorage, TestResultStorage, InstantiationType.Delayed); @@ -73,8 +74,29 @@ const viewContainer = Registry.as(ViewContainerExtensio hideIfEmpty: true, }, ViewContainerLocation.Sidebar); + +const testResultsViewContainer = Registry.as(ViewContainerExtensions.ViewContainersRegistry).registerViewContainer({ + id: Testing.ResultsPanelId, + title: localize('testResultsPanelName', "Test Results"), + icon: testingResultsIcon, + ctorDescriptor: new SyncDescriptor(ViewPaneContainer, [Testing.ResultsPanelId, { mergeViewWithContainerWhenSingleView: true }]), + hideIfEmpty: true, + order: 3, +}, ViewContainerLocation.Panel, { doNotRegisterOpenCommand: true }); + const viewsRegistry = Registry.as(ViewContainerExtensions.ViewsRegistry); + +viewsRegistry.registerViews([{ + id: Testing.ResultsViewId, + name: localize('testResultsPanelName', "Test Results"), + containerIcon: testingResultsIcon, + canToggleVisibility: false, + canMoveView: true, + when: TestingContextKeys.hasAnyResults.isEqualTo(true), + ctorDescriptor: new SyncDescriptor(TestResultsView), +}], testResultsViewContainer); + viewsRegistry.registerViewWelcomeContent(Testing.ExplorerViewId, { content: localize('noTestProvidersRegistered', "No tests have been found in this workspace yet."), }); diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index 5065d22647b04..b7a25fd020403 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -17,7 +17,6 @@ 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 { ThemeIcon } from 'vs/base/common/themables'; import { Color } from 'vs/base/common/color'; import { Emitter, Event } from 'vs/base/common/event'; import { FuzzyScore } from 'vs/base/common/filters'; @@ -29,58 +28,66 @@ import { Lazy } from 'vs/base/common/lazy'; import { Disposable, DisposableStore, IDisposable, IReference, MutableDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { clamp } from 'vs/base/common/numbers'; import { count } from 'vs/base/common/strings'; +import { ThemeIcon } from 'vs/base/common/themables'; import { URI } from 'vs/base/common/uri'; +import 'vs/css!./testingOutputPeek'; import { ICodeEditor, IDiffEditorConstructionOptions, 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/codeEditorWidget'; +import { DiffEditorWidget } from 'vs/editor/browser/widget/diffEditorWidget'; import { EmbeddedCodeEditorWidget, EmbeddedDiffEditorWidget } from 'vs/editor/browser/widget/embeddedCodeEditorWidget'; 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 { IEditorContribution, ScrollType } from 'vs/editor/common/editorCommon'; +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 { MarkdownRenderer } from 'vs/editor/contrib/markdownRenderer/browser/markdownRenderer'; -import { getOuterEditor, IPeekViewService, peekViewTitleForeground, peekViewTitleInfoForeground, PeekViewWidget } from 'vs/editor/contrib/peekView/browser/peekView'; +import { IPeekViewService, PeekViewWidget, peekViewTitleForeground, peekViewTitleInfoForeground } from 'vs/editor/contrib/peekView/browser/peekView'; import { localize } from 'vs/nls'; -import { createAndFillInActionBarActions, MenuEntryActionViewItem } from 'vs/platform/actions/browser/menuEntryActionViewItem'; -import { IMenuService, MenuId, MenuItemAction } from 'vs/platform/actions/common/actions'; +import { Categories } from 'vs/platform/action/common/actionCommonCategories'; +import { MenuEntryActionViewItem, createAndFillInActionBarActions } from 'vs/platform/actions/browser/menuEntryActionViewItem'; +import { Action2, IMenuService, MenuId, MenuItemAction } 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'; import { IContextMenuService } from 'vs/platform/contextview/browser/contextView'; import { ITextEditorOptions } from 'vs/platform/editor/common/editor'; import { IInstantiationService, ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; +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 { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; +import { ITelemetryService } from 'vs/platform/telemetry/common/telemetry'; import { IColorTheme, IThemeService } from 'vs/platform/theme/common/themeService'; -import { Categories } from 'vs/platform/action/common/actionCommonCategories'; +import { IViewPaneOptions, ViewPane } from 'vs/workbench/browser/parts/views/viewPane'; import { EditorModel } from 'vs/workbench/common/editor/editorModel'; +import { IViewDescriptorService, IViewsService } from 'vs/workbench/common/views'; import { flatTestItemDelimiter } from 'vs/workbench/contrib/testing/browser/explorerProjections/display'; import { getTestItemContextOverlay } from 'vs/workbench/contrib/testing/browser/explorerProjections/testItemContextOverlay'; import * as icons from 'vs/workbench/contrib/testing/browser/icons'; import { ITestingOutputTerminalService } from 'vs/workbench/contrib/testing/browser/testingOutputTerminalService'; import { testingPeekBorder, testingPeekHeaderBackground } from 'vs/workbench/contrib/testing/browser/theme'; -import { AutoOpenPeekViewWhen, getTestingConfiguration, TestingConfigKeys } from 'vs/workbench/contrib/testing/common/configuration'; +import { AutoOpenPeekViewWhen, TestingConfigKeys, getTestingConfiguration } from 'vs/workbench/contrib/testing/common/configuration'; import { Testing } from 'vs/workbench/contrib/testing/common/constants'; -import { IObservableValue, MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; +import { IObservableValue, MutableObservableValue, staticObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; import { ITestExplorerFilterState } from 'vs/workbench/contrib/testing/common/testExplorerFilterState'; -import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; -import { ITestingPeekOpener } from 'vs/workbench/contrib/testing/common/testingPeekOpener'; -import { isFailedState } from 'vs/workbench/contrib/testing/common/testingStates'; -import { buildTestUri, ParsedTestUri, parseTestUri, TestUriType } from 'vs/workbench/contrib/testing/common/testingUri'; import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService'; -import { ITestResult, maxCountPriority, resultItemParents, TestResultItemChange, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; +import { ITestResult, TestResultItemChange, TestResultItemChangeReason, maxCountPriority, resultItemParents } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultService, ResultChangeEvent } from 'vs/workbench/contrib/testing/common/testResultService'; import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; import { IRichLocation, ITestErrorMessage, ITestItem, ITestMessage, ITestRunTask, ITestTaskState, TestMessageType, TestResultItem, TestResultState, TestRunProfileBitset } 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 { 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 'vs/css!./testingOutputPeek'; -import { INotificationService } from 'vs/platform/notification/common/notification'; -class TestDto { +class MessageSubject { public readonly test: ITestItem; public readonly messages: ITestMessage[]; public readonly expectedUri: URI; @@ -108,6 +115,17 @@ class TestDto { } } +class ResultSubject { + public readonly outputUri: URI; + public readonly revealLocation: undefined; + + constructor(public readonly resultId: string) { + this.outputUri = buildTestUri({ resultId, type: TestUriType.AllOutput }); + } +} + +type InspectSubject = MessageSubject | ResultSubject; + /** Iterates through every message in every result */ function* allMessages(results: readonly ITestResult[]) { for (const result of results) { @@ -128,12 +146,23 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener private lastUri?: TestUriWithDocument; + /** @inheritdoc */ + public readonly historyVisible = MutableObservableValue.stored(new StoredValue({ + key: 'testHistoryVisibleInPeek', + scope: StorageScope.PROFILE, + target: StorageTarget.USER, + }, this.storageService), false); + constructor( @IConfigurationService private readonly configuration: IConfigurationService, @IEditorService private readonly editorService: IEditorService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @ITestResultService private readonly testResults: ITestResultService, @ITestService private readonly testService: ITestService, + @IStorageService private readonly storageService: IStorageService, + @IViewsService private readonly viewsService: IViewsService, + @ICommandService private readonly commandService: ICommandService, + @INotificationService private readonly notificationService: INotificationService, ) { super(); this._register(testResults.onTestChanged(this.openPeekOnFailure, this)); @@ -180,15 +209,15 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener messageIndex: candidate.index, resultId: result.id, testExtId: test.item.extId, - }, { selection: message.location!.range, ...options }); + }, undefined, { selection: message.location!.range, ...options }); return true; } /** @inheritdoc */ - public peekUri(uri: URI, options?: Partial) { + public peekUri(uri: URI, options: IShowResultOptions = {}) { const parsed = parseTestUri(uri); const result = parsed && this.testResults.getResult(parsed.resultId); - if (!parsed || !result) { + if (!parsed || !result || !('testExtId' in parsed)) { return false; } @@ -204,7 +233,7 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener messageIndex: parsed.messageIndex, resultId: result.id, testExtId: parsed.testExtId, - }, { selection: message.location.range, ...options }); + }, options.inEditor, { selection: message.location.range, ...options.options }); return true; } @@ -215,8 +244,48 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener } } + public openCurrentInEditor(): void { + const current = this.getActiveControl(); + if (!current) { + return; + } + + const options = { pinned: false, revealIfOpened: true }; + if (current instanceof ResultSubject) { + this.editorService.openEditor({ resource: current.outputUri, options }); + return; + } + + const message = current.messages[current.messageIndex]; + if (current.isDiffable) { + this.editorService.openEditor({ + original: { resource: current.expectedUri }, + modified: { resource: current.actualUri }, + options, + }); + } else if (typeof message.message === 'string') { + this.editorService.openEditor({ resource: current.messageUri, options }); + } else { + this.commandService.executeCommand('markdown.showPreview', current.messageUri).catch(err => { + this.notificationService.error(localize('testing.markdownPeekError', 'Could not open markdown preview: {0}.\n\nPlease make sure the markdown extension is enabled.', err.message)); + }); + } + } + + private getActiveControl(): InspectSubject | undefined { + const editor = getPeekedEditorFromFocus(this.codeEditorService); + const controller = editor && TestingOutputPeekController.get(editor); + return controller?.subject ?? this.viewsService.getActiveViewWithId(Testing.ResultsViewId)?.subject; + } + /** @inheritdoc */ - private async showPeekFromUri(uri: TestUriWithDocument, options?: ITextEditorOptions) { + private async showPeekFromUri(uri: TestUriWithDocument, editor?: IEditor, options?: ITextEditorOptions) { + if (isCodeEditor(editor)) { + this.lastUri = uri; + TestingOutputPeekController.get(editor)?.show(buildTestUri(this.lastUri)); + return true; + } + const pane = await this.editorService.openEditor({ resource: uri.documentUri, options: { revealIfOpened: true, ...options } @@ -270,7 +339,7 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener } const controllers = editors.map(TestingOutputPeekController.get); - if (controllers.some(c => c?.isVisible)) { + if (controllers.some(c => c?.subject)) { return; } @@ -397,7 +466,7 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo /** * Currently-shown peek view. */ - private readonly peek = this._register(new MutableDisposable()); + private readonly peek = this._register(new MutableDisposable()); /** * URI of the currently-visible peek, if any. @@ -410,31 +479,18 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo private readonly visible: IContextKey; /** - * Gets whether a peek is currently shown in the associated editor. + * Gets the currently display subject. Undefined if the peek is not open. */ - public get isVisible() { - return this.peek.value; + public get subject() { + return this.peek.value?.current; } - /** - * Whether the history part of the peek view should be visible. - */ - public readonly historyVisible = MutableObservableValue.stored(new StoredValue({ - key: 'testHistoryVisibleInPeek', - scope: StorageScope.PROFILE, - target: StorageTarget.USER, - }, this.storageService), true); - constructor( private readonly editor: ICodeEditor, - @IEditorService private readonly editorService: IEditorService, @ICodeEditorService private readonly codeEditorService: ICodeEditorService, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITestResultService private readonly testResults: ITestResultService, - @IStorageService private readonly storageService: IStorageService, @IContextKeyService contextKeyService: IContextKeyService, - @ICommandService private readonly commandService: ICommandService, - @INotificationService private readonly notificationService: INotificationService, ) { super(); this.visible = TestingContextKeys.isPeekVisible.bindTo(contextKeyService); @@ -454,42 +510,17 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo } } - public openCurrentInEditor() { - const current = this.peek.value?.current; - if (!current) { - return; - } - - const options = { pinned: false, revealIfOpened: true }; - const message = current.messages[current.messageIndex]; - - if (current.isDiffable) { - this.editorService.openEditor({ - original: { resource: current.expectedUri }, - modified: { resource: current.actualUri }, - options, - }); - } else if (typeof message.message === 'string') { - this.editorService.openEditor({ resource: current.messageUri, options }); - } else { - this.commandService.executeCommand('markdown.showPreview', current.messageUri).catch(err => { - this.notificationService.error(localize('testing.markdownPeekError', 'Could not open markdown preview: {0}.\n\nPlease make sure the markdown extension is enabled.', err.message)); - }); - } - } - /** * Shows a peek for the message in the editor. */ public async show(uri: URI) { - const dto = this.retrieveTest(uri); - if (!dto) { + const subjecet = this.retrieveTest(uri); + if (!subjecet) { return; } - const message = dto.messages[dto.messageIndex]; if (!this.peek.value) { - this.peek.value = this.instantiationService.createInstance(TestingOutputPeek, this.editor, this.historyVisible); + this.peek.value = this.instantiationService.createInstance(TestResultsPeek, this.editor); this.peek.value.onDidClose(() => { this.visible.set(false); this.currentPeekUri = undefined; @@ -500,23 +531,27 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo this.peek.value!.create(); } - alert(renderStringAsPlaintext(message.message)); - this.peek.value.setModel(dto); + if (subjecet instanceof MessageSubject) { + const message = subjecet.messages[subjecet.messageIndex]; + alert(renderStringAsPlaintext(message.message)); + } + + this.peek.value.setModel(subjecet); this.currentPeekUri = uri; } public async openAndShow(uri: URI) { - const dto = this.retrieveTest(uri); - if (!dto) { + const subject = this.retrieveTest(uri); + if (!subject) { return; } - if (!dto.revealLocation || dto.revealLocation.uri.toString() === this.editor.getModel()?.uri.toString()) { + if (!subject.revealLocation || subject.revealLocation.uri.toString() === this.editor.getModel()?.uri.toString()) { return this.show(uri); } const otherEditor = await this.codeEditorService.openCodeEditor({ - resource: dto.revealLocation.uri, + resource: subject.revealLocation.uri, options: { pinned: false, revealIfOpened: true } }, this.editor); @@ -537,13 +572,17 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo * Shows the next message in the peek, if possible. */ public next() { - const dto = this.peek.value?.current; - if (!dto) { + const subject = this.peek.value?.current; + if (!subject) { return; } let found = false; for (const { messageIndex, taskIndex, result, test } of allMessages(this.testResults.results)) { + if (subject instanceof ResultSubject && result.id === subject.resultId) { + found = true; // open the first message found in the current result + } + if (found) { this.openAndShow(buildTestUri({ type: TestUriType.ResultMessage, @@ -553,7 +592,7 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo testExtId: test.item.extId })); return; - } else if (dto.test.extId === test.item.extId && dto.messageIndex === messageIndex && dto.taskIndex === taskIndex && dto.resultId === result.id) { + } if (subject instanceof MessageSubject && subject.test.extId === test.item.extId && subject.messageIndex === messageIndex && subject.taskIndex === taskIndex && subject.resultId === result.id) { found = true; } } @@ -563,37 +602,44 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo * Shows the previous message in the peek, if possible. */ public previous() { - const dto = this.peek.value?.current; - if (!dto) { + const subject = this.peek.value?.current; + if (!subject) { return; } let previous: { messageIndex: number; taskIndex: number; result: ITestResult; test: TestResultItem } | undefined; for (const m of allMessages(this.testResults.results)) { - if (dto.test.extId === m.test.item.extId && dto.messageIndex === m.messageIndex && dto.taskIndex === m.taskIndex && dto.resultId === m.result.id) { - if (!previous) { - return; + if (subject instanceof ResultSubject) { + if (m.result.id === subject.resultId) { + break; } + continue; + } - this.openAndShow(buildTestUri({ - type: TestUriType.ResultMessage, - messageIndex: previous.messageIndex, - taskIndex: previous.taskIndex, - resultId: previous.result.id, - testExtId: previous.test.item.extId - })); - return; + if (subject.test.extId === m.test.item.extId && subject.messageIndex === m.messageIndex && subject.taskIndex === m.taskIndex && subject.resultId === m.result.id) { + break; } previous = m; } + + if (previous) { + this.openAndShow(buildTestUri({ + type: TestUriType.ResultMessage, + messageIndex: previous.messageIndex, + taskIndex: previous.taskIndex, + resultId: previous.result.id, + testExtId: previous.test.item.extId + })); + } } /** * Removes the peek view if it's being displayed on the given test ID. */ public removeIfPeekingForTest(testId: string) { - if (this.peek.value?.current?.test.extId === testId) { + const c = this.peek.value?.current; + if (c && c instanceof MessageSubject && c.test.extId === testId) { this.peek.clear(); } } @@ -620,102 +666,83 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo } } - private retrieveTest(uri: URI): TestDto | undefined { + private retrieveTest(uri: URI): InspectSubject | undefined { const parts = parseTestUri(uri); if (!parts) { return undefined; } + if (parts.type === TestUriType.AllOutput) { + return new ResultSubject(parts.resultId); + } + const { resultId, testExtId, taskIndex, messageIndex } = parts; const test = this.testResults.getResult(parts.resultId)?.getStateById(testExtId); if (!test || !test.tasks[parts.taskIndex]) { return; } - return new TestDto(resultId, test, taskIndex, messageIndex); + return new MessageSubject(resultId, test, taskIndex, messageIndex); } } -class TestingOutputPeek extends PeekViewWidget { - private static lastHeightInLines?: number; +class TestResultsViewContent extends Disposable { private static lastSplitWidth?: number; - private readonly visibilityChange = this._disposables.add(new Emitter()); - private readonly didReveal = this._disposables.add(new Emitter()); + private readonly didReveal = this._register(new Emitter<{ subject: InspectSubject; preserveFocus: boolean }>()); private dimension?: dom.Dimension; private splitView!: SplitView; private contentProviders!: IPeekOutputRenderer[]; - public current?: TestDto; + public current?: InspectSubject; + + /** Fired when a tree item is selected. Populated only on .fillBody() */ + public onDidRequestReveal!: Event; constructor( - editor: ICodeEditor, - private readonly historyVisible: IObservableValue, - @IThemeService themeService: IThemeService, - @IPeekViewService peekViewService: IPeekViewService, - @IContextKeyService private readonly contextKeyService: IContextKeyService, - @IMenuService private readonly menuService: IMenuService, - @IInstantiationService instantiationService: IInstantiationService, + private readonly editor: ICodeEditor | undefined, + private readonly options: { + historyVisible: IObservableValue; + showRevealLocationOnMessages: boolean; + }, + @IContextKeyService contextKeyService: IContextKeyService, + @IInstantiationService private readonly instantiationService: IInstantiationService, @ITextModelService protected readonly modelService: ITextModelService, ) { - super(editor, { showFrame: true, frameWidth: 1, showArrow: true, isResizeable: true, isAccessible: true, className: 'test-output-peek' }, instantiationService); + super(); TestingContextKeys.isInPeek.bindTo(contextKeyService); - this._disposables.add(themeService.onDidColorThemeChange(this.applyTheme, this)); - this._disposables.add(this.onDidClose(() => this.visibilityChange.fire(false))); - this.applyTheme(themeService.getColorTheme()); - peekViewService.addExclusiveWidget(editor, this); - } - - private applyTheme(theme: IColorTheme) { - const borderColor = theme.getColor(testingPeekBorder) || Color.transparent; - const headerBg = theme.getColor(testingPeekHeaderBackground) || Color.transparent; - this.style({ - arrowColor: borderColor, - frameColor: borderColor, - headerBackgroundColor: headerBg, - primaryHeadingColor: theme.getColor(peekViewTitleForeground), - secondaryHeadingColor: theme.getColor(peekViewTitleInfoForeground) - }); - } - - protected override _fillHead(container: HTMLElement): void { - super._fillHead(container); - - const actions: IAction[] = []; - const menu = this.menuService.createMenu(MenuId.TestPeekTitle, this.contextKeyService); - createAndFillInActionBarActions(menu, undefined, actions); - this._actionbarWidget!.push(actions, { label: false, icon: true, index: 0 }); - menu.dispose(); } - protected override _fillBody(containerElement: HTMLElement): void { - const initialSpitWidth = TestingOutputPeek.lastSplitWidth; + public fillBody(containerElement: HTMLElement): void { + const initialSpitWidth = TestResultsViewContent.lastSplitWidth; this.splitView = new SplitView(containerElement, { orientation: Orientation.HORIZONTAL }); + const { historyVisible, showRevealLocationOnMessages } = this.options; const messageContainer = dom.append(containerElement, dom.$('.test-output-peek-message-container')); this.contentProviders = [ - this._disposables.add(this.instantiationService.createInstance(DiffContentProvider, this.editor, messageContainer)), - this._disposables.add(this.instantiationService.createInstance(MarkdownTestMessagePeek, messageContainer)), - this._disposables.add(this.instantiationService.createInstance(PlainTextMessagePeek, this.editor, messageContainer)), + this._register(this.instantiationService.createInstance(DiffContentProvider, this.editor, messageContainer)), + this._register(this.instantiationService.createInstance(MarkdownTestMessagePeek, messageContainer)), + this._register(this.instantiationService.createInstance(PlainTextMessagePeek, this.editor, messageContainer)), ]; const treeContainer = dom.append(containerElement, dom.$('.test-output-peek-tree')); - const tree = this._disposables.add(this.instantiationService.createInstance( + const tree = this._register(this.instantiationService.createInstance( OutputPeekTree, - this.editor, treeContainer, - this.visibilityChange.event, this.didReveal.event, + { showRevealLocationOnMessages } )); + this.onDidRequestReveal = tree.onDidRequestReview; + this.splitView.addView({ onDidChange: Event.None, element: messageContainer, minimumSize: 200, maximumSize: Number.MAX_VALUE, layout: width => { - TestingOutputPeek.lastSplitWidth = width; + TestResultsViewContent.lastSplitWidth = width; if (this.dimension) { for (const provider of this.contentProviders) { provider.layout({ height: this.dimension.height, width }); @@ -737,8 +764,8 @@ class TestingOutputPeek extends PeekViewWidget { }, Sizing.Distribute); const historyViewIndex = 1; - this.splitView.setViewVisible(historyViewIndex, this.historyVisible.value); - this._disposables.add(this.historyVisible.onDidChange(visible => { + this.splitView.setViewVisible(historyViewIndex, historyVisible.value); + this._register(historyVisible.onDidChange(visible => { this.splitView.setViewVisible(historyViewIndex, visible); })); @@ -747,50 +774,131 @@ class TestingOutputPeek extends PeekViewWidget { } } + /** + * Shows a message in-place without showing or changing the peek location. + * This is mostly used if peeking a message without a location. + */ + public async reveal(opts: { subject: InspectSubject; preserveFocus: boolean }) { + this.didReveal.fire(opts); + await Promise.all(this.contentProviders.map(p => p.update(opts.subject))); + } + + public onLayoutBody(height: number, width: number) { + this.dimension = new dom.Dimension(width, height); + this.splitView.layout(width); + } + + public onWidth(width: number) { + this.splitView.layout(width); + } +} + +class TestResultsPeek extends PeekViewWidget { + private static lastHeightInLines?: number; + + private readonly visibilityChange = this._disposables.add(new Emitter()); + private readonly content: TestResultsViewContent; + private dimension?: dom.Dimension; + public current?: InspectSubject; + + constructor( + editor: ICodeEditor, + @IThemeService themeService: IThemeService, + @IPeekViewService peekViewService: IPeekViewService, + @ITestingPeekOpener testingPeek: ITestingPeekOpener, + @IContextKeyService private readonly contextKeyService: IContextKeyService, + @IMenuService private readonly menuService: IMenuService, + @IInstantiationService instantiationService: IInstantiationService, + @ITextModelService protected readonly modelService: ITextModelService, + ) { + super(editor, { showFrame: true, frameWidth: 1, showArrow: true, isResizeable: true, isAccessible: true, className: 'test-output-peek' }, instantiationService); + + TestingContextKeys.isInPeek.bindTo(contextKeyService); + this._disposables.add(themeService.onDidColorThemeChange(this.applyTheme, this)); + this._disposables.add(this.onDidClose(() => this.visibilityChange.fire(false))); + this.content = this._disposables.add(instantiationService.createInstance(TestResultsViewContent, editor, { historyVisible: testingPeek.historyVisible, showRevealLocationOnMessages: false })); + this.applyTheme(themeService.getColorTheme()); + peekViewService.addExclusiveWidget(editor, this); + } + + private applyTheme(theme: IColorTheme) { + const borderColor = theme.getColor(testingPeekBorder) || Color.transparent; + const headerBg = theme.getColor(testingPeekHeaderBackground) || Color.transparent; + this.style({ + arrowColor: borderColor, + frameColor: borderColor, + headerBackgroundColor: headerBg, + primaryHeadingColor: theme.getColor(peekViewTitleForeground), + secondaryHeadingColor: theme.getColor(peekViewTitleInfoForeground) + }); + } + + protected override _fillHead(container: HTMLElement): void { + super._fillHead(container); + + const actions: IAction[] = []; + const menu = this.menuService.createMenu(MenuId.TestPeekTitle, this.contextKeyService); + createAndFillInActionBarActions(menu, undefined, actions); + this._actionbarWidget!.push(actions, { label: false, icon: true, index: 0 }); + menu.dispose(); + } + + protected override _fillBody(containerElement: HTMLElement): void { + this.content.fillBody(containerElement); + this.content.onDidRequestReveal(sub => { + TestingOutputPeekController.get(this.editor)?.show(sub instanceof MessageSubject ? sub.messageUri : sub.outputUri); + }); + } + /** * Updates the test to be shown. */ - public setModel(dto: TestDto): Promise { - const message = dto.messages[dto.messageIndex]; - const previous = this.current; + public setModel(subject: InspectSubject): Promise { + if (subject instanceof ResultSubject) { + this.current = subject; + return this.showInPlace(subject); + } - if (!dto.revealLocation && !previous) { + const message = subject.messages[subject.messageIndex]; + const previous = this.current; + if (!subject.revealLocation && !previous) { return Promise.resolve(); } - this.current = dto; - if (!dto.revealLocation) { - return this.showInPlace(dto); + this.current = subject; + if (!subject.revealLocation) { + return this.showInPlace(subject); } - this.show(dto.revealLocation.range, TestingOutputPeek.lastHeightInLines || hintMessagePeekHeight(message)); - this.editor.revealPositionNearTop(dto.revealLocation.range.getStartPosition(), ScrollType.Smooth); + this.show(subject.revealLocation.range, TestResultsPeek.lastHeightInLines || hintMessagePeekHeight(message)); + this.editor.revealPositionNearTop(subject.revealLocation.range.getStartPosition(), ScrollType.Smooth); - return this.showInPlace(dto); + return this.showInPlace(subject); } /** * Shows a message in-place without showing or changing the peek location. * This is mostly used if peeking a message without a location. */ - public async showInPlace(dto: TestDto) { - const message = dto.messages[dto.messageIndex]; - this.setTitle(firstLine(renderStringAsPlaintext(message.message)), stripIcons(dto.test.label)); - this.didReveal.fire(dto); - this.visibilityChange.fire(true); - await Promise.all(this.contentProviders.map(p => p.update(dto, message))); + public async showInPlace(subject: InspectSubject) { + if (subject instanceof MessageSubject) { + const message = subject.messages[subject.messageIndex]; + this.setTitle(firstLine(renderStringAsPlaintext(message.message)), stripIcons(subject.test.label)); + } else { + this.setTitle(localize('testOutputTitle', 'Test Output')); + } + await this.content.reveal({ subject: subject, preserveFocus: false }); } protected override _relayout(newHeightInLines: number): void { super._relayout(newHeightInLines); - TestingOutputPeek.lastHeightInLines = newHeightInLines; + TestResultsPeek.lastHeightInLines = newHeightInLines; } /** @override */ protected override _doLayoutBody(height: number, width: number) { super._doLayoutBody(height, width); - this.dimension = new dom.Dimension(width, height); - this.splitView.layout(width); + this.content.onLayoutBody(height, width); } /** @override */ @@ -800,13 +908,67 @@ class TestingOutputPeek extends PeekViewWidget { this.dimension = new dom.Dimension(width, this.dimension.height); } - this.splitView.layout(width); + this.content.onWidth(width); + } +} + +export class TestResultsView extends ViewPane { + private readonly content = this._register(this.instantiationService.createInstance(TestResultsViewContent, undefined, { + historyVisible: staticObservableValue(true), + showRevealLocationOnMessages: true, + })); + + constructor( + options: IViewPaneOptions, + @IKeybindingService keybindingService: IKeybindingService, + @IContextMenuService contextMenuService: IContextMenuService, + @IConfigurationService configurationService: IConfigurationService, + @IContextKeyService contextKeyService: IContextKeyService, + @IViewDescriptorService viewDescriptorService: IViewDescriptorService, + @IInstantiationService instantiationService: IInstantiationService, + @IOpenerService openerService: IOpenerService, + @IThemeService themeService: IThemeService, + @ITelemetryService telemetryService: ITelemetryService, + @ITestResultService private readonly resultService: ITestResultService, + ) { + super(options, keybindingService, contextMenuService, configurationService, contextKeyService, viewDescriptorService, instantiationService, openerService, themeService, telemetryService); + + this._register(resultService.onResultsChanged(ev => { + if (!this.isVisible()) { + return; + } + + if ('started' in ev) { + // allow the tree to update so that the item exists + queueMicrotask(() => this.content.reveal({ subject: new ResultSubject(ev.started.id), preserveFocus: true })); + } + })); + } + + public get subject() { + return this.content.current; + } + + protected override renderBody(container: HTMLElement): void { + super.renderBody(container); + this.content.fillBody(container); + this.content.onDidRequestReveal(subject => this.content.reveal({ preserveFocus: true, subject })); + + const [lastResult] = this.resultService.results; + if (lastResult) { + this.content.reveal({ preserveFocus: true, subject: new ResultSubject(lastResult.id) }); + } + } + + protected override layoutBody(height: number, width: number): void { + super.layoutBody(height, width); + this.content.onLayoutBody(height, width); } } interface IPeekOutputRenderer extends IDisposable { /** Updates the displayed test. Should clear if it cannot display the test. */ - update(dto: TestDto, message: ITestMessage): void; + update(subject: InspectSubject): void; /** Recalculate content layout. */ layout(dimension: dom.IDimension): void; /** Dispose the content provider. */ @@ -816,6 +978,7 @@ interface IPeekOutputRenderer extends IDisposable { const commonEditorOptions: IEditorOptions = { scrollBeyondLastLine: false, links: true, + lineNumbers: 'off', scrollbar: { verticalScrollbarSize: 14, horizontal: 'auto', @@ -848,12 +1011,12 @@ const isDiffable = (message: ITestMessage): message is ITestErrorMessage & { act 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 widget = this._register(new MutableDisposable()); private readonly model = this._register(new MutableDisposable()); private dimension?: dom.IDimension; constructor( - private readonly editor: ICodeEditor, + private readonly editor: ICodeEditor | undefined, private readonly container: HTMLElement, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITextModelService private readonly modelService: ITextModelService, @@ -861,23 +1024,32 @@ class DiffContentProvider extends Disposable implements IPeekOutputRenderer { super(); } - public async update({ expectedUri, actualUri }: TestDto, message: ITestErrorMessage) { + public async update(subject: InspectSubject) { + if (!(subject instanceof MessageSubject)) { + return this.clear(); + } + const message = subject.messages[subject.messageIndex]; if (!isDiffable(message)) { return this.clear(); } const [original, modified] = await Promise.all([ - this.modelService.createModelReference(expectedUri), - this.modelService.createModelReference(actualUri), + 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.instantiationService.createInstance( + this.widget.value = this.editor ? this.instantiationService.createInstance( EmbeddedDiffEditorWidget, this.container, diffEditorOptions, this.editor, + ) : this.instantiationService.createInstance( + DiffEditorWidget, + this.container, + diffEditorOptions, + {}, ); if (this.dimension) { @@ -955,7 +1127,12 @@ class MarkdownTestMessagePeek extends Disposable implements IPeekOutputRenderer super(); } - public update(_dto: TestDto, message: ITestErrorMessage): void { + public update(subject: InspectSubject): void { + if (!(subject instanceof MessageSubject)) { + return this.textPreview.clear(); + } + + const message = subject.messages[subject.messageIndex]; if (isDiffable(message) || typeof message.message === 'string') { return this.textPreview.clear(); } @@ -973,12 +1150,12 @@ class MarkdownTestMessagePeek extends Disposable implements IPeekOutputRenderer } class PlainTextMessagePeek extends Disposable implements IPeekOutputRenderer { - private readonly widget = 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, + private readonly editor: ICodeEditor | undefined, private readonly container: HTMLElement, @IInstantiationService private readonly instantiationService: IInstantiationService, @ITextModelService private readonly modelService: ITextModelService, @@ -986,18 +1163,31 @@ class PlainTextMessagePeek extends Disposable implements IPeekOutputRenderer { super(); } - public async update({ messageUri }: TestDto, message: ITestErrorMessage) { - if (isDiffable(message) || typeof message.message !== 'string') { - return this.clear(); + public async update(subject: InspectSubject) { + let uri: URI; + if (subject instanceof MessageSubject) { + const message = subject.messages[subject.messageIndex]; + if (isDiffable(message) || typeof message.message !== 'string') { + return this.clear(); + } + uri = subject.messageUri; + } else { + uri = subject.outputUri; } - const modelRef = this.model.value = await this.modelService.createModelReference(messageUri); + + const modelRef = this.model.value = await this.modelService.createModelReference(uri); if (!this.widget.value) { - this.widget.value = this.instantiationService.createInstance( + 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) { @@ -1006,7 +1196,7 @@ class PlainTextMessagePeek extends Disposable implements IPeekOutputRenderer { } this.widget.value.setModel(modelRef.object.textEditorModel); - this.widget.value.updateOptions(this.getOptions(isMultiline(message.message))); + this.widget.value.updateOptions(commonEditorOptions); } private clear() { @@ -1018,12 +1208,6 @@ class PlainTextMessagePeek extends Disposable implements IPeekOutputRenderer { this.dimension = dimensions; this.widget.value?.layout(dimensions); } - - protected getOptions(isMultiline: boolean): IDiffEditorOptions { - return isMultiline - ? { ...diffEditorOptions, lineNumbers: 'on' } - : { ...diffEditorOptions, lineNumbers: 'off' }; - } } const hintMessagePeekHeight = (msg: ITestMessage) => @@ -1058,8 +1242,8 @@ class SimpleDiffEditorModel extends EditorModel { } } -function getOuterEditorFromDiffEditor(accessor: ServicesAccessor): ICodeEditor | null { - const diffEditors = accessor.get(ICodeEditorService).listDiffEditors(); +function getOuterEditorFromDiffEditor(codeEditorService: ICodeEditorService): ICodeEditor | null { + const diffEditors = codeEditorService.listDiffEditors(); for (const diffEditor of diffEditors) { if (diffEditor.hasTextFocus() && diffEditor instanceof EmbeddedDiffEditorWidget) { @@ -1067,7 +1251,7 @@ function getOuterEditorFromDiffEditor(accessor: ServicesAccessor): ICodeEditor | } } - return getOuterEditor(accessor); + return null; } export class CloseTestPeek extends EditorAction2 { @@ -1086,7 +1270,7 @@ export class CloseTestPeek extends EditorAction2 { } runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor): void { - const parent = getOuterEditorFromDiffEditor(accessor); + const parent = getPeekedEditorFromFocus(accessor.get(ICodeEditorService)); TestingOutputPeekController.get(parent ?? editor)?.removePeek(); } } @@ -1208,13 +1392,14 @@ 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( - editor: ICodeEditor, container: HTMLElement, - onDidChangeVisibility: Event, - onDidReveal: Event, - peekController: TestingOutputPeek, + onDidReveal: Event<{ subject: InspectSubject; preserveFocus: boolean }>, + options: { showRevealLocationOnMessages: boolean }, @IContextMenuService private readonly contextMenuService: IContextMenuService, @ITestResultService results: ITestResultService, @IInstantiationService instantiationService: IInstantiationService, @@ -1222,7 +1407,7 @@ class OutputPeekTree extends Disposable { ) { super(); - this.treeActions = instantiationService.createInstance(TreeActionsProvider); + this.treeActions = instantiationService.createInstance(TreeActionsProvider, options.showRevealLocationOnMessages); const diffIdentityProvider: IIdentityProvider = { getId(e: TreeElement) { return e.id; @@ -1352,8 +1537,24 @@ class OutputPeekTree extends Disposable { this.tree.setChildren(null, getRootChildren(), { diffIdentityProvider }); })); - this._register(onDidReveal(dto => { - const messageNode = creationCache.get(dto.messages[dto.messageIndex]); + const revealItem = (element: TreeElement, preserveFocus: boolean) => { + this.tree.setFocus([element]); + this.tree.setSelection([element]); + if (!preserveFocus) { + this.tree.domFocus(); + } + }; + + this._register(onDidReveal(({ subject, preserveFocus = false }) => { + if (subject instanceof ResultSubject) { + const resultItem = this.tree.getNode(null).children.find(c => (c.element as TestResultElement)?.id === subject.resultId); + if (resultItem) { + revealItem(resultItem.element as TestResultElement, preserveFocus); + } + return; + } + + const messageNode = creationCache.get(subject.messages[subject.messageIndex]); if (!messageNode || !this.tree.hasElement(messageNode)) { return; } @@ -1371,21 +1572,14 @@ class OutputPeekTree extends Disposable { this.tree.reveal(messageNode, 0.5); } - this.tree.setFocus([messageNode]); - this.tree.setSelection([messageNode]); - this.tree.domFocus(); + revealItem(messageNode, preserveFocus); })); this._register(this.tree.onDidOpen(async e => { - if (!(e.element instanceof TestMessageElement)) { - return; - } - - const dto = new TestDto(e.element.result.id, e.element.test, e.element.taskIndex, e.element.messageIndex); - if (!dto.revealLocation) { - peekController.showInPlace(dto); - } else { - TestingOutputPeekController.get(editor)?.openAndShow(dto.messageUri); + if (e.element instanceof TestResultElement) { + this.requestReveal.fire(new ResultSubject(e.element.id)); + } else if (e.element instanceof TestMessageElement) { + this.requestReveal.fire(new MessageSubject(e.element.result.id, e.element.test, e.element.taskIndex, e.element.messageIndex)); } })); @@ -1514,11 +1708,13 @@ class TestRunElementRenderer implements ICompressibleTreeRenderer this.editorService.openEditor({ + resource: element.location!.uri, + options: { + selection: element.location!.range, + preserveFocus: true, + } + }), + )); + } if (element.marker !== undefined) { primary.push(new Action( 'testing.outputPeek.showMessageInTerminal', @@ -1631,12 +1842,20 @@ const navWhen = ContextKeyExpr.and( TestingContextKeys.isPeekVisible, ); +/** + * Gets the appropriate editor for peeking based on the currently focused editor. + */ +const getPeekedEditorFromFocus = (codeEditorService: ICodeEditorService) => { + const editor = codeEditorService.getFocusedCodeEditor() || codeEditorService.getActiveCodeEditor(); + return editor && getPeekedEditor(codeEditorService, editor); +}; + /** * Gets the editor where the peek may be shown, bubbling upwards if the given * editor is embedded (i.e. inside a peek already). */ -const getPeekedEditor = (accessor: ServicesAccessor, editor: ICodeEditor) => { - if (TestingOutputPeekController.get(editor)?.isVisible) { +const getPeekedEditor = (codeEditorService: ICodeEditorService, editor: ICodeEditor) => { + if (TestingOutputPeekController.get(editor)?.subject) { return editor; } @@ -1644,7 +1863,7 @@ const getPeekedEditor = (accessor: ServicesAccessor, editor: ICodeEditor) => { return editor.getParentEditor(); } - const outer = getOuterEditorFromDiffEditor(accessor); + const outer = getOuterEditorFromDiffEditor(codeEditorService); if (outer) { return outer; } @@ -1652,7 +1871,7 @@ const getPeekedEditor = (accessor: ServicesAccessor, editor: ICodeEditor) => { return editor; }; -export class GoToNextMessageAction extends EditorAction2 { +export class GoToNextMessageAction extends Action2 { public static readonly ID = 'testing.goToNextMessage'; constructor() { super({ @@ -1677,12 +1896,15 @@ export class GoToNextMessageAction extends EditorAction2 { }); } - public runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor) { - TestingOutputPeekController.get(getPeekedEditor(accessor, editor))?.next(); + public override run(accessor: ServicesAccessor) { + const editor = getPeekedEditorFromFocus(accessor.get(ICodeEditorService)); + if (editor) { + TestingOutputPeekController.get(editor)?.next(); + } } } -export class GoToPreviousMessageAction extends EditorAction2 { +export class GoToPreviousMessageAction extends Action2 { public static readonly ID = 'testing.goToPreviousMessage'; constructor() { super({ @@ -1707,12 +1929,15 @@ export class GoToPreviousMessageAction extends EditorAction2 { }); } - public runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor) { - TestingOutputPeekController.get(getPeekedEditor(accessor, editor))?.previous(); + public override run(accessor: ServicesAccessor) { + const editor = getPeekedEditorFromFocus(accessor.get(ICodeEditorService)); + if (editor) { + TestingOutputPeekController.get(editor)?.previous(); + } } } -export class OpenMessageInEditorAction extends EditorAction2 { +export class OpenMessageInEditorAction extends Action2 { public static readonly ID = 'testing.openMessageInEditor'; constructor() { super({ @@ -1725,12 +1950,12 @@ export class OpenMessageInEditorAction extends EditorAction2 { }); } - public runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor) { - TestingOutputPeekController.get(getPeekedEditor(accessor, editor))?.openCurrentInEditor(); + public override run(accessor: ServicesAccessor) { + accessor.get(ITestingPeekOpener).openCurrentInEditor(); } } -export class ToggleTestingPeekHistory extends EditorAction2 { +export class ToggleTestingPeekHistory extends Action2 { public static readonly ID = 'testing.toggleTestingPeekHistory'; constructor() { super({ @@ -1752,10 +1977,8 @@ export class ToggleTestingPeekHistory extends EditorAction2 { }); } - public runEditorCommand(accessor: ServicesAccessor, editor: ICodeEditor) { - const ctrl = TestingOutputPeekController.get(getPeekedEditor(accessor, editor)); - if (ctrl) { - ctrl.historyVisible.value = !ctrl.historyVisible.value; - } + public override run(accessor: ServicesAccessor) { + const opener = accessor.get(ITestingPeekOpener); + opener.historyVisible.value = !opener.historyVisible.value; } } diff --git a/src/vs/workbench/contrib/testing/browser/testingProgressUiService.ts b/src/vs/workbench/contrib/testing/browser/testingProgressUiService.ts index b854765983480..702a9f9da1dc7 100644 --- a/src/vs/workbench/contrib/testing/browser/testingProgressUiService.ts +++ b/src/vs/workbench/contrib/testing/browser/testingProgressUiService.ts @@ -143,7 +143,6 @@ export class TestingProgressUiService extends Disposable implements ITestingProg this.updateTextEmitter.fire(message); this.windowProg.value.report({ message }); const nextProgress = collected.runSoFar / collected.totalWillBeRun; - console.log({ increment: nextProgress - this.lastProgress, total: 1 }); this.testViewProg.value!.report({ increment: (nextProgress - this.lastProgress) * 1000, total: 1 }); this.lastProgress = nextProgress; } diff --git a/src/vs/workbench/contrib/testing/common/constants.ts b/src/vs/workbench/contrib/testing/common/constants.ts index 3edb527930ff4..542dbae955145 100644 --- a/src/vs/workbench/contrib/testing/common/constants.ts +++ b/src/vs/workbench/contrib/testing/common/constants.ts @@ -13,6 +13,9 @@ export const enum Testing { ExplorerViewId = 'workbench.view.testing', OutputPeekContributionId = 'editor.contrib.testingOutputPeek', DecorationsContributionId = 'editor.contrib.testingDecorations', + + ResultsPanelId = 'workbench.panel.testResults', + ResultsViewId = 'workbench.panel.testResults.view', } export const enum TestExplorerViewMode { diff --git a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts index 16be2168fde57..7c78d8db22e44 100644 --- a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts +++ b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts @@ -3,16 +3,17 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; import { URI } from 'vs/base/common/uri'; +import { ILanguageSelection, ILanguageService } from 'vs/editor/common/languages/language'; import { ITextModel } from 'vs/editor/common/model'; import { IModelService } from 'vs/editor/common/services/model'; -import { ILanguageSelection, ILanguageService } from 'vs/editor/common/languages/language'; import { ITextModelContentProvider, ITextModelService } from 'vs/editor/common/services/resolverService'; +import { localize } from 'vs/nls'; import { IWorkbenchContribution } from 'vs/workbench/common/contributions'; -import { TestMessageType } from 'vs/workbench/contrib/testing/common/testTypes'; -import { parseTestUri, TestUriType, TEST_DATA_SCHEME } from 'vs/workbench/contrib/testing/common/testingUri'; import { ITestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; -import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; +import { TestMessageType } from 'vs/workbench/contrib/testing/common/testTypes'; +import { TEST_DATA_SCHEME, TestUriType, parseTestUri } from 'vs/workbench/contrib/testing/common/testingUri'; /** * A content provider that returns various outputs for tests. This is used @@ -43,8 +44,33 @@ export class TestingContentProvider implements IWorkbenchContribution, ITextMode } const result = this.resultService.getResult(parsed.resultId); - const test = result?.getStateById(parsed.testExtId); + if (!result) { + return null; + } + if (parsed.type === TestUriType.AllOutput) { + const stream = await result.getOutput(); + const model = this.modelService.createModel('', null, resource, false); + const append = (text: string) => model.applyEdits([{ + range: { startColumn: 1, endColumn: 1, startLineNumber: Infinity, endLineNumber: Infinity }, + text, + }]); + + let hadContent = false; + stream.on('data', buf => { + hadContent ||= buf.byteLength > 0; + append(removeAnsiEscapeCodes(buf.toString())); + }); + stream.on('end', () => { + if (!hadContent) { + append(localize('runNoOutout', 'The test run did not record any output.')); + } + }); + model.onWillDispose(() => stream.destroy()); + return model; + } + + const test = result?.getStateById(parsed.testExtId); if (!test) { return null; } diff --git a/src/vs/workbench/contrib/testing/common/testingPeekOpener.ts b/src/vs/workbench/contrib/testing/common/testingPeekOpener.ts index 4d999302c9e55..c00dbf9ad51bc 100644 --- a/src/vs/workbench/contrib/testing/common/testingPeekOpener.ts +++ b/src/vs/workbench/contrib/testing/common/testingPeekOpener.ts @@ -8,10 +8,22 @@ import { ITextEditorOptions } from 'vs/platform/editor/common/editor'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { TestResultItem } from 'vs/workbench/contrib/testing/common/testTypes'; import { ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; +import { IEditor } from 'vs/editor/common/editorCommon'; +import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; + +export interface IShowResultOptions { + /** Reveal the peek, if configured, in the given editor */ + inEditor?: IEditor; + /** Editor options, if a new editor is opened */ + options?: Partial; +} export interface ITestingPeekOpener { _serviceBrand: undefined; + /** Whether test history should be shown in the results output. */ + historyVisible: MutableObservableValue; + /** * Tries to peek the first test error, if the item is in a failed state. * @returns a boolean indicating whether a peek was opened @@ -22,7 +34,12 @@ export interface ITestingPeekOpener { * Peeks at the given test message uri. * @returns a boolean indicating whether a peek was opened */ - peekUri(uri: URI, options?: Partial): boolean; + peekUri(uri: URI, options?: IShowResultOptions): boolean; + + /** + * Opens the currently selected message in an editor. + */ + openCurrentInEditor(): void; /** * Opens the peek. Shows any available message. diff --git a/src/vs/workbench/contrib/testing/common/testingUri.ts b/src/vs/workbench/contrib/testing/common/testingUri.ts index 04bc87965c306..f2a2556649972 100644 --- a/src/vs/workbench/contrib/testing/common/testingUri.ts +++ b/src/vs/workbench/contrib/testing/common/testingUri.ts @@ -3,16 +3,23 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { assertNever } from 'vs/base/common/assert'; import { URI } from 'vs/base/common/uri'; export const TEST_DATA_SCHEME = 'vscode-test-data'; export const enum TestUriType { + AllOutput, ResultMessage, ResultActualOutput, ResultExpectedOutput, } +interface IAllOutputReference { + type: TestUriType.AllOutput; + resultId: string; +} + interface IResultTestUri { resultId: string; taskIndex: number; @@ -30,12 +37,14 @@ interface IResultTestOutputReference extends IResultTestUri { } export type ParsedTestUri = + | IAllOutputReference | IResultTestMessageReference | IResultTestOutputReference; const enum TestUriParts { Results = 'results', + AllOutput = 'output', Messages = 'message', Text = 'TestFailureMessage', ActualOutput = 'ActualOutput', @@ -63,10 +72,22 @@ export const parseTestUri = (uri: URI): ParsedTestUri | undefined => { } } + if (request[0] === TestUriParts.AllOutput) { + return { resultId: locationId, type: TestUriType.AllOutput }; + } + return undefined; }; export const buildTestUri = (parsed: ParsedTestUri): URI => { + if (parsed.type === TestUriType.AllOutput) { + return URI.from({ + scheme: TEST_DATA_SCHEME, + authority: TestUriParts.Results, + path: ['', parsed.resultId, TestUriParts.AllOutput].join('/'), + }); + } + const uriParts = { scheme: TEST_DATA_SCHEME, authority: TestUriParts.Results @@ -86,6 +107,6 @@ export const buildTestUri = (parsed: ParsedTestUri): URI => { case TestUriType.ResultMessage: return msgRef(parsed.resultId, parsed.taskIndex, parsed.messageIndex, TestUriParts.Text); default: - throw new Error('Invalid test uri'); + assertNever(parsed, 'Invalid test uri'); } };