// ***************************************************************************** // Copyright (C) 2023 STMicroelectronics and others. // // This program and the accompanying materials are made available under the // terms of the Eclipse Public License v. 2.0 which is available at // http://www.eclipse.org/legal/epl-2.0. // // This Source Code may also be made available under the following Secondary // Licenses when the conditions for such availability set forth in the Eclipse // Public License v. 2.0 are satisfied: GNU General Public License, version 2 // with the GNU Classpath Exception which is available at // https://www.gnu.org/software/classpath/license.html. // // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 // ***************************************************************************** import { injectable, inject, postConstruct } from '@theia/core/shared/inversify'; import { TreeWidget, TreeModel, TreeProps, CompositeTreeNode, TreeNode, TreeImpl, NodeProps, SelectableTreeNode } from '@theia/core/lib/browser/tree'; import { ContextMenuRenderer, codicon } from '@theia/core/lib/browser'; import { IconThemeService } from '@theia/core/lib/browser/icon-theme-service'; import { ThemeService } from '@theia/core/lib/browser/theming'; import { ContextKeyService } from '@theia/core/lib/browser/context-key-service'; import { TestController, TestExecutionState, TestFailure, TestItem, TestMessage, TestOutputItem, TestRun, TestService } from '../test-service'; import * as React from '@theia/core/shared/react'; import { Disposable, DisposableCollection, Event, nls } from '@theia/core'; import { TestExecutionStateManager } from './test-execution-state-manager'; import { TestOutputUIModel } from './test-output-ui-model'; class TestRunNode implements TreeNode, SelectableTreeNode { constructor(readonly counter: number, readonly id: string, readonly run: TestRun, readonly parent: CompositeTreeNode) { } get name(): string { return this.run.name || nls.localize('theia/test/testRunDefaultName', '{0} run {1}', this.run.controller.label, this.counter); }; expanded?: boolean; selected: boolean = false; children: TestItemNode[] = []; } class TestItemNode implements TreeNode, SelectableTreeNode { constructor(readonly id: string, readonly item: TestItem, readonly parent: TestRunNode) { } selected: boolean = false; get name(): string { return this.item.label; } } interface RunInfo { node: TestRunNode; disposable: Disposable; tests: Map; } @injectable() export class TestRunTree extends TreeImpl { private ROOT: CompositeTreeNode = { id: 'TestResults', name: 'Test Results', parent: undefined, children: [], visible: false }; @inject(TestService) protected readonly testService: TestService; private controllerListeners = new Map(); private runs = new Map(); private nextId = 0; @postConstruct() init(): void { this.root = this.ROOT; this.testService.getControllers().forEach(controller => { this.addController(controller); }); this.testService.onControllersChanged(controllerDelta => { controllerDelta.removed?.forEach(controller => { this.controllerListeners.get(controller)?.dispose(); }); controllerDelta.added?.forEach(controller => this.addController(controller)); }); } private addController(controller: TestController): void { controller.testRuns.forEach(run => this.addRun(run)); const listeners = new DisposableCollection(); this.controllerListeners.set(controller.id, listeners); listeners.push(controller.onRunsChanged(runDelta => { runDelta.removed?.forEach(run => { this.runs.get(run)?.disposable.dispose(); this.runs.delete(run); this.refresh(this.ROOT); }); runDelta.added?.forEach(run => { this.addRun(run); this.refresh(this.ROOT); }); })); } private addRun(run: TestRun): void { const newNode = this.createRunNode(run); const affected: TestItemNode[] = []; const disposables = new DisposableCollection(); disposables.push(run.onDidChangeTestState(deltas => { let needsRefresh = false; deltas.forEach(delta => { if (delta.newState) { if (delta.newState.state > TestExecutionState.Queued) { const testNode = info.tests.get(delta.test); if (!testNode) { if (info.tests.size === 0) { newNode.expanded = true; } info.tests.set(delta.test, this.createTestItemNode(newNode, delta.test)); needsRefresh = true; } else { affected.push(testNode); } } } else { info.tests.delete(delta.test); needsRefresh = true; } }); if (needsRefresh) { this.refresh(newNode); } else { this.onDidUpdateEmitter.fire(affected); } })); disposables.push(run.onDidChangeProperty(() => this.onDidUpdateEmitter.fire([]))); const info = { node: newNode, disposable: disposables, tests: new Map(run.items.filter(item => (run.getTestState(item)?.state || 0) > TestExecutionState.Queued).map(item => [item, this.createTestItemNode(newNode, item)])) }; this.runs.set(run, info); } protected createRunNode(run: TestRun): TestRunNode { return new TestRunNode(this.nextId, `id-${this.nextId++}`, run, this.ROOT); } createTestItemNode(parent: TestRunNode, item: TestItem): TestItemNode { return new TestItemNode(`testitem-${this.nextId++}`, item, parent); } protected override async resolveChildren(parent: CompositeTreeNode): Promise { if (parent === this.ROOT) { return Promise.resolve([...this.runs.values()].reverse().map(info => info.node)); } else if (parent instanceof TestRunNode) { const runInfo = this.runs.get(parent.run); if (runInfo) { return Promise.resolve([...runInfo.tests.values()]); } else { return Promise.resolve([]); } } else { return Promise.resolve([]); } } } @injectable() export class TestRunTreeWidget extends TreeWidget { static ID = 'test-run-widget'; @inject(IconThemeService) protected readonly iconThemeService: IconThemeService; @inject(ContextKeyService) protected readonly contextKeys: ContextKeyService; @inject(ThemeService) protected readonly themeService: ThemeService; @inject(TestExecutionStateManager) protected readonly stateManager: TestExecutionStateManager; @inject(TestOutputUIModel) protected readonly uiModel: TestOutputUIModel; protected readonly selectedItemStateListener = new DisposableCollection(); constructor( @inject(TreeProps) props: TreeProps, @inject(TreeModel) model: TreeModel, @inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer, ) { super(props, model, contextMenuRenderer); this.id = TestRunTreeWidget.ID; this.title.label = nls.localize('theia/test/testRuns', 'Test Runs'); this.title.caption = nls.localize('theia/test/testRuns', 'Test Runs'); this.title.iconClass = codicon('run'); this.title.closable = true; } @postConstruct() protected override init(): void { super.init(); this.addClass('theia-test-run-view'); this.toDispose.push(this.selectedItemStateListener); this.toDispose.push(this.model.onSelectionChanged(() => { this.selectedItemStateListener.dispose(); const node = this.model.selectedNodes[0]; if (node instanceof TestRunNode) { this.uiModel.selectedOutputSource = { get output(): readonly TestOutputItem[] { return node.run.getOutput(); }, onDidAddTestOutput: Event.map(node.run.onDidChangeTestOutput, evt => evt.map(item => item[1])) }; } else if (node instanceof TestItemNode) { const run = node.parent.run; const item = node.item; this.uiModel.selectedOutputSource = { get output(): readonly TestOutputItem[] { const itemOutput = run.getOutput(item); if (itemOutput.length > 0) { return itemOutput; } const currentState = run.getTestState(item); if (TestFailure.is(currentState)) { return currentState.messages.map(msg => ({ output: typeof msg.message === 'string' ? msg.message : msg.message.value })); } return []; }, onDidAddTestOutput: Event.map(run.onDidChangeTestOutput, evt => evt.filter(output => output[0] === item).map(output => output[1]) ), noOutputMessage: nls.localizeByDefault('The test case did not report any output.') }; this.uiModel.selectedTestState = run.getTestState(item); // Keep selectedTestState (and its derived context key) in sync while this item stays selected. this.selectedItemStateListener.push(run.onDidChangeTestState(deltas => { const update = deltas.find(delta => delta.test === item); if (update) { this.uiModel.selectedTestState = update.newState; } })); } })); } protected override renderTree(model: TreeModel): React.ReactNode { if (CompositeTreeNode.is(this.model.root) && this.model.root.children.length > 0) { return super.renderTree(model); } return
{nls.localizeByDefault('No tests have been found in this workspace yet.')}
; } protected getTestStateClass(state: TestExecutionState | undefined): string { switch (state) { case TestExecutionState.Queued: return `${codicon('history')} queued`; case TestExecutionState.Running: return `${codicon('sync')} codicon-modifier-spin running`; case TestExecutionState.Skipped: return `${codicon('debug-step-over')} skipped`; case TestExecutionState.Failed: return `${codicon('error')} failed`; case TestExecutionState.Errored: return `${codicon('issues')} errored`; case TestExecutionState.Passed: return `${codicon('pass')} passed`; default: return codicon('circle'); } } protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode { if (node instanceof TestItemNode) { const state = node.parent.run.getTestState(node.item)?.state; return
; } else if (node instanceof TestRunNode) { const icon = node.run.isRunning ? `${codicon('sync')} codicon-modifier-spin running` : codicon('circle'); return
; } else { return super.renderIcon(node, props); } } protected override toContextMenuArgs(node: SelectableTreeNode): (TestRun | TestItem | TestMessage[])[] { if (node instanceof TestRunNode) { return [node.run]; } else if (node instanceof TestItemNode) { const item = node.item; const executionState = node.parent.run.getTestState(node.item); if (TestFailure.is(executionState)) { return [item, executionState.messages]; } return [item]; } return []; } override storeState(): object { return {}; // don't store any state for now } }