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'); } };