From 6b666279b8ea2d99a42cbbc6dbbe4ef6d35b3b50 Mon Sep 17 00:00:00 2001 From: Connor Peet Date: Fri, 9 Apr 2021 17:46:21 -0700 Subject: [PATCH] wip --- src/vs/platform/terminal/common/terminal.ts | 6 + src/vs/vscode.proposed.d.ts | 3 + .../api/browser/mainThreadTesting.ts | 15 +- .../workbench/api/common/extHost.protocol.ts | 1 + src/vs/workbench/api/common/extHostTesting.ts | 5 +- .../browser/terminalProcessManager.ts | 10 +- .../testing/browser/testExplorerActions.ts | 24 +++ .../testing/browser/testing.contribution.ts | 35 ++-- .../testing/browser/testingExplorerFilter.ts | 22 +-- .../browser/testingOutputTerminalService.ts | 159 ++++++++++++++++++ .../contrib/testing/common/observableValue.ts | 16 +- .../contrib/testing/common/testResult.ts | 121 ++++++++++++- .../testing/common/testResultService.ts | 17 +- .../testing/common/testResultStorage.ts | 106 +++++++++++- .../contrib/testing/common/testService.ts | 4 +- .../contrib/testing/common/testServiceImpl.ts | 6 +- .../test/common/testResultService.test.ts | 21 ++- .../test/common/testResultStorage.test.ts | 3 + 18 files changed, 514 insertions(+), 60 deletions(-) create mode 100644 src/vs/workbench/contrib/testing/browser/testingOutputTerminalService.ts diff --git a/src/vs/platform/terminal/common/terminal.ts b/src/vs/platform/terminal/common/terminal.ts index aec2480f665..1e2ec937a2e 100644 --- a/src/vs/platform/terminal/common/terminal.ts +++ b/src/vs/platform/terminal/common/terminal.ts @@ -243,6 +243,12 @@ export interface IShellLaunchConfig { */ isExtensionCustomPtyTerminal?: boolean; + /** + * Custom PTY/pseudoterminal process to use. + * @todo should `TerminalProcessExtHostProxy` be passed to here and remove `isExtensionCustomPtyTerminal`? + */ + customPtyImplementation?: ITerminalChildProcess; + /** * A UUID generated by the extension host process for terminals created on the extension host process. */ diff --git a/src/vs/vscode.proposed.d.ts b/src/vs/vscode.proposed.d.ts index 0eae941dfad..b267f382001 100644 --- a/src/vs/vscode.proposed.d.ts +++ b/src/vs/vscode.proposed.d.ts @@ -2323,6 +2323,9 @@ declare module 'vscode' { * Appends raw output from the test runner. On the user's request, the * output will be displayed in a terminal. ANSI escape sequences, * such as colors and text styles, are supported. + * + * @param output Output text to append + * @param associateTo Optionally, associate the given segment of output */ appendOutput(output: string): void; } diff --git a/src/vs/workbench/api/browser/mainThreadTesting.ts b/src/vs/workbench/api/browser/mainThreadTesting.ts index 1681a58d8e3..47cb37351e7 100644 --- a/src/vs/workbench/api/browser/mainThreadTesting.ts +++ b/src/vs/workbench/api/browser/mainThreadTesting.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { bufferToStream, VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken } from 'vs/base/common/cancellation'; import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; import { isDefined } from 'vs/base/common/types'; @@ -71,7 +72,8 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh * @inheritdoc */ public $publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void { - this.resultService.push(new HydratedTestResult(results, persist)); + // todo + this.resultService.push(new HydratedTestResult(results, () => Promise.resolve(bufferToStream(VSBuffer.alloc(0))), persist)); } /** @@ -84,6 +86,17 @@ export class MainThreadTesting extends Disposable implements MainThreadTestingSh } } + /** + * @inheritdoc + */ + public $appendOutputToRun(runId: string, output: VSBuffer): void { + const r = this.resultService.getResult(runId); + if (r && r instanceof LiveTestResult) { + r.output.append(output); + } + } + + /** * @inheritdoc */ diff --git a/src/vs/workbench/api/common/extHost.protocol.ts b/src/vs/workbench/api/common/extHost.protocol.ts index 83fd9e2e4b5..4bf3e105c84 100644 --- a/src/vs/workbench/api/common/extHost.protocol.ts +++ b/src/vs/workbench/api/common/extHost.protocol.ts @@ -1977,6 +1977,7 @@ export interface MainThreadTestingShape { $publishDiff(resource: ExtHostTestingResource, uri: UriComponents, diff: TestsDiff): void; $updateTestStateInRun(runId: string, testId: string, state: TestResultState, duration?: number): void; $appendTestMessageInRun(runId: string, testId: string, message: ITestMessage): void; + $appendOutputToRun(runId: string, output: VSBuffer): void; $runTests(req: RunTestsRequest, token: CancellationToken): Promise; $publishExtensionProvidedResults(results: ISerializedTestResults, persist: boolean): void; } diff --git a/src/vs/workbench/api/common/extHostTesting.ts b/src/vs/workbench/api/common/extHostTesting.ts index d75657c2d4b..142aa312694 100644 --- a/src/vs/workbench/api/common/extHostTesting.ts +++ b/src/vs/workbench/api/common/extHostTesting.ts @@ -5,6 +5,7 @@ import { mapFind } from 'vs/base/common/arrays'; import { disposableTimeout } from 'vs/base/common/async'; +import { VSBuffer } from 'vs/base/common/buffer'; import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { Emitter } from 'vs/base/common/event'; import { once } from 'vs/base/common/functional'; @@ -274,8 +275,8 @@ export class ExtHostTesting implements ExtHostTestingShape { try { await provider.runTests({ - appendOutput() { - // todo + appendOutput: message => { + this.proxy.$appendOutputToRun(req.runId, VSBuffer.fromString(message)); }, appendMessage: (test, message) => { if (!isExcluded(test)) { diff --git a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts index 5f1cb27eb9d..9f37c9694ef 100644 --- a/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts +++ b/src/vs/workbench/contrib/terminal/browser/terminalProcessManager.ts @@ -42,7 +42,7 @@ const LATENCY_MEASURING_INTERVAL = 1000; enum ProcessType { Process, - ExtensionTerminal + PsuedoTerminal } /** @@ -191,9 +191,9 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce let newProcess: ITerminalChildProcess; - if (shellLaunchConfig.isExtensionCustomPtyTerminal) { - this._processType = ProcessType.ExtensionTerminal; - newProcess = this._instantiationService.createInstance(TerminalProcessExtHostProxy, this._instanceId, shellLaunchConfig, cols, rows); + if (shellLaunchConfig.isExtensionCustomPtyTerminal || shellLaunchConfig.customPtyImplementation) { + this._processType = ProcessType.PsuedoTerminal; + newProcess = shellLaunchConfig.customPtyImplementation || this._instantiationService.createInstance(TerminalProcessExtHostProxy, this._instanceId, shellLaunchConfig, cols, rows); } else { const forceExtHostProcess = (this._configHelper.config as any).extHostProcess; if (shellLaunchConfig.cwd && typeof shellLaunchConfig.cwd === 'object') { @@ -488,7 +488,7 @@ export class TerminalProcessManager extends Disposable implements ITerminalProce await this.ptyProcessReady; this._dataFilter.triggerSwap(); this._hasWrittenData = true; - if (this.shellProcessId || this._processType === ProcessType.ExtensionTerminal) { + if (this.shellProcessId || this._processType === ProcessType.PsuedoTerminal) { if (this._process) { // Send data if the pty is ready this._process.input(data); diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index d1f066f0e00..c7f2363f5ba 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -31,6 +31,7 @@ import * as icons from 'vs/workbench/contrib/testing/browser/icons'; import { ITestExplorerFilterState } from 'vs/workbench/contrib/testing/browser/testingExplorerFilter'; import { TestingExplorerView, TestingExplorerViewModel } from 'vs/workbench/contrib/testing/browser/testingExplorerView'; import { TestingOutputPeekController } from 'vs/workbench/contrib/testing/browser/testingOutputPeek'; +import { ITestingOutputTerminalService } from 'vs/workbench/contrib/testing/browser/testingOutputTerminalService'; import { TestExplorerViewMode, TestExplorerViewSorting, Testing } from 'vs/workbench/contrib/testing/common/constants'; import { InternalTestItem, ITestItem, TestIdPath, TestIdWithSrc, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestingAutoRun } from 'vs/workbench/contrib/testing/common/testingAutoRun'; @@ -435,6 +436,29 @@ export class TestingSortByLocationAction extends ViewAction } } +export class ShowMostRecentOutputAction extends Action2 { + constructor() { + super({ + id: 'testing.showMostRecentOutput', + title: localize('testing.showMostRecentOutput', "Collapse All Tests"), + f1: false, + icon: Codicon.terminal, + menu: { + id: MenuId.ViewTitle, + order: ActionOrder.Collapse, + group: 'navigation', + when: ContextKeyEqualsExpr.create('view', Testing.ExplorerViewId) + } + }); + } + + public run(accessor: ServicesAccessor) { + const result = accessor.get(ITestResultService).results[0]; + accessor.get(ITestingOutputTerminalService).open(result); + } +} + + export class CollapseAllAction extends ViewAction { constructor() { super({ diff --git a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts index 4474084a823..3aaa5ae0b0e 100644 --- a/src/vs/workbench/contrib/testing/browser/testing.contribution.ts +++ b/src/vs/workbench/contrib/testing/browser/testing.contribution.ts @@ -20,6 +20,7 @@ import { TestingDecorations } from 'vs/workbench/contrib/testing/browser/testing import { ITestExplorerFilterState, TestExplorerFilterState } from 'vs/workbench/contrib/testing/browser/testingExplorerFilter'; import { TestingExplorerView } from 'vs/workbench/contrib/testing/browser/testingExplorerView'; import { CloseTestPeek, ITestingPeekOpener, TestingOutputPeekController, TestingPeekOpener } from 'vs/workbench/contrib/testing/browser/testingOutputPeek'; +import { ITestingOutputTerminalService, TestingOutputTerminalService } from 'vs/workbench/contrib/testing/browser/testingOutputTerminalService'; import { ITestingProgressUiService, TestingProgressUiService } from 'vs/workbench/contrib/testing/browser/testingProgressUiService'; import { TestingViewPaneContainer } from 'vs/workbench/contrib/testing/browser/testingViewPaneContainer'; import { testingConfiguation } from 'vs/workbench/contrib/testing/common/configuration'; @@ -41,6 +42,7 @@ registerSingleton(ITestResultStorage, TestResultStorage); registerSingleton(ITestResultService, TestResultService); registerSingleton(ITestExplorerFilterState, TestExplorerFilterState); registerSingleton(ITestingAutoRun, TestingAutoRun, true); +registerSingleton(ITestingOutputTerminalService, TestingOutputTerminalService, true); registerSingleton(ITestingPeekOpener, TestingPeekOpener); registerSingleton(ITestingProgressUiService, TestingProgressUiService); registerSingleton(IWorkspaceTestCollectionService, WorkspaceTestCollectionService); @@ -86,30 +88,31 @@ viewsRegistry.registerViews([{ when: ContextKeyExpr.greater(TestingContextKeys.providerCount.key, 0), }], viewContainer); -registerAction2(Action.TestingViewAsListAction); -registerAction2(Action.TestingViewAsTreeAction); +registerAction2(Action.AutoRunOffAction); +registerAction2(Action.AutoRunOnAction); registerAction2(Action.CancelTestRunAction); -registerAction2(Action.RunSelectedAction); -registerAction2(Action.DebugSelectedAction); -registerAction2(Action.TestingSortByNameAction); -registerAction2(Action.TestingSortByLocationAction); -registerAction2(Action.RefreshTestsAction); +registerAction2(Action.ClearTestResultsAction); registerAction2(Action.CollapseAllAction); -registerAction2(Action.RunAllAction); registerAction2(Action.DebugAllAction); -registerAction2(Action.EditFocusedTest); -registerAction2(Action.ClearTestResultsAction); -registerAction2(Action.AutoRunOffAction); -registerAction2(Action.AutoRunOnAction); registerAction2(Action.DebugAtCursor); -registerAction2(Action.RunAtCursor); registerAction2(Action.DebugCurrentFile); -registerAction2(Action.RunCurrentFile); -registerAction2(Action.ReRunFailedTests); registerAction2(Action.DebugFailedTests); -registerAction2(Action.ReRunLastRun); registerAction2(Action.DebugLastRun); +registerAction2(Action.DebugSelectedAction); +registerAction2(Action.EditFocusedTest); +registerAction2(Action.RefreshTestsAction); +registerAction2(Action.ReRunFailedTests); +registerAction2(Action.ReRunLastRun); +registerAction2(Action.RunAllAction); +registerAction2(Action.RunAtCursor); +registerAction2(Action.RunCurrentFile); +registerAction2(Action.RunSelectedAction); registerAction2(Action.SearchForTestExtension); +registerAction2(Action.ShowMostRecentOutputAction); +registerAction2(Action.TestingSortByLocationAction); +registerAction2(Action.TestingSortByNameAction); +registerAction2(Action.TestingViewAsListAction); +registerAction2(Action.TestingViewAsTreeAction); registerAction2(CloseTestPeek); Registry.as(WorkbenchExtensions.Workbench).registerWorkbenchContribution(TestingContentProvider, LifecyclePhase.Restored); diff --git a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts index 04f12a92609..a4f8d4e1641 100644 --- a/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts +++ b/src/vs/workbench/contrib/testing/browser/testingExplorerFilter.ts @@ -25,7 +25,7 @@ import { IThemeService, ThemeIcon } from 'vs/platform/theme/common/themeService' import { ViewContainerLocation } from 'vs/workbench/common/views'; import { testingFilterIcon } from 'vs/workbench/contrib/testing/browser/icons'; import { TestExplorerStateFilter, Testing } from 'vs/workbench/contrib/testing/common/constants'; -import { ObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; +import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; import { TestIdPath } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; @@ -33,17 +33,17 @@ import { ITestService } from 'vs/workbench/contrib/testing/common/testService'; export interface ITestExplorerFilterState { _serviceBrand: undefined; - readonly text: ObservableValue; + readonly text: MutableObservableValue; /** * Reveal request: the path to the test to reveal. The last element of the * array is the test the user wanted to reveal, and the previous * items are its parents. */ - readonly reveal: ObservableValue; - readonly stateFilter: ObservableValue; - readonly currentDocumentOnly: ObservableValue; + readonly reveal: MutableObservableValue; + readonly stateFilter: MutableObservableValue; + readonly currentDocumentOnly: MutableObservableValue; /** Whether excluded test should be shown in the view */ - readonly showExcludedTests: ObservableValue; + readonly showExcludedTests: MutableObservableValue; readonly onDidRequestInputFocus: Event; focusInput(): void; @@ -54,20 +54,20 @@ export const ITestExplorerFilterState = createDecorator(); - public readonly text = new ObservableValue(''); - public readonly stateFilter = ObservableValue.stored(new StoredValue({ + public readonly text = new MutableObservableValue(''); + public readonly stateFilter = MutableObservableValue.stored(new StoredValue({ key: 'testStateFilter', scope: StorageScope.WORKSPACE, target: StorageTarget.USER }, this.storage), TestExplorerStateFilter.All); - public readonly currentDocumentOnly = ObservableValue.stored(new StoredValue({ + public readonly currentDocumentOnly = MutableObservableValue.stored(new StoredValue({ key: 'testsByCurrentDocumentOnly', scope: StorageScope.WORKSPACE, target: StorageTarget.USER }, this.storage), false); - public readonly showExcludedTests = new ObservableValue(false); - public readonly reveal = new ObservableValue(undefined); + public readonly showExcludedTests = new MutableObservableValue(false); + public readonly reveal = new MutableObservableValue(undefined); public readonly onDidRequestInputFocus = this.focusEmitter.event; diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputTerminalService.ts b/src/vs/workbench/contrib/testing/browser/testingOutputTerminalService.ts new file mode 100644 index 00000000000..918280afb6f --- /dev/null +++ b/src/vs/workbench/contrib/testing/browser/testingOutputTerminalService.ts @@ -0,0 +1,159 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Emitter } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { listenStream } from 'vs/base/common/stream'; +import { localize } from 'vs/nls'; +import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; +import { IProcessDataEvent, ITerminalChildProcess, ITerminalLaunchError, TerminalShellType } from 'vs/platform/terminal/common/terminal'; +import { ITerminalInstance, ITerminalService } from 'vs/workbench/contrib/terminal/browser/terminal'; +import { ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; + + +export interface ITestingOutputTerminalService { + _serviceBrand: undefined; + + /** + * Opens a terminal for the given test's output. + */ + open(result: ITestResult): Promise; +} + +const friendlyDate = (date: number) => { + const d = new Date(date); + return d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); +}; + +const genericTitle = localize('testOutputTerminalTitle', 'Test Output'); + +type TestOutputTerminalInstance = ITerminalInstance & { shellLaunchConfig: { customPtyImplementation: TestOutputProcess } }; + +export const ITestingOutputTerminalService = createDecorator('ITestingOutputTerminalService'); + +export class TestingOutputTerminalService implements ITestingOutputTerminalService { + _serviceBrand: undefined; + + constructor(@ITerminalService private readonly terminalService: ITerminalService) { } + + /** + * @inheritdoc + */ + public async open(result: ITestResult | undefined): Promise { + const title = result + ? localize('testOutputTerminalTitleWithDate', 'Test Output at {0}', friendlyDate(result.completedAt ?? Date.now())) + : genericTitle; + + const testOutputPtys = this.terminalService.terminalInstances.filter( + (i): i is TestOutputTerminalInstance => i.shellLaunchConfig.customPtyImplementation instanceof TestOutputProcess); + + // If there's an existing terminal for the attempted reveal, show that instead. + const existing = testOutputPtys.find(i => i.shellLaunchConfig.customPtyImplementation.resultId === result?.id); + if (existing) { + this.terminalService.setActiveInstance(existing); + this.terminalService.showPanel(); + return; + } + + // Try to reuse ended terminals, otherwise make a new one + let output: TestOutputProcess; + let terminal = testOutputPtys.find(i => i.shellLaunchConfig.customPtyImplementation.ended); + if (terminal) { + output = terminal.shellLaunchConfig.customPtyImplementation; + } else { + output = new TestOutputProcess(); + terminal = this.terminalService.createTerminal({ + isFeatureTerminal: true, + customPtyImplementation: output, + name: title, + }) as TestOutputTerminalInstance; + } + + output.resetFor(result?.id, title); + this.terminalService.setActiveInstance(terminal); + this.terminalService.showPanel(); + + if (!result) { + // seems like it takes a tick for listeners to be registered + output.ended = true; + setTimeout(() => output.pushData(localize('testNoRunYet', '\r\nNo tests have been run, yet.\r\n'))); + return; + } + + listenStream(await result.getOutput(), { + onData: d => output.pushData(d.toString()), + onError: err => output.pushData(`\r\n\r\n${err.stack || err.message}`), + onEnd: () => { + const completedAt = result.completedAt ? new Date(result.completedAt) : new Date(); + const text = localize('runFinished', 'Test run finished at {0}', completedAt.toLocaleString()); + output.pushData(`\r\n\r\n\x1b[1m> ${text} <\x1b[0m\r\n`); + output.ended = true; + }, + }); + } +} + +class TestOutputProcess extends Disposable implements ITerminalChildProcess { + private processDataEmitter = this._register(new Emitter()); + private titleEmitter = this._register(new Emitter()); + + /** Whether the associated test has ended (indicating the terminal can be reused) */ + public ended = true; + /** Result currently being displayed */ + public resultId: string | undefined; + + public pushData(data: string | IProcessDataEvent) { + this.processDataEmitter.fire(data); + } + + public resetFor(resultId: string | undefined, title: string) { + this.ended = false; + this.resultId = resultId; + this.processDataEmitter.fire('\x1bc'); + this.titleEmitter.fire(title); + } + + //#region implementation + public readonly id = 0; + public readonly shouldPersist = false; + + public readonly onProcessData = this.processDataEmitter.event; + public readonly onProcessExit = this._register(new Emitter()).event; + public readonly onProcessReady = this._register(new Emitter<{ pid: number; cwd: string; }>()).event; + public readonly onProcessTitleChanged = this.titleEmitter.event; + public readonly onProcessShellTypeChanged = this._register(new Emitter()).event; + + public start(): Promise { + return Promise.resolve(undefined); + } + public shutdown(): void { + // no-op + } + public input(): void { + // not supported + } + public processBinary(): Promise { + return Promise.resolve(); + } + public resize(): void { + // no-op + } + public acknowledgeDataEvent(): void { + // no-op, flow control not currently implemented + } + + public getInitialCwd(): Promise { + return Promise.resolve(''); + } + + public getCwd(): Promise { + return Promise.resolve(''); + } + + public getLatency(): Promise { + return Promise.resolve(0); + } + //#endregion +} diff --git a/src/vs/workbench/contrib/testing/common/observableValue.ts b/src/vs/workbench/contrib/testing/common/observableValue.ts index 53c1a232a14..a736966b46d 100644 --- a/src/vs/workbench/contrib/testing/common/observableValue.ts +++ b/src/vs/workbench/contrib/testing/common/observableValue.ts @@ -3,10 +3,20 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Emitter } from 'vs/base/common/event'; +import { Emitter, Event } from 'vs/base/common/event'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; -export class ObservableValue { +export interface IObservableValue { + onDidChange: Event; + readonly value: T; +} + +export const staticObservableValue = (value: T): IObservableValue => ({ + onDidChange: Event.None, + value, +}); + +export class MutableObservableValue implements IObservableValue { private readonly changeEmitter = new Emitter(); public readonly onDidChange = this.changeEmitter.event; @@ -23,7 +33,7 @@ export class ObservableValue { } public static stored(stored: StoredValue, defaultValue: T) { - const o = new ObservableValue(stored.get(defaultValue)); + const o = new MutableObservableValue(stored.get(defaultValue)); o.onDidChange(value => stored.store(value)); return o; } diff --git a/src/vs/workbench/contrib/testing/common/testResult.ts b/src/vs/workbench/contrib/testing/common/testResult.ts index c73cede51c9..fc68b130e02 100644 --- a/src/vs/workbench/contrib/testing/common/testResult.ts +++ b/src/vs/workbench/contrib/testing/common/testResult.ts @@ -3,10 +3,11 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { newWriteableBufferStream, VSBuffer, VSBufferReadableStream, VSBufferWriteableStream } from 'vs/base/common/buffer'; import { Emitter } from 'vs/base/common/event'; import { Lazy } from 'vs/base/common/lazy'; +import { DisposableStore } from 'vs/base/common/lifecycle'; import { URI } from 'vs/base/common/uri'; -import { generateUuid } from 'vs/base/common/uuid'; import { Range } from 'vs/editor/common/core/range'; import { TestResultState } from 'vs/workbench/api/common/extHostTypes'; import { IComputedStateAccessor, refreshComputedState } from 'vs/workbench/contrib/testing/common/getComputedState'; @@ -46,6 +47,11 @@ export interface ITestResult { */ getStateById(testExtId: string): TestResultItem | undefined; + /** + * Loads the output of the result as a stream. + */ + getOutput(): Promise; + /** * Serializes the test result. Used to save and restore results * in the workspace. @@ -78,6 +84,88 @@ export const sumCounts = (counts: Iterable) => { return total; }; +/** + * Deals with output of a {@link LiveTestResult}. By default we pass-through + * data into the underlying write stream, but if a client requests to read it + * we splice in the written data and then continue streaming incoming data. + */ +export class LiveOutputController { + /** Set on close() to a promise that is resolved once closing is complete */ + private closed?: Promise; + /** Data written so far. This is available until the file closes. */ + private previouslyWritten: VSBuffer[] | undefined = []; + + private readonly dataEmitter = new Emitter(); + private readonly endEmitter = new Emitter(); + + constructor( + private readonly writer: Lazy<[VSBufferWriteableStream, Promise]>, + private readonly reader: () => Promise, + ) { } + + /** + * Appends data to the output. + */ + public append(data: VSBuffer): Promise | void { + if (this.closed) { + return this.closed; + } + + this.previouslyWritten?.push(data); + this.dataEmitter.fire(data); + + return this.writer.getValue()[0].write(data); + } + + /** + * Reads the value of the stream. + */ + public read() { + if (!this.previouslyWritten) { + return this.reader(); + } + + const stream = newWriteableBufferStream(); + for (const chunk of this.previouslyWritten) { + stream.write(chunk); + } + + const disposable = new DisposableStore(); + disposable.add(this.dataEmitter.event(d => stream.write(d))); + disposable.add(this.endEmitter.event(() => stream.end())); + stream.on('end', () => disposable.dispose()); + + return Promise.resolve(stream); + } + + /** + * Closes the output, signalling no more writes will be made. + * @returns a promise that resolves when the output is written + */ + public close(): Promise { + if (this.closed) { + return this.closed; + } + + if (!this.writer.hasValue()) { + this.closed = Promise.resolve(); + } else { + const [stream, ended] = this.writer.getValue(); + stream.end(); + this.closed = ended; + } + + this.endEmitter.fire(); + this.closed.then(() => { + this.previouslyWritten = undefined; + this.dataEmitter.dispose(); + this.endEmitter.dispose(); + }); + + return this.closed; + } +} + const itemToNode = ( item: IncrementalTestCollectionItem, @@ -172,7 +260,9 @@ export class LiveTestResult implements ITestResult { * of collections. */ public static from( + resultId: string, collections: ReadonlyArray, + output: LiveOutputController, req: RunTestsRequest, ) { const testByExtId = new Map(); @@ -189,7 +279,7 @@ export class LiveTestResult implements ITestResult { } } - return new LiveTestResult(collections, testByExtId, excludeSet, !!req.isAutoRun); + return new LiveTestResult(resultId, collections, testByExtId, excludeSet, output, !!req.isAutoRun); } private readonly completeEmitter = new Emitter(); @@ -199,11 +289,6 @@ export class LiveTestResult implements ITestResult { public readonly onChange = this.changeEmitter.event; public readonly onComplete = this.completeEmitter.event; - /** - * Unique ID for referring to this set of test results. - */ - public readonly id = generateUuid(); - /** * @inheritdoc */ @@ -255,9 +340,11 @@ export class LiveTestResult implements ITestResult { }; constructor( + public readonly id: string, private readonly collections: ReadonlyArray, private readonly testById: Map, private readonly excluded: ReadonlySet, + public readonly output: LiveOutputController, public readonly isAutoRun: boolean, ) { this.counts[TestResultState.Unset] = testById.size; @@ -315,6 +402,13 @@ export class LiveTestResult implements ITestResult { }); } + /** + * @inheritdoc + */ + public getOutput() { + return this.output.read(); + } + private fireUpdateAndRefresh(entry: TestResultItem, newState: TestResultState) { const previous = entry.state.state; if (newState === previous) { @@ -445,7 +539,11 @@ export class HydratedTestResult implements ITestResult { private readonly testById = new Map(); - constructor(private readonly serialized: ISerializedTestResults, private readonly persist = true) { + constructor( + private readonly serialized: ISerializedTestResults, + private readonly outputLoader: () => Promise, + private readonly persist = true, + ) { this.id = serialized.id; this.completedAt = serialized.completedAt; @@ -472,6 +570,13 @@ export class HydratedTestResult implements ITestResult { return this.testById.get(extTestId); } + /** + * @inheritdoc + */ + public getOutput() { + return this.outputLoader(); + } + /** * @inheritdoc */ diff --git a/src/vs/workbench/contrib/testing/common/testResultService.ts b/src/vs/workbench/contrib/testing/common/testResultService.ts index 2e0d1c147d4..8db013579e6 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -9,13 +9,15 @@ import { Emitter, Event } from 'vs/base/common/event'; import { once } from 'vs/base/common/functional'; import { Iterable } from 'vs/base/common/iterator'; import { equals } from 'vs/base/common/objects'; +import { generateUuid } from 'vs/base/common/uuid'; import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/contextkey'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { TestResultState } from 'vs/workbench/api/common/extHostTypes'; -import { TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; +import { RunTestsRequest, TestResultItem } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; import { ITestResult, LiveTestResult, TestResultItemChange, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; import { ITestResultStorage, RETAIN_MAX_RESULTS } from 'vs/workbench/contrib/testing/common/testResultStorage'; +import { IMainThreadTestCollection } from 'vs/workbench/contrib/testing/common/testService'; export type ResultChangeEvent = | { completed: LiveTestResult } @@ -45,6 +47,11 @@ export interface ITestResultService { */ clear(): void; + /** + * Creates a new, live test result. + */ + createLiveResult(collections: ReadonlyArray, req: RunTestsRequest): LiveTestResult; + /** * Adds a new test result to the collection. */ @@ -125,6 +132,14 @@ export class TestResultService implements ITestResultService { return undefined; } + /** + * @inheritdoc + */ + public createLiveResult(collections: ReadonlyArray, req: RunTestsRequest) { + const id = generateUuid(); + return this.push(LiveTestResult.from(id, collections, this.storage.getOutputController(id), req)); + } + /** * @inheritdoc */ diff --git a/src/vs/workbench/contrib/testing/common/testResultStorage.ts b/src/vs/workbench/contrib/testing/common/testResultStorage.ts index c6f1ccbe87a..411f9dab959 100644 --- a/src/vs/workbench/contrib/testing/common/testResultStorage.ts +++ b/src/vs/workbench/contrib/testing/common/testResultStorage.ts @@ -3,7 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { VSBuffer } from 'vs/base/common/buffer'; +import { bufferToStream, newWriteableBufferStream, VSBuffer, VSBufferReadableStream, VSBufferWriteableStream } from 'vs/base/common/buffer'; +import { Lazy } from 'vs/base/common/lazy'; import { isDefined } from 'vs/base/common/types'; import { URI } from 'vs/base/common/uri'; import { IEnvironmentService } from 'vs/platform/environment/common/environment'; @@ -14,11 +15,12 @@ import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storag import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; import { ISerializedTestResults } from 'vs/workbench/contrib/testing/common/testCollection'; -import { HydratedTestResult, ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; +import { HydratedTestResult, ITestResult, LiveOutputController, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; export const RETAIN_MAX_RESULTS = 128; const RETAIN_MIN_RESULTS = 16; const RETAIN_MAX_BYTES = 1024 * 128; +const CLEANUP_PROBABILITY = 0.2; export interface ITestResultStorage { _serviceBrand: undefined; @@ -32,6 +34,11 @@ export interface ITestResultStorage { * Persists the list of test results. */ persist(results: ReadonlyArray): Promise; + + /** + * Gets the output controller for a new or existing test result. + */ + getOutputController(resultId: string): LiveOutputController; } export const ITestResultStorage = createDecorator('ITestResultStorage'); @@ -39,7 +46,7 @@ export const ITestResultStorage = createDecorator('ITestResultStorage'); export abstract class BaseTestResultStorage implements ITestResultStorage { declare readonly _serviceBrand: undefined; - private readonly stored = new StoredValue>({ + protected readonly stored = new StoredValue>({ key: 'storedTestResults', scope: StorageScope.WORKSPACE, target: StorageTarget.MACHINE @@ -62,7 +69,7 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { return undefined; } - return new HydratedTestResult(contents); + return new HydratedTestResult(contents, () => this.readOutputForResultId(id)); } catch (e) { this.logService.warn(`Error deserializing stored test result ${id}`, e); return undefined; @@ -72,6 +79,29 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { return results.filter(isDefined); } + /** + * @override + */ + public getOutputController(resultId: string) { + return new LiveOutputController( + new Lazy(() => { + const stream = newWriteableBufferStream(); + const promise = this.storeOutputForResultId(resultId, stream); + return [stream, promise]; + }), + () => this.readOutputForResultId(resultId), + ); + } + + /** + * @override + */ + public getResultOutputWriter(resultId: string) { + const stream = newWriteableBufferStream(); + this.storeOutputForResultId(resultId, stream); + return stream; + } + /** * @override */ @@ -108,6 +138,10 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { todo.push(this.storeForResultId(result.id, obj)); toStore.push({ id: result.id, bytes: contents.byteLength }); budget -= contents.byteLength; + + if (result instanceof LiveTestResult && result.completedAt !== undefined) { + todo.push(result.output.close()); + } } for (const id of toDelete.keys()) { @@ -123,6 +157,11 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { */ protected abstract readForResultId(id: string): Promise; + /** + * Reads serialized results for the test. Is allowed to throw. + */ + protected abstract readOutputForResultId(id: string): Promise; + /** * Deletes serialized results for the test. */ @@ -132,6 +171,11 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { * Stores test results by ID. */ protected abstract storeForResultId(id: string, data: ISerializedTestResults): Promise; + + /** + * Reads serialized results for the test. Is allowed to throw. + */ + protected abstract storeOutputForResultId(id: string, input: VSBufferWriteableStream): Promise; } export class InMemoryResultStorage extends BaseTestResultStorage { @@ -150,6 +194,14 @@ export class InMemoryResultStorage extends BaseTestResultStorage { this.cache.delete(id); return Promise.resolve(); } + + protected readOutputForResultId(id: string): Promise { + throw new Error('Method not implemented.'); + } + + protected storeOutputForResultId(id: string, input: VSBufferWriteableStream): Promise { + throw new Error('Method not implemented.'); + } } export class TestResultStorage extends BaseTestResultStorage { @@ -179,7 +231,53 @@ export class TestResultStorage extends BaseTestResultStorage { return this.fileService.del(this.getResultJsonPath(id)).catch(() => undefined); } + protected async readOutputForResultId(id: string): Promise { + try { + const { value } = await this.fileService.readFileStream(this.getResultOutputPath(id)); + return value; + } catch { + return bufferToStream(VSBuffer.alloc(0)); + } + } + + protected async storeOutputForResultId(id: string, input: VSBufferWriteableStream) { + await this.fileService.createFile(this.getResultOutputPath(id), input); + } + + /** + * @inheritdoc + */ + public override async persist(results: ReadonlyArray) { + await super.persist(results); + if (Math.random() < CLEANUP_PROBABILITY) { + await this.cleanupDereferenced(); + } + } + + /** + * Cleans up orphaned files. For instance, output can get orphaned if it's + * written but the editor is closed before the test run is complete. + */ + private async cleanupDereferenced() { + const { children } = await this.fileService.resolve(this.directory); + if (!children) { + return; + } + + const stored = new Set(this.stored.get()?.map(({ id }) => id)); + + await Promise.all( + children + .filter(child => !stored.has(child.name.replace(/\.[a-z]+$/, ''))) + .map(child => this.fileService.del(child.resource)) + ); + } + private getResultJsonPath(id: string) { return URI.joinPath(this.directory, `${id}.json`); } + + private getResultOutputPath(id: string) { + return URI.joinPath(this.directory, `${id}.output`); + } } diff --git a/src/vs/workbench/contrib/testing/common/testService.ts b/src/vs/workbench/contrib/testing/common/testService.ts index 6a7137631c5..3e36b08101b 100644 --- a/src/vs/workbench/contrib/testing/common/testService.ts +++ b/src/vs/workbench/contrib/testing/common/testService.ts @@ -9,7 +9,7 @@ import { DisposableStore, IDisposable, IReference } from 'vs/base/common/lifecyc import { URI } from 'vs/base/common/uri'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; -import { ObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; +import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; import { AbstractIncrementalTestCollection, IncrementalTestCollectionItem, InternalTestItem, RunTestForProviderRequest, RunTestsRequest, TestIdPath, TestIdWithSrc, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import { ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; @@ -157,7 +157,7 @@ export interface ITestService { /** * Set of test IDs the user asked to exclude. */ - readonly excludeTests: ObservableValue>; + readonly excludeTests: MutableObservableValue>; /** * Sets whether a test is excluded. diff --git a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts index d9aed4e08a7..a4f609925b5 100644 --- a/src/vs/workbench/contrib/testing/common/testServiceImpl.ts +++ b/src/vs/workbench/contrib/testing/common/testServiceImpl.ts @@ -14,7 +14,7 @@ import { IContextKey, IContextKeyService } from 'vs/platform/contextkey/common/c import { INotificationService } from 'vs/platform/notification/common/notification'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { ExtHostTestingResource } from 'vs/workbench/api/common/extHost.protocol'; -import { ObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; +import { MutableObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; import { AbstractIncrementalTestCollection, getTestSubscriptionKey, IncrementalTestCollectionItem, InternalTestItem, RunTestsRequest, TestDiffOpType, TestIdWithSrc, TestsDiff } from 'vs/workbench/contrib/testing/common/testCollection'; import { TestingContextKeys } from 'vs/workbench/contrib/testing/common/testingContextKeys'; @@ -48,7 +48,7 @@ export class TestService extends Disposable implements ITestService { private readonly runningTests = new Map(); private readonly rootProviders = new Set(); - public readonly excludeTests = ObservableValue.stored(new StoredValue>({ + public readonly excludeTests = MutableObservableValue.stored(new StoredValue>({ key: 'excludedTestItems', scope: StorageScope.WORKSPACE, target: StorageTarget.USER, @@ -196,7 +196,7 @@ export class TestService extends Disposable implements ITestService { const subscriptions = [...this.testSubscriptions.values()] .filter(v => req.tests.some(t => v.collection.getNodeById(t.testId))) .map(s => this.subscribeToDiffs(s.ident.resource, s.ident.uri)); - const result = this.testResults.push(LiveTestResult.from(subscriptions.map(s => s.object), req)); + const result = this.testResults.createLiveResult(subscriptions.map(s => s.object), req); try { const tests = groupBy(req.tests, (a, b) => a.src.provider === b.src.provider ? 0 : 1); diff --git a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts index 061261b83e2..63bad953408 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts @@ -5,16 +5,23 @@ import * as assert from 'assert'; import { timeout } from 'vs/base/common/async'; +import { bufferToStream, newWriteableBufferStream, VSBuffer } from 'vs/base/common/buffer'; +import { Lazy } from 'vs/base/common/lazy'; import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; import { NullLogService } from 'vs/platform/log/common/log'; import { InternalTestItem } from 'vs/workbench/contrib/testing/common/testCollection'; -import { HydratedTestResult, LiveTestResult, makeEmptyCounts, TestResultItemChange, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; +import { HydratedTestResult, LiveOutputController, LiveTestResult, makeEmptyCounts, TestResultItemChange, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; import { TestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; import { InMemoryResultStorage, ITestResultStorage } from 'vs/workbench/contrib/testing/common/testResultStorage'; import { ReExportedTestRunState as TestRunState } from 'vs/workbench/contrib/testing/common/testStubs'; import { getInitializedMainTestCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection'; import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; +export const emptyOutputController = () => new LiveOutputController( + new Lazy(() => [newWriteableBufferStream(), Promise.resolve()]), + () => Promise.resolve(bufferToStream(VSBuffer.alloc(0))), +); + suite('Workbench - Test Results Service', () => { const getLabelsIn = (it: Iterable) => [...it].map(t => t.item.label).sort(); const getChangeSummary = () => [...changed] @@ -27,8 +34,10 @@ suite('Workbench - Test Results Service', () => { setup(async () => { changed = new Set(); r = LiveTestResult.from( + 'foo', [await getInitializedMainTestCollection()], - { tests: [{ src: { provider: 'provider', tree: 0 }, testId: 'id-a' }], debug: false } + emptyOutputController(), + { tests: [{ src: { provider: 'provider', tree: 0 }, testId: 'id-a' }], debug: false }, ); r.onChange(e => changed.add(e)); @@ -36,7 +45,7 @@ suite('Workbench - Test Results Service', () => { suite('LiveTestResult', () => { test('is empty if no tests are requesteed', async () => { - const r = LiveTestResult.from([await getInitializedMainTestCollection()], { tests: [], debug: false }); + const r = LiveTestResult.from('', [await getInitializedMainTestCollection()], emptyOutputController(), { tests: [], debug: false }); assert.deepStrictEqual(getLabelsIn(r.tests), []); }); @@ -181,7 +190,9 @@ suite('Workbench - Test Results Service', () => { r.markComplete(); const r2 = results.push(LiveTestResult.from( + '', [await getInitializedMainTestCollection()], + emptyOutputController(), { tests: [{ src: { provider: 'provider', tree: 0 }, testId: '1' }], debug: false } )); results.clear(); @@ -192,7 +203,9 @@ suite('Workbench - Test Results Service', () => { test('keeps ongoing tests on top', async () => { results.push(r); const r2 = results.push(LiveTestResult.from( + '', [await getInitializedMainTestCollection()], + emptyOutputController(), { tests: [{ src: { provider: 'provider', tree: 0 }, testId: '1' }], debug: false } )); @@ -213,7 +226,7 @@ suite('Workbench - Test Results Service', () => { retired: undefined, children: [], }] - }); + }, () => Promise.resolve(bufferToStream(VSBuffer.alloc(0)))); test('pushes hydrated results', async () => { results.push(r); diff --git a/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts b/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts index 21afa791f19..33237417545 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts @@ -10,6 +10,7 @@ import { ITestResult, LiveTestResult } from 'vs/workbench/contrib/testing/common import { InMemoryResultStorage, RETAIN_MAX_RESULTS } from 'vs/workbench/contrib/testing/common/testResultStorage'; import { MainThreadTestCollection } from 'vs/workbench/contrib/testing/common/testServiceImpl'; import { getInitializedMainTestCollection } from 'vs/workbench/contrib/testing/test/common/ownedTestCollection'; +import { emptyOutputController } from 'vs/workbench/contrib/testing/test/common/testResultService.test'; import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; suite('Workbench - Test Result Storage', () => { @@ -18,7 +19,9 @@ suite('Workbench - Test Result Storage', () => { const makeResult = (addMessage?: string) => { const t = LiveTestResult.from( + '', [collection], + emptyOutputController(), { tests: [{ src: { provider: 'provider', tree: 0 }, testId: 'id-a' }], debug: false } ); if (addMessage) { -- GitLab