diff --git a/src/vs/base/common/strings.ts b/src/vs/base/common/strings.ts index 6e1d31bd91db9..5837c2f4c4975 100644 --- a/src/vs/base/common/strings.ts +++ b/src/vs/base/common/strings.ts @@ -730,9 +730,12 @@ export function lcut(text: string, n: number) { // Escape codes, compiled from https://invisible-island.net/xterm/ctlseqs/ctlseqs.html#h3-Functions-using-CSI-_-ordered-by-the-final-character_s_ const CSI_SEQUENCE = /(:?\x1b\[|\x9B)[=?>!]?[\d;:]*["$#'* ]?[a-zA-Z@^`{}|~]/g; +// Plus additional markers for custom `\x1b]...\x07` instructions. +const CSI_CUSTOM_SEQUENCE = /\x1b\].*?\x07/g; + export function removeAnsiEscapeCodes(str: string): string { if (str) { - str = str.replace(CSI_SEQUENCE, ''); + str = str.replace(CSI_SEQUENCE, '').replace(CSI_CUSTOM_SEQUENCE, ''); } return str; diff --git a/src/vs/base/test/common/strings.test.ts b/src/vs/base/test/common/strings.test.ts index 168ff6bca5b34..f4bea3785b85e 100644 --- a/src/vs/base/test/common/strings.test.ts +++ b/src/vs/base/test/common/strings.test.ts @@ -511,6 +511,10 @@ suite('Strings', () => { `${CSI}48;5;128m`, // 256 indexed color alt `${CSI}38:2:0:255:255:255m`, // truecolor `${CSI}38;2;255;255;255m`, // truecolor alt + + // Custom sequences: + '\x1b]633;SetMark;\x07', + '\x1b]633;P;Cwd=/foo\x07', ]; for (const sequence of sequences) { diff --git a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts index e940b30270cfd..921745eea82da 100644 --- a/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts +++ b/src/vs/workbench/contrib/testing/browser/testExplorerActions.ts @@ -838,9 +838,31 @@ export class ShowMostRecentOutputAction extends Action2 { }); } - public run(accessor: ServicesAccessor) { + public async run(accessor: ServicesAccessor) { + const quickInputService = accessor.get(IQuickInputService); + const terminalOutputService = accessor.get(ITestingOutputTerminalService); const result = accessor.get(ITestResultService).results[0]; - accessor.get(ITestingOutputTerminalService).open(result); + + if (!result.tasks.length) { + return; + } + + let index = 0; + if (result.tasks.length > 1) { + const picked = await quickInputService.pick( + result.tasks.map((t, i) => ({ label: t.name || localize('testing.pickTaskUnnamed', "Run #{0}", i), index: i })), + { placeHolder: localize('testing.pickTask', "Pick a run to show output for") } + ); + + if (!picked) { + return; + } + + index = picked.index; + } + + + terminalOutputService.open(result, index); } } diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts index e4e18fbb770e1..3eec54c4a9719 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputPeek.ts @@ -77,13 +77,13 @@ import { IObservableValue, MutableObservableValue, staticObservableValue } from import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; import { ITestExplorerFilterState } from 'vs/workbench/contrib/testing/common/testExplorerFilterState'; import { ITestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService'; -import { ITestResult, TestResultItemChange, TestResultItemChangeReason, maxCountPriority, resultItemParents } from 'vs/workbench/contrib/testing/common/testResult'; +import { ITestResult, LiveTestResult, 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 { cmpPriority, 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'; @@ -115,16 +115,16 @@ class MessageSubject { } } -class ResultSubject { +class TaskSubject { public readonly outputUri: URI; public readonly revealLocation: undefined; - constructor(public readonly resultId: string) { - this.outputUri = buildTestUri({ resultId, type: TestUriType.AllOutput }); + constructor(public readonly resultId: string, public readonly taskIndex: number) { + this.outputUri = buildTestUri({ resultId, taskIndex, type: TestUriType.TaskOutput }); } } -type InspectSubject = MessageSubject | ResultSubject; +type InspectSubject = MessageSubject | TaskSubject; /** Iterates through every message in every result */ function* allMessages(results: readonly ITestResult[]) { @@ -251,7 +251,7 @@ export class TestingPeekOpener extends Disposable implements ITestingPeekOpener } const options = { pinned: false, revealIfOpened: true }; - if (current instanceof ResultSubject) { + if (current instanceof TaskSubject) { this.editorService.openEditor({ resource: current.outputUri, options }); return; } @@ -579,7 +579,7 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo let found = false; for (const { messageIndex, taskIndex, result, test } of allMessages(this.testResults.results)) { - if (subject instanceof ResultSubject && result.id === subject.resultId) { + if (subject instanceof TaskSubject && result.id === subject.resultId) { found = true; // open the first message found in the current result } @@ -609,7 +609,7 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo let previous: { messageIndex: number; taskIndex: number; result: ITestResult; test: TestResultItem } | undefined; for (const m of allMessages(this.testResults.results)) { - if (subject instanceof ResultSubject) { + if (subject instanceof TaskSubject) { if (m.result.id === subject.resultId) { break; } @@ -672,8 +672,8 @@ export class TestingOutputPeekController extends Disposable implements IEditorCo return undefined; } - if (parts.type === TestUriType.AllOutput) { - return new ResultSubject(parts.resultId); + if (parts.type === TestUriType.TaskOutput) { + return new TaskSubject(parts.resultId, parts.taskIndex); } const { resultId, testExtId, taskIndex, messageIndex } = parts; @@ -854,7 +854,7 @@ class TestResultsPeek extends PeekViewWidget { * Updates the test to be shown. */ public setModel(subject: InspectSubject): Promise { - if (subject instanceof ResultSubject) { + if (subject instanceof TaskSubject) { this.current = subject; return this.showInPlace(subject); } @@ -932,17 +932,6 @@ export class TestResultsView extends ViewPane { @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() { @@ -955,8 +944,8 @@ export class TestResultsView extends ViewPane { 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) }); + if (lastResult && lastResult.tasks.length) { + this.content.reveal({ preserveFocus: true, subject: new TaskSubject(lastResult.id, 0) }); } } @@ -1282,6 +1271,7 @@ interface ITreeElement { context: unknown; id: string; label: string; + onDidChange: Event; labelWithIcons?: readonly (HTMLSpanElement | string)[]; icon?: ThemeIcon; description?: string; @@ -1289,6 +1279,8 @@ interface ITreeElement { } class TestResultElement implements ITreeElement { + public readonly changeEmitter = new Emitter(); + public readonly onDidChange = this.changeEmitter.event; public readonly type = 'result'; public readonly context = this.value.id; public readonly id = this.value.id; @@ -1306,20 +1298,33 @@ class TestResultElement implements ITreeElement { } class TestCaseElement implements ITreeElement { + public readonly changeEmitter = new Emitter(); + public readonly onDidChange = this.changeEmitter.event; public readonly type = 'test'; public readonly context = this.test.item.extId; public readonly id = `${this.results.id}/${this.test.item.extId}`; - public readonly label = this.test.item.label; - public readonly labelWithIcons = renderLabelWithIcons(this.label); public readonly description?: string; + public get state() { + return this.test.tasks[this.taskIndex].state; + } + + public get label() { + return this.test.item.label; + } + + public get labelWithIcons() { + return renderLabelWithIcons(this.label); + } + public get icon() { - return icons.testingStatesToIcons.get(this.test.computedState); + return icons.testingStatesToIcons.get(this.state); } constructor( private readonly results: ITestResult, public readonly test: TestResultItem, + public readonly taskIndex: number, ) { for (const parent of resultItemParents(results, test)) { if (parent !== test) { @@ -1331,16 +1336,21 @@ class TestCaseElement implements ITreeElement { } } -class TestTaskElement implements ITreeElement { +class TaskElement implements ITreeElement { + public readonly changeEmitter = new Emitter(); + public readonly onDidChange = this.changeEmitter.event; public readonly type = 'task'; - public readonly task: ITestRunTask; public readonly context: string; public readonly id: string; public readonly label: string; - public readonly icon = undefined; + public readonly itemsCache = new CreationCache(); - constructor(results: ITestResult, public readonly test: TestResultItem, index: number) { - this.id = `${results.id}/${test.item.extId}/${index}`; + public get icon() { + return this.results.tasks[this.index].running ? icons.testingStatesToIcons.get(TestResultState.Running) : undefined; + } + + constructor(public readonly results: ITestResult, public readonly task: ITestRunTask, public readonly index: number) { + this.id = `${results.id}/${index}`; this.task = results.tasks[index]; this.context = String(index); this.label = this.task.name ?? localize('testUnnamedTask', 'Unnamed Task'); @@ -1356,6 +1366,7 @@ class TestMessageElement implements ITreeElement { public readonly location?: IRichLocation; public readonly description?: string; public readonly marker?: number; + public readonly onDidChange = Event.None; constructor( public readonly result: ITestResult, @@ -1388,7 +1399,7 @@ class TestMessageElement implements ITreeElement { } } -type TreeElement = TestResultElement | TestCaseElement | TestMessageElement | TestTaskElement; +type TreeElement = TestResultElement | TestCaseElement | TestMessageElement | TaskElement; class OutputPeekTree extends Disposable { private disposed = false; @@ -1429,6 +1440,15 @@ class OutputPeekTree extends Disposable { compressionEnabled: true, hideTwistiesOfChildlessElements: true, identityProvider: diffIdentityProvider, + sorter: { + compare(a, b) { + if (a instanceof TestCaseElement && b instanceof TestCaseElement) { + return cmpPriority(a.state, b.state); + } + + return 0; + }, + }, accessibilityProvider: { getAriaLabel(element: ITreeElement) { return element.ariaLabel || element.label; @@ -1440,45 +1460,37 @@ class OutputPeekTree extends Disposable { }, )) as WorkbenchCompressibleObjectTree; - const creationCache = new WeakMap(); - const cachedCreate = (ref: object, factory: () => T): TreeElement => { - const existing = creationCache.get(ref); - if (existing) { - return existing; - } + const cc = new CreationCache(); + const getTaskChildren = (taskElem: TaskElement): Iterable> => { + const tests = Iterable.filter(taskElem.results.tests, test => test.tasks[taskElem.index].state >= TestResultState.Running || test.tasks[taskElem.index].messages.length > 0); - const fresh = factory(); - creationCache.set(ref, fresh); - return fresh; - }; - - const getTaskChildren = (result: ITestResult, test: TestResultItem, taskId: number): Iterable> => { - return Iterable.map(test.tasks[0].messages, (m, messageIndex) => ({ - element: cachedCreate(m, () => new TestMessageElement(result, test, taskId, messageIndex)), + return Iterable.map(tests, test => ({ + element: taskElem.itemsCache.getOrCreate(test, () => new TestCaseElement(taskElem.results, test, taskElem.index)), incompressible: true, + children: getTestChildren(taskElem.results, test, taskElem.index), })); }; - const getTestChildren = (result: ITestResult, test: TestResultItem): Iterable> => { - const tasks = Iterable.filter(test.tasks, task => task.messages.length > 0); - return Iterable.map(tasks, (t, taskId) => ({ - element: cachedCreate(t, () => new TestTaskElement(result, test, taskId)), - incompressible: false, - children: getTaskChildren(result, test, taskId), + const getTestChildren = (result: ITestResult, test: TestResultItem, taskIndex: number): Iterable> => { + return Iterable.map(test.tasks[taskIndex].messages, (m, messageIndex) => ({ + element: cc.getOrCreate(m, () => new TestMessageElement(result, test, taskIndex, messageIndex)), + incompressible: true, })); }; const getResultChildren = (result: ITestResult): Iterable> => { - const tests = Iterable.filter(result.tests, test => test.tasks.some(t => t.messages.length > 0)); - return Iterable.map(tests, test => ({ - element: cachedCreate(test, () => new TestCaseElement(result, test)), - incompressible: true, - children: getTestChildren(result, test), - })); + return result.tasks.map((task, taskIndex) => { + const taskElem = cc.getOrCreate(task, () => new TaskElement(result, task, taskIndex)); + return ({ + element: taskElem, + incompressible: false, + children: getTaskChildren(taskElem), + }); + }); }; const getRootChildren = () => results.results.map(result => { - const element = cachedCreate(result, () => new TestResultElement(result)); + const element = cc.getOrCreate(result, () => new TestResultElement(result)); return { element, incompressible: true, @@ -1489,36 +1501,57 @@ class OutputPeekTree extends Disposable { // Queued result updates to prevent spamming CPU when lots of tests are // completing and messaging quickly (#142514) - const resultsToUpdate = new Set(); - const resultUpdateScheduler = this._register(new RunOnceScheduler(() => { - for (const result of resultsToUpdate) { - const resultNode = creationCache.get(result); - if (resultNode && this.tree.hasElement(resultNode)) { - this.tree.setChildren(resultNode, getResultChildren(result), { diffIdentityProvider }); + const taskChildrenToUpdate = new Set(); + const taskChildrenUpdate = this._register(new RunOnceScheduler(() => { + for (const taskNode of taskChildrenToUpdate) { + if (this.tree.hasElement(taskNode)) { + this.tree.setChildren(taskNode, getTaskChildren(taskNode), { diffIdentityProvider }); } } - resultsToUpdate.clear(); + taskChildrenToUpdate.clear(); }, 300)); - this._register(results.onTestChanged(e => { - const itemNode = creationCache.get(e.item); - if (itemNode && this.tree.hasElement(itemNode)) { // update to existing test message/state - this.tree.setChildren(itemNode, getTestChildren(e.result, e.item)); - return; - } + const handleNewResults = (result: LiveTestResult) => { + const resultNode = cc.get(result)! as TestResultElement; + const disposable = new DisposableStore(); + disposable.add(result.onNewTask(() => { + if (this.tree.hasElement(resultNode)) { + this.tree.setChildren(resultNode, getResultChildren(result), { diffIdentityProvider }); + } + })); + disposable.add(result.onEndTask(index => { + (cc.get(result.tasks[index]) as TaskElement | undefined)?.changeEmitter.fire(); + })); + + disposable.add(result.onChange(e => { + // try updating the item in each of its tasks + for (const [index, task] of result.tasks.entries()) { + const taskNode = cc.get(task) as TaskElement; + if (!this.tree.hasElement(taskNode)) { + continue; + } - const resultNode = creationCache.get(e.result); - if (resultNode && this.tree.hasElement(resultNode)) { // new test, update result children - if (!resultUpdateScheduler.isScheduled) { - resultsToUpdate.add(e.result); - resultUpdateScheduler.schedule(); + const itemNode = taskNode.itemsCache.get(e.item); + if (itemNode && this.tree.hasElement(itemNode)) { + this.tree.setChildren(itemNode, getTestChildren(result, e.item, index), { diffIdentityProvider }); + itemNode.changeEmitter.fire(); + return; + } + + taskChildrenToUpdate.add(taskNode); + if (!taskChildrenUpdate.isScheduled()) { + taskChildrenUpdate.schedule(); + } } - return; - } + })); - // should be unreachable? - this.tree.setChildren(null, getRootChildren(), { diffIdentityProvider }); - })); + disposable.add(result.onComplete(() => { + resultNode.changeEmitter.fire(); + disposable.dispose(); + })); + + this.tree.expand(resultNode, true); + }; this._register(results.onResultsChanged(e => { // little hack here: a result change can cause the peek to be disposed, @@ -1529,14 +1562,20 @@ class OutputPeekTree extends Disposable { } if ('completed' in e) { - const resultNode = creationCache.get(e.completed); - if (resultNode && this.tree.hasElement(resultNode)) { - this.tree.setChildren(resultNode, getResultChildren(e.completed)); - return; - } + (cc.get(e.completed) as TestResultElement | undefined)?.changeEmitter.fire(); + return; } this.tree.setChildren(null, getRootChildren(), { diffIdentityProvider }); + + // done after setChildren intentionally so that the ResultElement exists in the cache. + if ('started' in e) { + for (const child of this.tree.getNode(null).children) { + this.tree.collapse(child.element, false); + } + + handleNewResults(e.started); + } })); const revealItem = (element: TreeElement, preserveFocus: boolean) => { @@ -1548,7 +1587,7 @@ class OutputPeekTree extends Disposable { }; this._register(onDidReveal(({ subject, preserveFocus = false }) => { - if (subject instanceof ResultSubject) { + if (subject instanceof TaskSubject) { const resultItem = this.tree.getNode(null).children.find(c => (c.element as TestResultElement)?.id === subject.resultId); if (resultItem) { revealItem(resultItem.element as TestResultElement, preserveFocus); @@ -1556,7 +1595,7 @@ class OutputPeekTree extends Disposable { return; } - const messageNode = creationCache.get(subject.messages[subject.messageIndex]); + const messageNode = cc.get(subject.messages[subject.messageIndex]); if (!messageNode || !this.tree.hasElement(messageNode)) { return; } @@ -1578,8 +1617,8 @@ class OutputPeekTree extends Disposable { })); this._register(this.tree.onDidOpen(async e => { - if (e.element instanceof TestResultElement) { - this.requestReveal.fire(new ResultSubject(e.element.id)); + if (e.element instanceof TaskElement) { + this.requestReveal.fire(new TaskSubject(e.element.results.id, e.element.index)); } else if (e.element instanceof TestMessageElement) { this.requestReveal.fire(new MessageSubject(e.element.result.id, e.element.test, e.element.taskIndex, e.element.messageIndex)); } @@ -1646,7 +1685,7 @@ class TestRunElementRenderer implements ICompressibleTreeRenderer, FuzzyScore>, _index: number, templateData: TemplateData): void { const chain = node.element.elements; const lastElement = chain[chain.length - 1]; - if (lastElement instanceof TestTaskElement && chain.length >= 2) { + if (lastElement instanceof TaskElement && chain.length >= 2) { this.doRender(chain[chain.length - 2], templateData); } else { this.doRender(lastElement, templateData); @@ -1680,6 +1719,8 @@ class TestRunElementRenderer implements ICompressibleTreeRenderer, _index: number, templateData: TemplateData): void { + templateData.elementDisposable.clear(); + templateData.elementDisposable.add(element.element.onDidChange(() => this.doRender(element.element, templateData))); this.doRender(element.element, templateData); } @@ -1689,7 +1730,6 @@ class TestRunElementRenderer implements ICompressibleTreeRenderer this.testTerminalService.open(element.value) + () => this.testTerminalService.open(element.results, element.index) )); + } + + if (element instanceof TestResultElement) { + // only show if there are no collapsed test nodes that have more specific choices + if (element.value.tasks.length === 1) { + primary.push(new Action( + 'testing.outputPeek.showResultOutput', + localize('testing.showResultOutput', "Show Result Output"), + ThemeIcon.asClassName(Codicon.terminal), + undefined, + () => this.testTerminalService.open(element.value, 0) + )); + } primary.push(new Action( 'testing.outputPeek.reRunLastRun', @@ -1761,7 +1814,7 @@ class TreeActionsProvider { } } - if (element instanceof TestCaseElement || element instanceof TestTaskElement) { + if (element instanceof TestCaseElement) { const extId = element.test.item.extId; primary.push(new Action( 'testing.outputPeek.goToFile', @@ -1822,7 +1875,7 @@ class TreeActionsProvider { localize('testing.showMessageInTerminal', "Show Output in Terminal"), ThemeIcon.asClassName(Codicon.terminal), undefined, - () => this.testTerminalService.open(element.result, element.marker), + () => this.testTerminalService.open(element.result, element.taskIndex, element.marker), )); } } @@ -1984,3 +2037,22 @@ export class ToggleTestingPeekHistory extends Action2 { opener.historyVisible.value = !opener.historyVisible.value; } } + +class CreationCache { + private readonly v = new WeakMap(); + + public get(key: object) { + return this.v.get(key); + } + + public getOrCreate(ref: object, factory: () => T2): T2 { + const existing = this.v.get(ref); + if (existing) { + return existing as T2; + } + + const fresh = factory(); + this.v.set(ref, fresh); + return fresh; + } +} diff --git a/src/vs/workbench/contrib/testing/browser/testingOutputTerminalService.ts b/src/vs/workbench/contrib/testing/browser/testingOutputTerminalService.ts index 4639fbc0db963..66434b228ebbb 100644 --- a/src/vs/workbench/contrib/testing/browser/testingOutputTerminalService.ts +++ b/src/vs/workbench/contrib/testing/browser/testingOutputTerminalService.ts @@ -5,9 +5,8 @@ import { DeferredPromise } from 'vs/base/common/async'; import { Emitter, Event } from 'vs/base/common/event'; -import { Disposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; import { language } from 'vs/base/common/platform'; -import { listenStream } from 'vs/base/common/stream'; import { isDefined } from 'vs/base/common/types'; import { localize } from 'vs/nls'; import { createDecorator } from 'vs/platform/instantiation/common/instantiation'; @@ -28,7 +27,7 @@ export interface ITestingOutputTerminalService { * Opens a terminal for the given test's output. Optionally, scrolls to and * selects the given marker in the test results. */ - open(result: ITestResult, marker?: number): Promise; + open(result: ITestResult, taskIndex: number, marker?: number): Promise; } const friendlyDate = (date: number) => { @@ -36,10 +35,17 @@ const friendlyDate = (date: number) => { return d.getHours() + ':' + String(d.getMinutes()).padStart(2, '0') + ':' + String(d.getSeconds()).padStart(2, '0'); }; -const getTitle = (result: ITestResult | undefined) => { - return result - ? localize('testOutputTerminalTitleWithDate', 'Test Output at {0}', friendlyDate(result.completedAt ?? Date.now())) - : genericTitle; +const getTitle = (result: ITestResult | undefined, taskIndex: number | undefined) => { + if (!result || taskIndex === undefined) { + return genericTitle; + } + + const task = result.tasks[taskIndex]; + if (result.tasks.length < 2 || !task?.name) { + return localize('testOutputTerminalTitleWithDate', 'Test Output at {0}', friendlyDate(result.completedAt ?? Date.now())); + } + + return localize('testOutputTerminalTitleWithDateAndTaskName', '{0} at {1}', task.name, friendlyDate(result.completedAt ?? Date.now())); }; const genericTitle = localize('testOutputTerminalTitle', 'Test Output'); @@ -58,30 +64,39 @@ export class TestingOutputTerminalService implements ITestingOutputTerminalServi @ITestResultService resultService: ITestResultService, @IViewsService private viewsService: IViewsService, ) { + + const newTaskListener = new MutableDisposable(); + // If a result terminal is currently active and we start a new test run, // stream live results there automatically. resultService.onResultsChanged(evt => { - const active = this.terminalService.activeInstance; - if (!('started' in evt) || !active) { + if (!('started' in evt)) { return; } - const pane = this.viewsService.getActiveViewWithId(TERMINAL_VIEW_ID); - if (!pane) { - return; - } + newTaskListener.value = evt.started.onNewTask(taskIndex => { + const active = this.terminalService.activeInstance; + if (!active) { + return; + } - const output = this.outputTerminals.get(active); - if (output && output.ended) { - this.showResultsInTerminal(active, output, evt.started); - } + const pane = this.viewsService.getActiveViewWithId(TERMINAL_VIEW_ID); + if (!pane) { + return; + } + + const output = this.outputTerminals.get(active); + if (output && output.ended) { + this.showResultsInTerminal(active, output, evt.started, taskIndex); + } + }); }); } /** * @inheritdoc */ - public async open(result: ITestResult | undefined, marker?: number): Promise { + public async open(result: ITestResult | undefined, taskIndex: number | undefined, marker?: number): Promise { const testOutputPtys = this.terminalService.instances .map(t => { const output = this.outputTerminals.get(t); @@ -90,7 +105,7 @@ export class TestingOutputTerminalService implements ITestingOutputTerminalServi .filter(isDefined); // If there's an existing terminal for the attempted reveal, show that instead. - const existing = testOutputPtys.find(([, o]) => o.resultId === result?.id); + const existing = testOutputPtys.find(([, o]) => o.resultId === result?.id && o.taskIndex === taskIndex); if (existing) { this.terminalService.setActiveInstance(existing[0]); if (existing[0].target === TerminalLocation.Editor) { @@ -107,7 +122,7 @@ export class TestingOutputTerminalService implements ITestingOutputTerminalServi const ended = testOutputPtys.find(([, o]) => o.ended); if (ended) { ended[1].clear(); - this.showResultsInTerminal(ended[0], ended[1], result); + this.showResultsInTerminal(ended[0], ended[1], result, taskIndex); return; } @@ -117,14 +132,17 @@ export class TestingOutputTerminalService implements ITestingOutputTerminalServi isFeatureTerminal: true, icon: testingViewIcon, customPtyImplementation: () => output, - name: getTitle(result), + name: getTitle(result, taskIndex), }, - }), output, result, marker); + }), output, result, taskIndex, marker); } - private async showResultsInTerminal(terminal: ITerminalInstance, output: TestOutputProcess, result: ITestResult | undefined, thenSelectMarker?: number) { + private async showResultsInTerminal(terminal: ITerminalInstance, output: TestOutputProcess, result: ITestResult | undefined, taskIndex: number | undefined, thenSelectMarker?: number) { this.outputTerminals.set(terminal, output); - output.resetFor(result?.id, getTitle(result)); + const title = getTitle(result, taskIndex); + output.resetFor(result?.id, taskIndex, title); + terminal.rename(title); + this.terminalService.setActiveInstance(terminal); if (terminal.target === TerminalLocation.Editor) { this.terminalEditorService.revealActiveEditor(); @@ -132,33 +150,49 @@ export class TestingOutputTerminalService implements ITestingOutputTerminalServi this.terminalGroupService.showPanel(); } - if (!result) { + await output.started; + + if (!result || taskIndex === undefined) { // 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; } - const [stream] = await Promise.all([result.getOutput(), output.started]); + + const testOutput = result.tasks[taskIndex].output; + let hadData = false; - listenStream(stream, { - onData: d => { - hadData = true; - output.pushData(d.toString()); - }, - onError: err => output.pushData(`\r\n\r\n${err.stack || err.message}`), - onEnd: () => { - if (!hadData) { - output.pushData(`\x1b[2m${localize('runNoOutout', 'The test run did not record any output.')}\x1b[0m`); - } + for (const d of testOutput.buffers) { + hadData = true; + output.pushData(d.toString()); + } - const completedAt = result.completedAt ? new Date(result.completedAt) : new Date(); - const text = localize('runFinished', 'Test run finished at {0}', completedAt.toLocaleString(language)); - output.pushData(`\r\n\r\n\x1b[1m> ${text} <\x1b[0m\r\n\r\n`); - output.ended = true; - this.revealMarker(terminal, thenSelectMarker); - }, + const disposable = new DisposableStore(); + disposable.add(testOutput.onDidWriteData(d => { + hadData = true; + output.pushData(d.toString()); + })); + + testOutput.endPromise.then(() => { + if (disposable.isDisposed) { + return; + } + if (!hadData) { + output.pushData(`\x1b[2m${localize('runNoOutout', 'The test run did not record any output.')}\x1b[0m`); + } + + const completedAt = result.completedAt ? new Date(result.completedAt) : new Date(); + const text = localize('runFinished', 'Test run finished at {0}', completedAt.toLocaleString(language)); + output.pushData(`\r\n\r\n\x1b[1m> ${text} <\x1b[0m\r\n\r\n`); + output.ended = true; + this.revealMarker(terminal, thenSelectMarker); + disposable.dispose(); }); + + disposable.add(terminal.onDisposed(() => { + disposable.dispose(); + })); } private revealMarker(terminal: ITerminalInstance, marker?: number) { @@ -174,12 +208,13 @@ class TestOutputProcess extends Disposable implements ITerminalChildProcess { onDidChangeHasChildProcesses?: Event | undefined; onDidChangeProperty = Event.None; private processDataEmitter = this._register(new Emitter()); - private titleEmitter = this._register(new Emitter()); private readonly startedDeferred = new DeferredPromise(); /** Whether the associated test has ended (indicating the terminal can be reused) */ public ended = true; /** Result currently being displayed */ public resultId: string | undefined; + /** Task currently being displayed */ + public taskIndex: number | undefined; /** Promise resolved when the terminal is ready to take data */ public readonly started = this.startedDeferred.p; @@ -191,10 +226,10 @@ class TestOutputProcess extends Disposable implements ITerminalChildProcess { this.processDataEmitter.fire('\x1bc'); } - public resetFor(resultId: string | undefined, title: string) { + public resetFor(resultId: string | undefined, taskIndex: number | undefined, title: string) { this.ended = false; this.resultId = resultId; - this.titleEmitter.fire(title); + this.taskIndex = taskIndex; } //#region implementation @@ -205,7 +240,6 @@ class TestOutputProcess extends Disposable implements ITerminalChildProcess { public readonly onProcessExit = this._register(new Emitter()).event; private readonly _onProcessReady = this._register(new Emitter<{ pid: number; cwd: string }>()); public readonly onProcessReady = this._onProcessReady.event; - public readonly onProcessTitleChanged = this.titleEmitter.event; public readonly onProcessShellTypeChanged = this._register(new Emitter()).event; public start(): Promise { diff --git a/src/vs/workbench/contrib/testing/common/testResult.ts b/src/vs/workbench/contrib/testing/common/testResult.ts index 95282f36f8084..b21f49df31f70 100644 --- a/src/vs/workbench/contrib/testing/common/testResult.ts +++ b/src/vs/workbench/contrib/testing/common/testResult.ts @@ -3,19 +3,19 @@ * 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 { DeferredPromise } from 'vs/base/common/async'; +import { VSBuffer } from 'vs/base/common/buffer'; +import { Emitter, Event } from 'vs/base/common/event'; import { Lazy } from 'vs/base/common/lazy'; -import { DisposableStore } from 'vs/base/common/lifecycle'; +import { language } from 'vs/base/common/platform'; +import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; import { localize } from 'vs/nls'; import { IComputedStateAccessor, refreshComputedState } from 'vs/workbench/contrib/testing/common/getComputedState'; import { IObservableValue, MutableObservableValue, staticObservableValue } from 'vs/workbench/contrib/testing/common/observableValue'; -import { getMarkId, IRichLocation, ISerializedTestResults, ITestItem, ITestMessage, ITestOutputMessage, ITestRunTask, ITestTaskState, ResolvedTestRunRequest, TestItemExpandState, TestMessageType, TestResultItem, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes'; import { TestCoverage } from 'vs/workbench/contrib/testing/common/testCoverage'; -import { maxPriority, statesInOrder, terminalStatePriorities } from 'vs/workbench/contrib/testing/common/testingStates'; -import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; import { TestId } from 'vs/workbench/contrib/testing/common/testId'; -import { language } from 'vs/base/common/platform'; +import { maxPriority, statesInOrder, terminalStatePriorities } from 'vs/workbench/contrib/testing/common/testingStates'; +import { getMarkId, IRichLocation, ISerializedTestResults, ITestItem, ITestMessage, ITestOutputMessage, ITestRunTask, ITestTaskState, ResolvedTestRunRequest, TestItemExpandState, TestMessageType, TestResultItem, TestResultState } from 'vs/workbench/contrib/testing/common/testTypes'; export interface ITestRunTaskResults extends ITestRunTask { /** @@ -27,6 +27,11 @@ export interface ITestRunTaskResults extends ITestRunTask { * Messages from the task not associated with any specific test. */ readonly otherMessages: ITestOutputMessage[]; + + /** + * Test results output for the task. + */ + readonly output: ITaskRawOutput; } export interface ITestResult { @@ -71,16 +76,6 @@ export interface ITestResult { */ getStateById(testExtId: string): TestResultItem | undefined; - /** - * Loads the output of the result as a stream. - */ - getOutput(): Promise; - - /** - * Loads an output of the result. - */ - getOutputRange(offset: number, length: number): Promise; - /** * Serializes the test result. Used to save and restore results * in the workspace. @@ -93,75 +88,77 @@ export interface ITestResult { toJSONWithMessages(): ISerializedTestResults | undefined; } -export const resultItemParents = function* (results: ITestResult, item: TestResultItem) { - for (const id of TestId.fromString(item.item.extId).idsToRoot()) { - yield results.getStateById(id.toString())!; - } -}; - /** - * Count of the number of tests in each run state. + * Output type exposed from live test results. */ -export type TestStateCount = { [K in TestResultState]: number }; +export interface ITaskRawOutput { + readonly onDidWriteData: Event; + readonly endPromise: Promise; + readonly buffers: VSBuffer[]; + readonly length: number; -export const makeEmptyCounts = () => { - const o: Partial = {}; - for (const state of statesInOrder) { - o[state] = 0; - } + getRange(start: number, end: number): VSBuffer; +} - return o as TestStateCount; +const emptyRawOutput: ITaskRawOutput = { + buffers: [], + length: 0, + onDidWriteData: Event.None, + endPromise: Promise.resolve(), + getRange: () => VSBuffer.alloc(0), }; -export const maxCountPriority = (counts: Readonly) => { - for (const state of statesInOrder) { - if (counts[state] > 0) { - return state; - } +export class TaskRawOutput implements ITaskRawOutput { + private readonly writeDataEmitter = new Emitter(); + private readonly endDeferred = new DeferredPromise(); + private offset = 0; + + /** @inheritdoc */ + public readonly onDidWriteData = this.writeDataEmitter.event; + + /** @inheritdoc */ + public readonly endPromise = this.endDeferred.p; + + /** @inheritdoc */ + public readonly buffers: VSBuffer[] = []; + + /** @inheritdoc */ + public get length() { + return this.offset; } - return TestResultState.Unset; -}; + /** @inheritdoc */ + getRange(start: number, length: number): VSBuffer { + const buf = VSBuffer.alloc(length); -const getMarkCode = (marker: number, start: boolean) => `\x1b]633;SetMark;Id=${getMarkId(marker, start)};Hidden\x07`; + let bufLastWrite = 0; + let internalLastRead = 0; + for (const b of this.buffers) { + if (internalLastRead + b.byteLength <= start) { + internalLastRead += b.byteLength; + continue; + } -/** - * 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 = []; + const bstart = Math.max(0, start - internalLastRead); + const bend = Math.min(b.byteLength, bstart + length - bufLastWrite); - private readonly dataEmitter = new Emitter(); - private readonly endEmitter = new Emitter(); - private _offset = 0; + buf.buffer.set(b.buffer.subarray(bstart, bend), bufLastWrite); + bufLastWrite += bend - bstart; + internalLastRead += b.byteLength; - /** - * Gets the number of written bytes. - */ - public get offset() { - return this._offset; - } + if (bufLastWrite === buf.byteLength) { + break; + } + } - constructor( - private readonly writer: Lazy<[VSBufferWriteableStream, Promise]>, - private readonly reader: () => Promise, - private readonly rangeReader: (offset: number, length: number) => Promise, - ) { } + return bufLastWrite < length ? buf.slice(0, bufLastWrite) : buf; + } /** - * Appends data to the output. + * Appends data to the output, returning the byte index where data starts. */ - public append(data: VSBuffer, marker?: number): { offset: number; done: Promise | void } { - if (this.closed) { - return { offset: this._offset, done: this.closed }; - } - - let startOffset = this._offset; + public append(data: VSBuffer, marker?: number): number { + let startOffset = this.offset; if (marker !== undefined) { const start = VSBuffer.fromString(getMarkCode(marker, true)); const end = VSBuffer.fromString(getMarkCode(marker, false)); @@ -169,89 +166,50 @@ export class LiveOutputController { data = VSBuffer.concat([start, data, end]); } - this.previouslyWritten?.push(data); - this.dataEmitter.fire(data); - this._offset += data.byteLength; + this.buffers.push(data); + this.writeDataEmitter.fire(data); + this.offset += data.byteLength; - return { offset: startOffset, done: this.writer.value[0].write(data) }; + return startOffset; } - /** - * Reads a range of data from the output. - */ - public getRange(offset: number, length: number) { - if (!this.previouslyWritten) { - return this.rangeReader(offset, length); - } - - const buffer = VSBuffer.alloc(length); - let pos = 0; - for (const chunk of this.previouslyWritten) { - if (pos + chunk.byteLength < offset) { - // no-op - } else if (pos > offset + length) { - break; - } else { - const cs = Math.max(0, offset - pos); - const bs = Math.max(0, pos - offset); - buffer.set(chunk.slice(cs, cs + Math.min(length - bs, chunk.byteLength - cs)), bs); - } - - pos += chunk.byteLength; - } - - const trailing = (offset + length) - pos; - return Promise.resolve(trailing > 0 ? buffer.slice(0, -trailing) : buffer); + /** Signals the output has ended. */ + public end() { + this.endDeferred.complete(); } +} - /** - * 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); - } +export const resultItemParents = function* (results: ITestResult, item: TestResultItem) { + for (const id of TestId.fromString(item.item.extId).idsToRoot()) { + yield results.getStateById(id.toString())!; + } +}; - 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()); +/** + * Count of the number of tests in each run state. + */ +export type TestStateCount = { [K in TestResultState]: number }; - return Promise.resolve(stream); +export const makeEmptyCounts = () => { + const o: Partial = {}; + for (const state of statesInOrder) { + o[state] = 0; } - /** - * 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; - } + return o as TestStateCount; +}; - if (!this.writer.hasValue) { - this.closed = Promise.resolve(); - } else { - const [stream, ended] = this.writer.value; - stream.end(); - this.closed = ended; +export const maxCountPriority = (counts: Readonly) => { + for (const state of statesInOrder) { + if (counts[state] > 0) { + return state; } + } - this.endEmitter.fire(); - this.closed.then(() => { - this.previouslyWritten = undefined; - this.dataEmitter.dispose(); - this.endEmitter.dispose(); - }); + return TestResultState.Unset; +}; - return this.closed; - } -} +const getMarkCode = (marker: number, start: boolean) => `\x1b]633;SetMark;Id=${getMarkId(marker, start)};Hidden\x07`; interface TestResultItemWithChildren extends TestResultItem { /** Children in the run */ @@ -284,6 +242,8 @@ export type TestResultItemChange = { item: TestResultItem; result: ITestResult } */ export class LiveTestResult implements ITestResult { private readonly completeEmitter = new Emitter(); + private readonly newTaskEmitter = new Emitter(); + private readonly endTaskEmitter = new Emitter(); private readonly changeEmitter = new Emitter(); private readonly testById = new Map(); private testMarkerCounter = 0; @@ -291,7 +251,9 @@ export class LiveTestResult implements ITestResult { public readonly onChange = this.changeEmitter.event; public readonly onComplete = this.completeEmitter.event; - public readonly tasks: ITestRunTaskResults[] = []; + public readonly onNewTask = this.newTaskEmitter.event; + public readonly onEndTask = this.endTaskEmitter.event; + public readonly tasks: (ITestRunTaskResults & { output: TaskRawOutput })[] = []; public readonly name = localize('runFinished', 'Test run at {0}', new Date().toLocaleString(language)); /** @@ -333,7 +295,6 @@ export class LiveTestResult implements ITestResult { constructor( public readonly id: string, - public readonly output: LiveOutputController, public readonly persist: boolean, public readonly request: ResolvedTestRunRequest, ) { @@ -359,7 +320,10 @@ export class LiveTestResult implements ITestResult { marker = this.testMarkerCounter++; } - const { offset } = this.output.append(output, marker); + const index = this.mustGetTaskIndex(taskId); + const task = this.tasks[index]; + + const offset = task.output.append(output, marker); const message: ITestOutputMessage = { location, message: removeAnsiEscapeCodes(preview), @@ -369,12 +333,10 @@ export class LiveTestResult implements ITestResult { type: TestMessageType.Output, }; - - const index = this.mustGetTaskIndex(taskId); if (testId) { this.testById.get(testId)?.tasks[index].messages.push(message); } else { - this.tasks[index].otherMessages.push(message); + task.otherMessages.push(message); } } @@ -382,11 +344,13 @@ export class LiveTestResult implements ITestResult { * Adds a new run task to the results. */ public addTask(task: ITestRunTask) { - this.tasks.push({ ...task, coverage: new MutableObservableValue(undefined), otherMessages: [] }); + this.tasks.push({ ...task, coverage: new MutableObservableValue(undefined), otherMessages: [], output: new TaskRawOutput() }); for (const test of this.tests) { test.tasks.push({ duration: undefined, messages: [], state: TestResultState.Unset }); } + + this.newTaskEmitter.fire(this.tasks.length - 1); } /** @@ -453,30 +417,22 @@ export class LiveTestResult implements ITestResult { }); } - /** - * @inheritdoc - */ - public getOutput() { - return this.output.read(); - } - - /** - * @inheritdoc - */ - public getOutputRange(offset: number, bytes: number) { - return this.output.getRange(offset, bytes); - } - /** * Marks the task in the test run complete. */ public markTaskComplete(taskId: string) { - this.tasks[this.mustGetTaskIndex(taskId)].running = false; + const index = this.mustGetTaskIndex(taskId); + const task = this.tasks[index]; + task.running = false; + task.output.end(); + this.setAllToState( TestResultState.Unset, taskId, t => t.state === TestResultState.Queued || t.state === TestResultState.Running, ); + + this.endTaskEmitter.fire(index); } /** @@ -648,8 +604,6 @@ export class HydratedTestResult implements ITestResult { constructor( private readonly serialized: ISerializedTestResults, - private readonly outputLoader: () => Promise, - private readonly outputRangeLoader: (offset: number, length: number) => Promise, private readonly persist = true, ) { this.id = serialized.id; @@ -659,6 +613,7 @@ export class HydratedTestResult implements ITestResult { name: task.name, running: false, coverage: staticObservableValue(undefined), + output: emptyRawOutput, otherMessages: [] })); this.name = serialized.name; @@ -671,13 +626,6 @@ export class HydratedTestResult implements ITestResult { } } - /** - * @inheritdoc - */ - public getOutputRange(offset: number, bytes: number) { - return this.outputRangeLoader(offset, bytes); - } - /** * @inheritdoc */ @@ -685,13 +633,6 @@ 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 0df9e450a3ff6..ecd01614b4c1c 100644 --- a/src/vs/workbench/contrib/testing/common/testResultService.ts +++ b/src/vs/workbench/contrib/testing/common/testResultService.ts @@ -133,7 +133,7 @@ export class TestResultService implements ITestResultService { public createLiveResult(req: ResolvedTestRunRequest | ExtensionRunTestsRequest) { if ('targets' in req) { const id = generateUuid(); - return this.push(new LiveTestResult(id, this.storage.getOutputController(id), true, req)); + return this.push(new LiveTestResult(id, true, req)); } let profile: ITestRunProfile | undefined; @@ -158,7 +158,7 @@ export class TestResultService implements ITestResultService { }); } - return this.push(new LiveTestResult(req.id, this.storage.getOutputController(req.id), req.persist, resolved)); + return this.push(new LiveTestResult(req.id, req.persist, resolved)); } /** diff --git a/src/vs/workbench/contrib/testing/common/testResultStorage.ts b/src/vs/workbench/contrib/testing/common/testResultStorage.ts index db6b0e82c0117..2b2e027b65b29 100644 --- a/src/vs/workbench/contrib/testing/common/testResultStorage.ts +++ b/src/vs/workbench/contrib/testing/common/testResultStorage.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ 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,8 +13,8 @@ import { ILogService } from 'vs/platform/log/common/log'; import { IStorageService, StorageScope, StorageTarget } from 'vs/platform/storage/common/storage'; import { IWorkspaceContextService } from 'vs/platform/workspace/common/workspace'; import { StoredValue } from 'vs/workbench/contrib/testing/common/storedValue'; +import { HydratedTestResult, ITestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { ISerializedTestResults } from 'vs/workbench/contrib/testing/common/testTypes'; -import { HydratedTestResult, ITestResult, LiveOutputController, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; export const RETAIN_MAX_RESULTS = 128; const RETAIN_MIN_RESULTS = 16; @@ -34,11 +33,6 @@ 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'); @@ -80,7 +74,7 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { return undefined; } - return new HydratedTestResult(contents, () => this.readOutputForResultId(id), (o, l) => this.readOutputRangeForResultId(id, o, l)); + return new HydratedTestResult(contents); } catch (e) { this.logService.warn(`Error deserializing stored test result ${id}`, e); return undefined; @@ -90,21 +84,6 @@ 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), - (o, l) => this.readOutputRangeForResultId(resultId, o, l) - ); - } - /** * @override */ @@ -150,10 +129,6 @@ export abstract class BaseTestResultStorage implements ITestResultStorage { todo.push(this.storeForResultId(result.id, obj)); toStore.push({ id: result.id, rev: currentRevision, bytes: contents.byteLength }); budget -= contents.byteLength; - - if (result instanceof LiveTestResult && result.completedAt !== undefined) { - todo.push(result.output.close()); - } } for (const id of toDelete.keys()) { diff --git a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts index 7c78d8db22e44..bcd992edfc1d4 100644 --- a/src/vs/workbench/contrib/testing/common/testingContentProvider.ts +++ b/src/vs/workbench/contrib/testing/common/testingContentProvider.ts @@ -3,6 +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 { DisposableStore } from 'vs/base/common/lifecycle'; import { removeAnsiEscapeCodes } from 'vs/base/common/strings'; import { URI } from 'vs/base/common/uri'; import { ILanguageSelection, ILanguageService } from 'vs/editor/common/languages/language'; @@ -48,25 +50,34 @@ export class TestingContentProvider implements IWorkbenchContribution, ITextMode return null; } - if (parsed.type === TestUriType.AllOutput) { - const stream = await result.getOutput(); + if (parsed.type === TestUriType.TaskOutput) { + const task = result.tasks[parsed.taskIndex]; 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', () => { + const init = VSBuffer.concat(task.output.buffers, task.output.length).toString(); + append(removeAnsiEscapeCodes(init)); + + let hadContent = init.length > 0; + const dispose = new DisposableStore(); + dispose.add(task.output.onDidWriteData(d => { + hadContent ||= d.byteLength > 0; + append(removeAnsiEscapeCodes(d.toString())); + })); + task.output.endPromise.then(() => { + if (dispose.isDisposed) { + return; + } if (!hadContent) { append(localize('runNoOutout', 'The test run did not record any output.')); + dispose.dispose(); } }); - model.onWillDispose(() => stream.destroy()); + model.onWillDispose(() => dispose.dispose()); + return model; } @@ -95,7 +106,7 @@ export class TestingContentProvider implements IWorkbenchContribution, ITextMode } if (message.type === TestMessageType.Output) { - const content = await result!.getOutputRange(message.offset, message.length); + const content = result.tasks[parsed.taskIndex].output.getRange(message.offset, message.length); text = removeAnsiEscapeCodes(content.toString()); } else if (typeof message.message === 'string') { text = message.message; diff --git a/src/vs/workbench/contrib/testing/common/testingUri.ts b/src/vs/workbench/contrib/testing/common/testingUri.ts index f2a2556649972..1e3b99eb21f59 100644 --- a/src/vs/workbench/contrib/testing/common/testingUri.ts +++ b/src/vs/workbench/contrib/testing/common/testingUri.ts @@ -9,15 +9,16 @@ import { URI } from 'vs/base/common/uri'; export const TEST_DATA_SCHEME = 'vscode-test-data'; export const enum TestUriType { - AllOutput, + TaskOutput, ResultMessage, ResultActualOutput, ResultExpectedOutput, } interface IAllOutputReference { - type: TestUriType.AllOutput; + type: TestUriType.TaskOutput; resultId: string; + taskIndex: number; } interface IResultTestUri { @@ -73,18 +74,18 @@ export const parseTestUri = (uri: URI): ParsedTestUri | undefined => { } if (request[0] === TestUriParts.AllOutput) { - return { resultId: locationId, type: TestUriType.AllOutput }; + return { resultId: locationId, taskIndex: Number(request[1]), type: TestUriType.TaskOutput }; } return undefined; }; export const buildTestUri = (parsed: ParsedTestUri): URI => { - if (parsed.type === TestUriType.AllOutput) { + if (parsed.type === TestUriType.TaskOutput) { return URI.from({ scheme: TEST_DATA_SCHEME, authority: TestUriParts.Results, - path: ['', parsed.resultId, TestUriParts.AllOutput].join('/'), + path: ['', parsed.resultId, TestUriParts.AllOutput, parsed.taskIndex].join('/'), }); } 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 4d53c4ddd790a..befed0d417b5f 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultService.test.ts @@ -5,28 +5,17 @@ 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 { VSBuffer } from 'vs/base/common/buffer'; import { MockContextKeyService } from 'vs/platform/keybinding/test/common/mockKeybindingService'; import { NullLogService } from 'vs/platform/log/common/log'; -import { ITestTaskState, ResolvedTestRunRequest, TestResultItem, TestResultState, TestRunProfileBitset } from 'vs/workbench/contrib/testing/common/testTypes'; import { TestId } from 'vs/workbench/contrib/testing/common/testId'; import { TestProfileService } from 'vs/workbench/contrib/testing/common/testProfileService'; -import { HydratedTestResult, LiveOutputController, LiveTestResult, makeEmptyCounts, resultItemParents, TestResultItemChange, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; +import { HydratedTestResult, LiveTestResult, makeEmptyCounts, resultItemParents, TaskRawOutput, TestResultItemChange, TestResultItemChangeReason } from 'vs/workbench/contrib/testing/common/testResult'; import { TestResultService } from 'vs/workbench/contrib/testing/common/testResultService'; -import { InMemoryResultStorage, ITestResultStorage, TestResultStorage } from 'vs/workbench/contrib/testing/common/testResultStorage'; +import { InMemoryResultStorage, ITestResultStorage } from 'vs/workbench/contrib/testing/common/testResultStorage'; +import { ITestTaskState, ResolvedTestRunRequest, TestResultItem, TestResultState, TestRunProfileBitset } from 'vs/workbench/contrib/testing/common/testTypes'; import { getInitializedMainTestCollection, testStubs, TestTestCollection } from 'vs/workbench/contrib/testing/test/common/testStubs'; import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; -import { InMemoryFileSystemProvider } from 'vs/platform/files/common/inMemoryFilesystemProvider'; -import { FileService } from 'vs/platform/files/common/fileService'; -import { DisposableStore } from 'vs/base/common/lifecycle'; -import { URI } from 'vs/base/common/uri'; - -export const emptyOutputController = () => new LiveOutputController( - new Lazy(() => [newWriteableBufferStream(), Promise.resolve()]), - () => Promise.resolve(bufferToStream(VSBuffer.alloc(0))), - () => Promise.resolve(VSBuffer.alloc(0)), -); suite('Workbench - Test Results Service', () => { const getLabelsIn = (it: Iterable) => [...it].map(t => t.item.label).sort(); @@ -57,7 +46,6 @@ suite('Workbench - Test Results Service', () => { changed = new Set(); r = new TestLiveTestResult( 'foo', - emptyOutputController(), true, defaultOpts(['id-a']), ); @@ -92,7 +80,6 @@ suite('Workbench - Test Results Service', () => { test('is empty if no tests are yet present', async () => { assert.deepStrictEqual(getLabelsIn(new TestLiveTestResult( 'foo', - emptyOutputController(), false, defaultOpts(['id-a']), ).tests), []); @@ -254,7 +241,6 @@ suite('Workbench - Test Results Service', () => { const r2 = results.push(new LiveTestResult( '', - emptyOutputController(), false, defaultOpts([]), )); @@ -267,7 +253,6 @@ suite('Workbench - Test Results Service', () => { results.push(r); const r2 = results.push(new LiveTestResult( '', - emptyOutputController(), false, defaultOpts([]), )); @@ -291,7 +276,7 @@ suite('Workbench - Test Results Service', () => { computedState: state, ownComputedState: state, }] - }, () => Promise.resolve(bufferToStream(VSBuffer.alloc(0))), () => Promise.resolve(VSBuffer.alloc(0))); + }); test('pushes hydrated results', async () => { results.push(r); @@ -330,64 +315,29 @@ suite('Workbench - Test Results Service', () => { }); suite('output controller', () => { - - const disposables = new DisposableStore(); - const ROOT = URI.file('tests').with({ scheme: 'vscode-tests' }); - let storage: TestResultStorage; - - setup(() => { - const logService = new NullLogService(); - const fileService = disposables.add(new FileService(logService)); - const fileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); - disposables.add(fileService.registerProvider(ROOT.scheme, fileSystemProvider)); - - storage = new TestResultStorage( - new TestStorageService(), - new NullLogService(), - { getWorkspace: () => ({ id: 'test' }) } as any, - fileService, - { workspaceStorageHome: ROOT } as any - ); - }); - - teardown(() => disposables.clear()); - test('reads live output ranges', async () => { - const ctrl = storage.getOutputController('a'); + const ctrl = new TaskRawOutput(); ctrl.append(VSBuffer.fromString('12345')); ctrl.append(VSBuffer.fromString('67890')); ctrl.append(VSBuffer.fromString('12345')); ctrl.append(VSBuffer.fromString('67890')); - assert.deepStrictEqual(await ctrl.getRange(0, 5), VSBuffer.fromString('12345')); - assert.deepStrictEqual(await ctrl.getRange(5, 5), VSBuffer.fromString('67890')); - assert.deepStrictEqual(await ctrl.getRange(7, 6), VSBuffer.fromString('890123')); - assert.deepStrictEqual(await ctrl.getRange(15, 5), VSBuffer.fromString('67890')); - assert.deepStrictEqual(await ctrl.getRange(15, 10), VSBuffer.fromString('67890')); + assert.deepStrictEqual(ctrl.getRange(0, 5), VSBuffer.fromString('12345')); + assert.deepStrictEqual(ctrl.getRange(5, 5), VSBuffer.fromString('67890')); + assert.deepStrictEqual(ctrl.getRange(7, 6), VSBuffer.fromString('890123')); + assert.deepStrictEqual(ctrl.getRange(15, 5), VSBuffer.fromString('67890')); + assert.deepStrictEqual(ctrl.getRange(15, 10), VSBuffer.fromString('67890')); }); test('corrects offsets for marked ranges', async () => { - const ctrl = storage.getOutputController('a'); + const ctrl = new TaskRawOutput(); const a1 = ctrl.append(VSBuffer.fromString('12345'), 1); const a2 = ctrl.append(VSBuffer.fromString('67890'), 1234); - assert.deepStrictEqual(await ctrl.getRange(a1.offset, 5), VSBuffer.fromString('12345')); - assert.deepStrictEqual(await ctrl.getRange(a2.offset, 5), VSBuffer.fromString('67890')); - }); - - test('reads stored output ranges', async () => { - const ctrl = storage.getOutputController('a'); - - ctrl.append(VSBuffer.fromString('12345')); - ctrl.append(VSBuffer.fromString('67890')); - ctrl.append(VSBuffer.fromString('12345')); - ctrl.append(VSBuffer.fromString('67890')); - await ctrl.close(); - - // sanity: - assert.deepStrictEqual(await ctrl.getRange(0, 5), VSBuffer.fromString('12345')); + assert.deepStrictEqual(ctrl.getRange(a1, 5), VSBuffer.fromString('12345')); + assert.deepStrictEqual(ctrl.getRange(a2, 5), VSBuffer.fromString('67890')); }); }); }); 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 115095f4eef2f..a46e0e630a68b 100644 --- a/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts +++ b/src/vs/workbench/contrib/testing/test/common/testResultStorage.test.ts @@ -8,7 +8,6 @@ import { range } from 'vs/base/common/arrays'; import { NullLogService } from 'vs/platform/log/common/log'; import { ITestResult, LiveTestResult } from 'vs/workbench/contrib/testing/common/testResult'; import { InMemoryResultStorage, RETAIN_MAX_RESULTS } from 'vs/workbench/contrib/testing/common/testResultStorage'; -import { emptyOutputController } from 'vs/workbench/contrib/testing/test/common/testResultService.test'; import { testStubs } from 'vs/workbench/contrib/testing/test/common/testStubs'; import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices'; @@ -18,7 +17,6 @@ suite('Workbench - Test Result Storage', () => { const makeResult = (taskName = 't') => { const t = new LiveTestResult( '', - emptyOutputController(), true, { targets: [] } );