deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/test/.eslintrc.js
Normal file
10
packages/test/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
32
packages/test/README.md
Normal file
32
packages/test/README.md
Normal file
@@ -0,0 +1,32 @@
|
||||
<div align='center'>
|
||||
|
||||
<br />
|
||||
|
||||
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
|
||||
|
||||
<h2>ECLIPSE THEIA - TEST EXTENSION</h2>
|
||||
|
||||
<hr />
|
||||
|
||||
</div>
|
||||
|
||||
## Description
|
||||
|
||||
The `@theia/test` extension adds the Test view for executing tests in the Theia application.
|
||||
|
||||
## Additional Information
|
||||
|
||||
- [API documentation for `@theia/test`](https://eclipse-theia.github.io/theia/docs/next/modules/_theia_test.html)
|
||||
- [Theia - GitHub](https://github.com/eclipse-theia/theia)
|
||||
- [Theia - Website](https://theia-ide.org/)
|
||||
- [VS Code Timeline Documentation](https://code.visualstudio.com/updates/v1_44#_timeline-view)
|
||||
|
||||
## License
|
||||
|
||||
- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
|
||||
- [一 (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
|
||||
|
||||
## Trademark
|
||||
|
||||
"Theia" is a trademark of the Eclipse Foundation
|
||||
<https://www.eclipse.org/theia>
|
||||
54
packages/test/package.json
Normal file
54
packages/test/package.json
Normal file
@@ -0,0 +1,54 @@
|
||||
{
|
||||
"name": "@theia/test",
|
||||
"version": "1.68.0",
|
||||
"description": "Theia - Test Extension",
|
||||
"dependencies": {
|
||||
"@theia/core": "1.68.0",
|
||||
"@theia/editor": "1.68.0",
|
||||
"@theia/filesystem": "1.68.0",
|
||||
"@theia/navigator": "1.68.0",
|
||||
"@theia/terminal": "1.68.0",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"theiaExtensions": [
|
||||
{
|
||||
"frontend": "lib/browser/view/test-view-frontend-module",
|
||||
"backend": "lib/node/test-backend-module"
|
||||
}
|
||||
],
|
||||
"keywords": [
|
||||
"theia-extension"
|
||||
],
|
||||
"license": "EPL-2.0 OR GPL-2.0 WITH Classpath-exception-2.0",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/eclipse-theia/theia.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/eclipse-theia/theia/issues"
|
||||
},
|
||||
"homepage": "https://github.com/eclipse-theia/theia",
|
||||
"files": [
|
||||
"lib",
|
||||
"src"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "theiaext build",
|
||||
"clean": "theiaext clean",
|
||||
"compile": "theiaext compile",
|
||||
"lint": "theiaext lint",
|
||||
"test": "theiaext test",
|
||||
"watch": "theiaext watch"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@theia/ext-scripts": "1.68.0"
|
||||
},
|
||||
"nyc": {
|
||||
"extends": "../../configs/nyc.json"
|
||||
},
|
||||
"gitHead": "21358137e41342742707f660b8e222f940a27652"
|
||||
}
|
||||
71
packages/test/src/browser/constants.ts
Normal file
71
packages/test/src/browser/constants.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2023 Mathieu Bussieres 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
|
||||
// *****************************************************************************
|
||||
|
||||
/*---------------------------------------------------------------------------------------------
|
||||
* Copyright (c) Microsoft Corporation and others. All rights reserved.
|
||||
* Licensed under the MIT License. See https://github.com/Microsoft/vscode/blob/master/LICENSE.txt for license information.
|
||||
*--------------------------------------------------------------------------------------------*/
|
||||
|
||||
// Based on https://github.com/microsoft/vscode/blob/1.72.2/src/vs/workbench/contrib/testing/common/constants.ts
|
||||
|
||||
/* eslint-disable import/no-extraneous-dependencies */
|
||||
export const enum Testing {
|
||||
// marked as "extension" so that any existing test extensions are assigned to it.
|
||||
ViewletId = 'workbench.view.extension.test',
|
||||
ExplorerViewId = 'workbench.view.testing',
|
||||
OutputPeekContributionId = 'editor.contrib.testingOutputPeek',
|
||||
DecorationsContributionId = 'editor.contrib.testingDecorations',
|
||||
}
|
||||
|
||||
export const enum TestCommandId {
|
||||
CancelTestRefreshAction = 'testing.cancelTestRefresh',
|
||||
CancelTestRunAction = 'testing.cancelRun',
|
||||
ClearTestResultsAction = 'testing.clearTestResults',
|
||||
CollapseAllAction = 'testing.collapseAll',
|
||||
ConfigureTestProfilesAction = 'testing.configureProfile',
|
||||
DebugAction = 'testing.debug',
|
||||
DebugAllAction = 'testing.debugAll',
|
||||
DebugAtCursor = 'testing.debugAtCursor',
|
||||
DebugCurrentFile = 'testing.debugCurrentFile',
|
||||
DebugFailedTests = 'testing.debugFailTests',
|
||||
DebugLastRun = 'testing.debugLastRun',
|
||||
DebugSelectedAction = 'testing.debugSelected',
|
||||
FilterAction = 'workbench.actions.treeView.testExplorer.filter',
|
||||
GoToTest = 'testing.editFocusedTest',
|
||||
HideTestAction = 'testing.hideTest',
|
||||
OpenOutputPeek = 'testing.openOutputPeek',
|
||||
RefreshTestsAction = 'testing.refreshTests',
|
||||
ReRunFailedTests = 'testing.reRunFailTests',
|
||||
ReRunLastRun = 'testing.reRunLastRun',
|
||||
RunAction = 'testing.run',
|
||||
RunAllAction = 'testing.runAll',
|
||||
RunAtCursor = 'testing.runAtCursor',
|
||||
RunCurrentFile = 'testing.runCurrentFile',
|
||||
RunSelectedAction = 'testing.runSelected',
|
||||
RunUsingProfileAction = 'testing.runUsing',
|
||||
SearchForTestExtension = 'testing.searchForTestExtension',
|
||||
SelectDefaultTestProfiles = 'testing.selectDefaultTestProfiles',
|
||||
ShowMostRecentOutputAction = 'testing.showMostRecentOutput',
|
||||
TestingSortByDurationAction = 'testing.sortByDuration',
|
||||
TestingSortByLocationAction = 'testing.sortByLocation',
|
||||
TestingSortByStatusAction = 'testing.sortByStatus',
|
||||
TestingViewAsListAction = 'testing.viewAsList',
|
||||
TestingViewAsTreeAction = 'testing.viewAsTree',
|
||||
ToggleAutoRun = 'testing.toggleautoRun',
|
||||
ToggleInlineTestOutput = 'testing.toggleInlineTestOutput',
|
||||
UnhideTestAction = 'testing.unhideTest',
|
||||
UnhideAllTestsAction = 'testing.unhideAllTests',
|
||||
}
|
||||
46
packages/test/src/browser/style/index.css
Normal file
46
packages/test/src/browser/style/index.css
Normal file
@@ -0,0 +1,46 @@
|
||||
/******************************************************************************
|
||||
* 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
|
||||
********************************************************************************/
|
||||
|
||||
.theia-test-view {
|
||||
height: 100%
|
||||
}
|
||||
|
||||
.theia-test-view .passed,
|
||||
.theia-test-run-view .passed {
|
||||
color: var(--theia-successBackground);
|
||||
}
|
||||
|
||||
.theia-test-view .failed,
|
||||
.theia-test-run-view .failed {
|
||||
color: var(--theia-editorError-foreground);
|
||||
}
|
||||
|
||||
.theia-test-view .errored,
|
||||
.theia-test-run-view .errored {
|
||||
color: var(--theia-editorError-foreground);
|
||||
}
|
||||
|
||||
.theia-test-view .queued,
|
||||
.theia-test-run-view .queued {
|
||||
color: var(--theia-editorWarning-foreground);
|
||||
}
|
||||
|
||||
.theia-test-result-view .debug-frame {
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.theia-test-view .theia-TreeNode:not(:hover):not(.theia-mod-selected) .theia-test-tree-inline-action {
|
||||
display: none;
|
||||
}
|
||||
53
packages/test/src/browser/test-execution-progress-service.ts
Normal file
53
packages/test/src/browser/test-execution-progress-service.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 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 { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { Widget } from '@theia/core/lib/browser';
|
||||
import { TestResultViewContribution } from './view/test-result-view-contribution';
|
||||
import { TestViewContribution } from './view/test-view-contribution';
|
||||
import { TestPreferences } from '../common/test-preferences';
|
||||
|
||||
export interface TestExecutionProgressService {
|
||||
onTestRunRequested(preserveFocus: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
export const TestExecutionProgressService = Symbol('TestExecutionProgressService');
|
||||
|
||||
@injectable()
|
||||
export class DefaultTestExecutionProgressService implements TestExecutionProgressService {
|
||||
|
||||
@inject(TestResultViewContribution)
|
||||
protected readonly testResultView: TestResultViewContribution;
|
||||
|
||||
@inject(TestViewContribution)
|
||||
protected readonly testView: TestViewContribution;
|
||||
|
||||
@inject(TestPreferences)
|
||||
protected readonly testPreferences: TestPreferences;
|
||||
|
||||
async onTestRunRequested(preserveFocus: boolean): Promise<void> {
|
||||
if (!preserveFocus) {
|
||||
const openTesting = this.testPreferences['testing.openTesting'];
|
||||
if (openTesting === 'openOnTestStart') {
|
||||
this.openTestResultView();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async openTestResultView(): Promise<Widget> {
|
||||
return this.testResultView.openView({ activate: true });
|
||||
}
|
||||
}
|
||||
409
packages/test/src/browser/test-service.ts
Normal file
409
packages/test/src/browser/test-service.ts
Normal file
@@ -0,0 +1,409 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2022 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 { CancellationToken, ContributionProvider, Disposable, Emitter, Event, QuickPickService, isObject, nls } from '@theia/core/lib/common';
|
||||
import { CancellationTokenSource, Location, Range, Position, DocumentUri } from '@theia/core/shared/vscode-languageserver-protocol';
|
||||
import { CollectionDelta, TreeDelta } from '../common/tree-delta';
|
||||
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
|
||||
import URI from '@theia/core/lib/common/uri';
|
||||
import { inject, injectable, named, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { groupBy } from '../common/collections';
|
||||
import { codiconArray } from '@theia/core/lib/browser';
|
||||
|
||||
export enum TestRunProfileKind {
|
||||
Run = 1,
|
||||
Debug = 2,
|
||||
Coverage = 3
|
||||
}
|
||||
|
||||
export interface TestRunProfile {
|
||||
readonly kind: TestRunProfileKind;
|
||||
readonly label: string,
|
||||
isDefault: boolean;
|
||||
readonly canConfigure: boolean;
|
||||
readonly tag: string;
|
||||
run(name: string, included: readonly TestItem[], excluded: readonly TestItem[], preserveFocus: boolean): void;
|
||||
configure(): void;
|
||||
}
|
||||
|
||||
export interface TestOutputItem {
|
||||
readonly output: string;
|
||||
readonly location?: Location;
|
||||
}
|
||||
|
||||
export enum TestExecutionState {
|
||||
Queued = 1,
|
||||
Running = 2,
|
||||
Passed = 3,
|
||||
Failed = 4,
|
||||
Skipped = 5,
|
||||
Errored = 6
|
||||
}
|
||||
|
||||
export interface TestMessage {
|
||||
readonly expected?: string;
|
||||
readonly actual?: string;
|
||||
readonly location?: Location;
|
||||
readonly message: string | MarkdownString;
|
||||
readonly contextValue?: string;
|
||||
readonly stackTrace?: TestMessageStackFrame[];
|
||||
}
|
||||
|
||||
export interface TestMessageStackFrame {
|
||||
readonly label: string,
|
||||
readonly uri?: DocumentUri,
|
||||
readonly position?: Position,
|
||||
}
|
||||
|
||||
export namespace TestMessage {
|
||||
export function is(obj: unknown): obj is TestMessage {
|
||||
return isObject<TestMessage>(obj) && (MarkdownString.is(obj.message) || typeof obj.message === 'string');
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestState {
|
||||
readonly state: TestExecutionState;
|
||||
}
|
||||
|
||||
export interface TestFailure extends TestState {
|
||||
readonly state: TestExecutionState.Failed | TestExecutionState.Errored;
|
||||
readonly messages: TestMessage[];
|
||||
readonly duration?: number;
|
||||
}
|
||||
|
||||
export namespace TestFailure {
|
||||
export function is(obj: unknown): obj is TestFailure {
|
||||
return isObject<TestFailure>(obj) && (obj.state === TestExecutionState.Failed || obj.state === TestExecutionState.Errored) && Array.isArray(obj.messages);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestSuccess extends TestState {
|
||||
readonly state: TestExecutionState.Passed;
|
||||
readonly duration?: number;
|
||||
}
|
||||
|
||||
export interface TestStateChangedEvent {
|
||||
test: TestItem;
|
||||
oldState: TestState | undefined;
|
||||
newState: TestState | undefined;
|
||||
}
|
||||
|
||||
export interface TestRun {
|
||||
cancel(): void;
|
||||
readonly id: string;
|
||||
readonly name: string;
|
||||
readonly isRunning: boolean;
|
||||
readonly controller: TestController;
|
||||
|
||||
onDidChangeProperty: Event<{ name?: string, isRunning?: boolean }>;
|
||||
|
||||
getTestState(item: TestItem): TestState | undefined;
|
||||
onDidChangeTestState: Event<TestStateChangedEvent[]>;
|
||||
|
||||
getOutput(item?: TestItem): readonly TestOutputItem[];
|
||||
onDidChangeTestOutput: Event<[TestItem | undefined, TestOutputItem][]>;
|
||||
|
||||
readonly items: readonly TestItem[];
|
||||
}
|
||||
|
||||
export namespace TestRun {
|
||||
export function is(obj: unknown): obj is TestRun {
|
||||
return isObject<TestRun>(obj)
|
||||
&& typeof obj.cancel === 'function'
|
||||
&& typeof obj.name === 'string'
|
||||
&& typeof obj.isRunning === 'boolean'
|
||||
&& typeof obj.controller === 'object'
|
||||
&& typeof obj.onDidChangeProperty === 'function'
|
||||
&& typeof obj.getTestState === 'function'
|
||||
&& typeof obj.onDidChangeTestState === 'function'
|
||||
&& typeof obj.onDidChangeTestState === 'function'
|
||||
&& typeof obj.getOutput === 'function'
|
||||
&& typeof obj.onDidChangeTestOutput === 'function'
|
||||
&& Array.isArray(obj.items);
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestItem {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly range?: Range;
|
||||
readonly sortKey?: string;
|
||||
readonly tags: string[];
|
||||
readonly uri?: URI;
|
||||
readonly busy: boolean;
|
||||
readonly tests: readonly TestItem[];
|
||||
readonly description?: string;
|
||||
readonly error?: string | MarkdownString;
|
||||
readonly parent: TestItem | undefined;
|
||||
readonly controller: TestController | undefined;
|
||||
readonly canResolveChildren: boolean;
|
||||
resolveChildren(): void;
|
||||
readonly path: string[];
|
||||
}
|
||||
|
||||
export namespace TestItem {
|
||||
export function is(obj: unknown): obj is TestItem {
|
||||
return isObject<TestItem>(obj)
|
||||
&& obj.id !== undefined
|
||||
&& obj.label !== undefined
|
||||
&& Array.isArray(obj.tags)
|
||||
&& Array.isArray(obj.tests)
|
||||
&& obj.busy !== undefined
|
||||
&& obj.canResolveChildren !== undefined
|
||||
&& typeof obj.resolveChildren === 'function';
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestController {
|
||||
readonly id: string;
|
||||
readonly label: string;
|
||||
readonly tests: readonly TestItem[];
|
||||
readonly testRunProfiles: readonly TestRunProfile[];
|
||||
readonly testRuns: readonly TestRun[];
|
||||
|
||||
readonly onItemsChanged: Event<TreeDelta<string, TestItem>[]>;
|
||||
readonly onRunsChanged: Event<CollectionDelta<TestRun, TestRun>>;
|
||||
readonly onProfilesChanged: Event<CollectionDelta<TestRunProfile, TestRunProfile>>;
|
||||
|
||||
refreshTests(token: CancellationToken): Promise<void>;
|
||||
clearRuns(): void;
|
||||
}
|
||||
|
||||
export interface TestService {
|
||||
clearResults(): void;
|
||||
configureProfile(): void;
|
||||
selectDefaultProfile(): void;
|
||||
runTestsWithProfile(tests: TestItem[]): void;
|
||||
runTests(profileKind: TestRunProfileKind, tests: TestItem[]): void;
|
||||
runAllTests(profileKind: TestRunProfileKind): void;
|
||||
getControllers(): TestController[];
|
||||
registerTestController(controller: TestController): Disposable;
|
||||
onControllersChanged: Event<CollectionDelta<string, TestController>>;
|
||||
|
||||
refresh(): void;
|
||||
cancelRefresh(): void;
|
||||
isRefreshing: boolean;
|
||||
onDidChangeIsRefreshing: Event<void>;
|
||||
}
|
||||
|
||||
export namespace TestServices {
|
||||
export function withTestRun(service: TestService, controllerId: string, runId: string): TestRun {
|
||||
const controller = service.getControllers().find(c => c.id === controllerId);
|
||||
if (!controller) {
|
||||
throw new Error(`No test controller with id '${controllerId}' found`);
|
||||
}
|
||||
const run = controller.testRuns.find(r => r.id === runId);
|
||||
if (!run) {
|
||||
throw new Error(`No test run with id '${runId}' found`);
|
||||
}
|
||||
return run;
|
||||
}
|
||||
}
|
||||
|
||||
export const TestContribution = Symbol('TestContribution');
|
||||
|
||||
export interface TestContribution {
|
||||
registerTestControllers(service: TestService): void;
|
||||
}
|
||||
|
||||
export const TestService = Symbol('TestService');
|
||||
|
||||
@injectable()
|
||||
export class DefaultTestService implements TestService {
|
||||
@inject(QuickPickService) quickpickService: QuickPickService;
|
||||
|
||||
private testRunCounter = 0;
|
||||
|
||||
private onDidChangeIsRefreshingEmitter = new Emitter<void>();
|
||||
onDidChangeIsRefreshing: Event<void> = this.onDidChangeIsRefreshingEmitter.event;
|
||||
|
||||
private controllers: Map<string, TestController> = new Map();
|
||||
private refreshing: Set<CancellationTokenSource> = new Set();
|
||||
private onControllersChangedEmitter = new Emitter<CollectionDelta<string, TestController>>();
|
||||
|
||||
@inject(ContributionProvider) @named(TestContribution)
|
||||
protected readonly contributionProvider: ContributionProvider<TestContribution>;
|
||||
|
||||
@postConstruct()
|
||||
protected registerContributions(): void {
|
||||
this.contributionProvider.getContributions().forEach(contribution => contribution.registerTestControllers(this));
|
||||
}
|
||||
|
||||
onControllersChanged: Event<CollectionDelta<string, TestController>> = this.onControllersChangedEmitter.event;
|
||||
|
||||
registerTestController(controller: TestController): Disposable {
|
||||
if (this.controllers.has(controller.id)) {
|
||||
throw new Error('TestController already registered: ' + controller.id);
|
||||
}
|
||||
this.controllers.set(controller.id, controller);
|
||||
this.onControllersChangedEmitter.fire({ added: [controller] });
|
||||
return Disposable.create(() => {
|
||||
this.controllers.delete(controller.id);
|
||||
this.onControllersChangedEmitter.fire({ removed: [controller.id] });
|
||||
});
|
||||
}
|
||||
|
||||
getControllers(): TestController[] {
|
||||
return Array.from(this.controllers.values());
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
const cts = new CancellationTokenSource();
|
||||
this.refreshing.add(cts);
|
||||
|
||||
Promise.all(this.getControllers().map(controller => controller.refreshTests(cts.token))).then(() => {
|
||||
this.refreshing.delete(cts);
|
||||
if (this.refreshing.size === 0) {
|
||||
this.onDidChangeIsRefreshingEmitter.fire();
|
||||
}
|
||||
});
|
||||
|
||||
if (this.refreshing.size === 1) {
|
||||
this.onDidChangeIsRefreshingEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
cancelRefresh(): void {
|
||||
if (this.refreshing.size > 0) {
|
||||
this.refreshing.forEach(cts => cts.cancel());
|
||||
this.refreshing.clear();
|
||||
this.onDidChangeIsRefreshingEmitter.fire();
|
||||
}
|
||||
}
|
||||
|
||||
get isRefreshing(): boolean {
|
||||
return this.refreshing.size > 0;
|
||||
}
|
||||
|
||||
runAllTests(profileKind: TestRunProfileKind): void {
|
||||
this.getControllers().forEach(controller => {
|
||||
this.runTestForController(controller, profileKind, controller.tests);
|
||||
});
|
||||
}
|
||||
|
||||
protected async runTestForController(controller: TestController, profileKind: TestRunProfileKind, items: readonly TestItem[]): Promise<void> {
|
||||
const runProfiles = controller.testRunProfiles.filter(profile => profile.kind === profileKind);
|
||||
let activeProfile;
|
||||
if (runProfiles.length === 1) {
|
||||
activeProfile = runProfiles[0];
|
||||
} else if (runProfiles.length > 1) {
|
||||
const defaultProfile = runProfiles.find(p => p.isDefault);
|
||||
if (defaultProfile) {
|
||||
activeProfile = defaultProfile;
|
||||
} else {
|
||||
|
||||
activeProfile = await this.pickProfile(runProfiles, nls.localizeByDefault('Pick a test profile to use'));
|
||||
}
|
||||
}
|
||||
if (activeProfile) {
|
||||
activeProfile.run(`Test run #${this.testRunCounter++}`, items, [], true);
|
||||
}
|
||||
}
|
||||
|
||||
protected async pickProfile(runProfiles: readonly TestRunProfile[], title: string): Promise<TestRunProfile | undefined> {
|
||||
if (runProfiles.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
// eslint-disable-next-line arrow-body-style
|
||||
const picks = runProfiles.map(profile => {
|
||||
let iconClasses;
|
||||
if (profile.kind === TestRunProfileKind.Run) {
|
||||
iconClasses = codiconArray('run');
|
||||
} else if (profile.kind === TestRunProfileKind.Debug) {
|
||||
iconClasses = codiconArray('debug-alt');
|
||||
}
|
||||
return {
|
||||
iconClasses,
|
||||
label: `${profile.label}${profile.isDefault ? ' (default)' : ''}`,
|
||||
profile: profile
|
||||
};
|
||||
});
|
||||
|
||||
return (await this.quickpickService.show(picks, { title: title }))?.profile;
|
||||
|
||||
}
|
||||
|
||||
protected async pickProfileKind(): Promise<TestRunProfileKind | undefined> {
|
||||
// eslint-disable-next-line arrow-body-style
|
||||
const picks = [{
|
||||
iconClasses: codiconArray('run'),
|
||||
label: 'Run',
|
||||
kind: TestRunProfileKind.Run
|
||||
}, {
|
||||
iconClasses: codiconArray('debug-alt'),
|
||||
label: 'Debug',
|
||||
kind: TestRunProfileKind.Debug
|
||||
}];
|
||||
|
||||
return (await this.quickpickService.show(picks, { title: 'Select the kind of profiles' }))?.kind;
|
||||
|
||||
}
|
||||
|
||||
runTests(profileKind: TestRunProfileKind, items: TestItem[]): void {
|
||||
groupBy(items, item => item.controller).forEach((tests, controller) => {
|
||||
if (controller) {
|
||||
this.runTestForController(controller, profileKind, tests);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
runTestsWithProfile(items: TestItem[]): void {
|
||||
groupBy(items, item => item.controller).forEach((tests, controller) => {
|
||||
if (controller) {
|
||||
this.pickProfile(controller.testRunProfiles, nls.localizeByDefault('Pick a test profile to use')).then(activeProfile => {
|
||||
if (activeProfile) {
|
||||
activeProfile.run(`Test run #${this.testRunCounter++}`, items, [], true);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
selectDefaultProfile(): void {
|
||||
this.pickProfileKind().then(kind => {
|
||||
const profiles = this.getControllers().flatMap(c => c.testRunProfiles).filter(profile => profile.kind === kind);
|
||||
this.pickProfile(profiles, nls.localizeByDefault('Pick a test profile to use')).then(activeProfile => {
|
||||
if (activeProfile) {
|
||||
// only change the default for the controller containing selected profile for default and its profiles with same kind
|
||||
const controller = this.getControllers().find(c => c.testRunProfiles.includes(activeProfile));
|
||||
controller?.testRunProfiles.filter(profile => profile.kind === activeProfile.kind).forEach(profile => {
|
||||
profile.isDefault = profile === activeProfile;
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
configureProfile(): void {
|
||||
const profiles: TestRunProfile[] = [];
|
||||
|
||||
for (const controller of this.controllers.values()) {
|
||||
profiles.push(...controller.testRunProfiles);
|
||||
}
|
||||
;
|
||||
this.pickProfile(profiles.filter(profile => profile.canConfigure), nls.localizeByDefault('Select a profile to update')).then(profile => {
|
||||
if (profile) {
|
||||
profile.configure();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearResults(): void {
|
||||
for (const controller of this.controllers.values()) {
|
||||
controller.clearRuns();
|
||||
}
|
||||
}
|
||||
}
|
||||
36
packages/test/src/browser/view/test-context-key-service.ts
Normal file
36
packages/test/src/browser/view/test-context-key-service.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// *****************************************************************************
|
||||
// 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 { ContextKeyService, ContextKey } from '@theia/core/lib/browser/context-key-service';
|
||||
|
||||
@injectable()
|
||||
export class TestContextKeyService {
|
||||
|
||||
@inject(ContextKeyService)
|
||||
protected readonly contextKeyService: ContextKeyService;
|
||||
|
||||
protected _contextValue: ContextKey<string | undefined>;
|
||||
get contextValue(): ContextKey<string | undefined> {
|
||||
return this._contextValue;
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected init(): void {
|
||||
this._contextValue = this.contextKeyService.createKey<string | undefined>('testMessage', undefined);
|
||||
}
|
||||
|
||||
}
|
||||
147
packages/test/src/browser/view/test-execution-state-manager.ts
Normal file
147
packages/test/src/browser/view/test-execution-state-manager.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// *****************************************************************************
|
||||
// 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 { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { TestController, TestExecutionState, TestItem, TestRun, TestService } from '../test-service';
|
||||
|
||||
/**
|
||||
* This class manages the state of "internal" nodes in the test tree
|
||||
*/
|
||||
@injectable()
|
||||
export class TestExecutionStateManager {
|
||||
@inject(TestService)
|
||||
protected readonly testService: TestService;
|
||||
|
||||
private executionStates = new Map<TestRun, TestExecutionStateMap>();
|
||||
|
||||
@postConstruct()
|
||||
init(): void {
|
||||
this.testService.getControllers().forEach(controller => this.addController(controller));
|
||||
this.testService.onControllersChanged(controllerDelta => {
|
||||
controllerDelta.added?.forEach(controller => this.addController(controller));
|
||||
});
|
||||
}
|
||||
addController(controller: TestController): void {
|
||||
controller.testRuns.forEach(run => this.addRun(run));
|
||||
controller.onRunsChanged(runDelta => {
|
||||
runDelta.added?.forEach(run => this.addRun(run));
|
||||
runDelta.removed?.forEach(run => {
|
||||
this.executionStates.delete(run);
|
||||
});
|
||||
});
|
||||
}
|
||||
addRun(run: TestRun): void {
|
||||
this.executionStates.set(run, new TestExecutionStateMap);
|
||||
run.onDidChangeTestState(updates => {
|
||||
updates.forEach(update => {
|
||||
this.updateState(run, update.test, update.oldState?.state, update.newState?.state);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
protected updateState(run: TestRun, item: TestItem, oldState: TestExecutionState | undefined, newState: TestExecutionState | undefined): void {
|
||||
const map = this.executionStates.get(run)!;
|
||||
map.reportState(item, oldState, newState);
|
||||
}
|
||||
|
||||
getComputedState(run: TestRun, item: TestItem): TestExecutionState | undefined {
|
||||
return this.executionStates.get(run)?.getComputedState(item);
|
||||
}
|
||||
}
|
||||
|
||||
class TestExecutionStateMap {
|
||||
reportState(item: TestItem, oldState: TestExecutionState | undefined, newState: TestExecutionState | undefined): void {
|
||||
if (oldState !== newState) {
|
||||
if (item.parent) {
|
||||
this.reportChildStateChanged(item.parent, oldState, newState);
|
||||
}
|
||||
}
|
||||
}
|
||||
reportChildStateChanged(parent: TestItem, oldState: TestExecutionState | undefined, newState: TestExecutionState | undefined): void {
|
||||
if (oldState !== newState) {
|
||||
const currentParentState = this.getComputedState(parent);
|
||||
let counts = this.stateCounts.get(parent);
|
||||
if (!counts) {
|
||||
counts = [];
|
||||
counts[TestExecutionState.Queued] = 0;
|
||||
counts[TestExecutionState.Running] = 0;
|
||||
counts[TestExecutionState.Passed] = 0;
|
||||
counts[TestExecutionState.Failed] = 0;
|
||||
counts[TestExecutionState.Skipped] = 0;
|
||||
counts[TestExecutionState.Errored] = 0;
|
||||
this.stateCounts.set(parent, counts);
|
||||
}
|
||||
if (oldState) {
|
||||
counts[oldState]--;
|
||||
}
|
||||
if (newState) {
|
||||
counts[newState]++;
|
||||
}
|
||||
const newParentState = this.getComputedState(parent);
|
||||
if (parent.parent && currentParentState !== newParentState) {
|
||||
this.reportChildStateChanged(parent.parent, currentParentState, newParentState!);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private stateCounts: Map<TestItem | TestController, number[]> = new Map();
|
||||
|
||||
updateState(item: TestItem, oldState: TestExecutionState | undefined, newState: TestExecutionState): void {
|
||||
let parent = item.parent;
|
||||
while (parent && 'parent' in parent) { // parent is a test item
|
||||
let counts = this.stateCounts.get(parent);
|
||||
if (!counts) {
|
||||
counts = [];
|
||||
counts[TestExecutionState.Queued] = 0;
|
||||
counts[TestExecutionState.Running] = 0;
|
||||
counts[TestExecutionState.Passed] = 0;
|
||||
counts[TestExecutionState.Failed] = 0;
|
||||
counts[TestExecutionState.Skipped] = 0;
|
||||
counts[TestExecutionState.Errored] = 0;
|
||||
this.stateCounts.set(parent, counts);
|
||||
}
|
||||
if (oldState) {
|
||||
counts[oldState]--;
|
||||
}
|
||||
counts[newState]++;
|
||||
parent = parent.parent;
|
||||
}
|
||||
}
|
||||
|
||||
getComputedState(item: TestItem): TestExecutionState | undefined {
|
||||
const counts = this.stateCounts.get(item);
|
||||
if (counts) {
|
||||
if (counts[TestExecutionState.Errored] > 0) {
|
||||
return TestExecutionState.Errored;
|
||||
} else if (counts[TestExecutionState.Failed] > 0) {
|
||||
return TestExecutionState.Failed;
|
||||
} else if (counts[TestExecutionState.Running] > 0) {
|
||||
return TestExecutionState.Running;
|
||||
} else if (counts[TestExecutionState.Queued] > 0) {
|
||||
return TestExecutionState.Queued;
|
||||
} else if (counts[TestExecutionState.Passed] > 0) {
|
||||
return TestExecutionState.Passed;
|
||||
} else if (counts[TestExecutionState.Skipped] > 0) {
|
||||
return TestExecutionState.Skipped;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
156
packages/test/src/browser/view/test-output-ui-model.ts
Normal file
156
packages/test/src/browser/view/test-output-ui-model.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
// *****************************************************************************
|
||||
// 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 { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { TestController, TestFailure, TestOutputItem, TestRun, TestService, TestState, TestStateChangedEvent } from '../test-service';
|
||||
import { Disposable, Emitter, Event } from '@theia/core';
|
||||
import { TestContextKeyService } from './test-context-key-service';
|
||||
|
||||
export interface ActiveRunEvent {
|
||||
controller: TestController;
|
||||
activeRun: TestRun | undefined
|
||||
}
|
||||
|
||||
export interface TestOutputSource {
|
||||
readonly output: readonly TestOutputItem[];
|
||||
onDidAddTestOutput: Event<TestOutputItem[]>;
|
||||
}
|
||||
|
||||
export interface ActiveTestStateChangedEvent {
|
||||
controller: TestController;
|
||||
testRun: TestRun;
|
||||
statedDelta: TestStateChangedEvent[];
|
||||
}
|
||||
|
||||
interface ActiveTestRunInfo {
|
||||
run: TestRun;
|
||||
toDispose: Disposable;
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class TestOutputUIModel {
|
||||
@inject(TestContextKeyService) protected readonly testContextKeys: TestContextKeyService;
|
||||
@inject(TestService) protected testService: TestService;
|
||||
|
||||
protected readonly activeRuns = new Map<string, ActiveTestRunInfo>();
|
||||
protected readonly controllerListeners = new Map<string, Disposable>();
|
||||
private _selectedOutputSource: TestOutputSource | undefined;
|
||||
private _selectedTestState: TestState | undefined;
|
||||
|
||||
@postConstruct()
|
||||
init(): void {
|
||||
this.testService.getControllers().forEach(controller => this.addController(controller));
|
||||
this.testService.onControllersChanged(deltas => {
|
||||
deltas.added?.forEach(controller => this.addController(controller));
|
||||
deltas.removed?.forEach(controller => this.removeController(controller));
|
||||
});
|
||||
}
|
||||
|
||||
protected removeController(id: string): void {
|
||||
this.controllerListeners.get(id)?.dispose();
|
||||
if (this.activeRuns.has(id)) {
|
||||
this.activeRuns.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
protected addController(controller: TestController): void {
|
||||
this.controllerListeners.set(controller.id, controller.onRunsChanged(delta => {
|
||||
if (delta.added) {
|
||||
const currentRun = controller.testRuns[controller.testRuns.length - 1];
|
||||
if (currentRun) {
|
||||
this.setActiveTestRun(currentRun);
|
||||
}
|
||||
} else {
|
||||
delta.removed?.forEach(run => {
|
||||
if (run === this.getActiveTestRun(controller)) {
|
||||
const currentRun = controller.testRuns[controller.testRuns.length - 1];
|
||||
this.doSetActiveRun(controller, currentRun);
|
||||
}
|
||||
});
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
getActiveTestRun(controller: TestController): TestRun | undefined {
|
||||
return this.activeRuns.get(controller.id)?.run;
|
||||
}
|
||||
|
||||
protected readonly onDidChangeActiveTestRunEmitter = new Emitter<ActiveRunEvent>();
|
||||
onDidChangeActiveTestRun: Event<ActiveRunEvent> = this.onDidChangeActiveTestRunEmitter.event;
|
||||
|
||||
setActiveTestRun(run: TestRun): void {
|
||||
this.doSetActiveRun(run.controller, run);
|
||||
}
|
||||
|
||||
doSetActiveRun(controller: TestController, run: TestRun | undefined): void {
|
||||
const old = this.activeRuns.get(controller.id);
|
||||
if (old !== run) {
|
||||
if (old) {
|
||||
old.toDispose.dispose();
|
||||
}
|
||||
if (run) {
|
||||
const toDispose = run.onDidChangeTestState(e => {
|
||||
this.onDidChangeActiveTestStateEmitter.fire({
|
||||
controller,
|
||||
testRun: run,
|
||||
statedDelta: e
|
||||
});
|
||||
});
|
||||
this.activeRuns.set(controller.id, { run, toDispose });
|
||||
} else {
|
||||
this.activeRuns.delete(controller.id);
|
||||
}
|
||||
this.onDidChangeActiveTestRunEmitter.fire({ activeRun: run, controller: controller });
|
||||
}
|
||||
}
|
||||
|
||||
private onDidChangeActiveTestStateEmitter: Emitter<ActiveTestStateChangedEvent> = new Emitter();
|
||||
onDidChangeActiveTestState: Event<ActiveTestStateChangedEvent> = this.onDidChangeActiveTestStateEmitter.event;
|
||||
|
||||
get selectedOutputSource(): TestOutputSource | undefined {
|
||||
return this._selectedOutputSource;
|
||||
}
|
||||
|
||||
set selectedOutputSource(element: TestOutputSource | undefined) {
|
||||
if (element !== this._selectedOutputSource) {
|
||||
this._selectedOutputSource = element;
|
||||
this.onDidChangeSelectedOutputSourceEmitter.fire(element);
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly onDidChangeSelectedOutputSourceEmitter = new Emitter<TestOutputSource | undefined>();
|
||||
readonly onDidChangeSelectedOutputSource: Event<TestOutputSource | undefined> = this.onDidChangeSelectedOutputSourceEmitter.event;
|
||||
|
||||
get selectedTestState(): TestState | undefined {
|
||||
return this._selectedTestState;
|
||||
}
|
||||
|
||||
set selectedTestState(element: TestState | undefined) {
|
||||
if (element !== this._selectedTestState) {
|
||||
this._selectedTestState = element;
|
||||
if (this._selectedTestState && TestFailure.is(this._selectedTestState.state)) {
|
||||
const message = this._selectedTestState.state.messages[0];
|
||||
this.testContextKeys.contextValue.set(message.contextValue);
|
||||
} else {
|
||||
this.testContextKeys.contextValue.reset();
|
||||
}
|
||||
this.onDidChangeSelectedTestStateEmitter.fire(element);
|
||||
}
|
||||
}
|
||||
|
||||
protected readonly onDidChangeSelectedTestStateEmitter = new Emitter<TestState | undefined>();
|
||||
readonly onDidChangeSelectedTestState: Event<TestState | undefined> = this.onDidChangeSelectedTestStateEmitter.event;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// *****************************************************************************
|
||||
// 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 { AbstractViewContribution } from '@theia/core/lib/browser';
|
||||
import { TestOutputWidget } from './test-output-widget';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { nls } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class TestOutputViewContribution extends AbstractViewContribution<TestOutputWidget> {
|
||||
constructor() {
|
||||
super({
|
||||
widgetId: TestOutputWidget.ID,
|
||||
widgetName: nls.localizeByDefault('Test Output'),
|
||||
defaultWidgetOptions: {
|
||||
area: 'bottom'
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
142
packages/test/src/browser/view/test-output-widget.ts
Normal file
142
packages/test/src/browser/view/test-output-widget.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
// *****************************************************************************
|
||||
// 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 { Terminal } from 'xterm';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
|
||||
import { BaseWidget, Message, Widget, codicon, isFirefox } from '@theia/core/lib/browser';
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { Disposable, DisposableCollection } from '@theia/core';
|
||||
import { TerminalPreferences } from '@theia/terminal/lib/common/terminal-preferences';
|
||||
import { TerminalThemeService } from '@theia/terminal/lib/browser/terminal-theme-service';
|
||||
import { TestOutputSource, TestOutputUIModel } from './test-output-ui-model';
|
||||
import debounce = require('p-debounce');
|
||||
|
||||
@injectable()
|
||||
export class TestOutputWidget extends BaseWidget {
|
||||
@inject(TerminalPreferences) protected readonly preferences: TerminalPreferences;
|
||||
@inject(TerminalThemeService) protected readonly themeService: TerminalThemeService;
|
||||
@inject(TestOutputUIModel) protected readonly uiModel: TestOutputUIModel;
|
||||
|
||||
static ID = 'test-output-view';
|
||||
|
||||
protected term: Terminal;
|
||||
protected disposeOnSetInput = new DisposableCollection();
|
||||
protected fitAddon: FitAddon;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.id = TestOutputWidget.ID;
|
||||
this.title.label = 'Test Output';
|
||||
this.title.caption = 'Test Output';
|
||||
this.title.iconClass = codicon('symbol-keyword');
|
||||
this.title.closable = true;
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
init(): void {
|
||||
this.term = new Terminal({
|
||||
disableStdin: true,
|
||||
cursorStyle: 'bar',
|
||||
fontFamily: this.preferences['terminal.integrated.fontFamily'],
|
||||
fontSize: this.preferences['terminal.integrated.fontSize'],
|
||||
fontWeight: this.preferences['terminal.integrated.fontWeight'],
|
||||
fontWeightBold: this.preferences['terminal.integrated.fontWeightBold'],
|
||||
drawBoldTextInBrightColors: this.preferences['terminal.integrated.drawBoldTextInBrightColors'],
|
||||
letterSpacing: this.preferences['terminal.integrated.letterSpacing'],
|
||||
lineHeight: this.preferences['terminal.integrated.lineHeight'],
|
||||
scrollback: this.preferences['terminal.integrated.scrollback'],
|
||||
fastScrollSensitivity: this.preferences['terminal.integrated.fastScrollSensitivity'],
|
||||
theme: this.themeService.theme
|
||||
});
|
||||
|
||||
this.fitAddon = new FitAddon();
|
||||
this.term.loadAddon(this.fitAddon);
|
||||
this.setInput(this.uiModel.selectedOutputSource);
|
||||
this.uiModel.onDidChangeSelectedOutputSource(source => this.setInput(source));
|
||||
|
||||
this.toDispose.push(Disposable.create(() =>
|
||||
this.term.dispose()
|
||||
));
|
||||
}
|
||||
|
||||
setInput(selectedOutputSource: TestOutputSource | undefined): void {
|
||||
this.disposeOnSetInput.dispose();
|
||||
this.disposeOnSetInput = new DisposableCollection();
|
||||
this.term.clear();
|
||||
if (selectedOutputSource) {
|
||||
selectedOutputSource.output.forEach(item => this.term.writeln(item.output));
|
||||
this.disposeOnSetInput.push(selectedOutputSource.onDidAddTestOutput(items => {
|
||||
items.forEach(item => this.term.writeln(item.output));
|
||||
}));
|
||||
this.term.scrollToBottom();
|
||||
}
|
||||
}
|
||||
|
||||
protected override onAfterAttach(msg: Message): void {
|
||||
super.onAfterAttach(msg);
|
||||
this.term.open(this.node);
|
||||
|
||||
if (isFirefox) {
|
||||
// monkey patching intersection observer handling for secondary window support
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const renderService: any = (this.term as any)._core._renderService;
|
||||
const originalFunc: (entry: IntersectionObserverEntry) => void = renderService._onIntersectionChange.bind(renderService);
|
||||
const replacement = function (entry: IntersectionObserverEntry): void {
|
||||
if (entry.target.ownerDocument !== document) {
|
||||
// in Firefox, the intersection observer always reports the widget as non-intersecting if the dom element
|
||||
// is in a different document from when the IntersectionObserver started observing. Since we know
|
||||
// that the widget is always "visible" when in a secondary window, so we mark the entry as "intersecting"
|
||||
const patchedEvent: IntersectionObserverEntry = {
|
||||
...entry,
|
||||
isIntersecting: true,
|
||||
};
|
||||
originalFunc(patchedEvent);
|
||||
} else {
|
||||
originalFunc(entry);
|
||||
}
|
||||
};
|
||||
|
||||
renderService._onIntersectionChange = replacement;
|
||||
}
|
||||
|
||||
if (isFirefox) {
|
||||
// The software scrollbars don't work with xterm.js, so we disable the scrollbar if we are on firefox.
|
||||
if (this.term.element) {
|
||||
(this.term.element.children.item(0) as HTMLElement).style.overflow = 'hidden';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected override onResize(msg: Widget.ResizeMessage): void {
|
||||
super.onResize(msg);
|
||||
this.resizeTerminal();
|
||||
}
|
||||
|
||||
protected resizeTerminal = debounce(() => this.doResizeTerminal(), 50);
|
||||
|
||||
protected doResizeTerminal(): void {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
const geo = this.fitAddon.proposeDimensions();
|
||||
if (geo) {
|
||||
const cols = geo.cols;
|
||||
const rows = geo.rows - 1; // subtract one row for margin
|
||||
this.term.resize(cols, rows);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// *****************************************************************************
|
||||
// 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 { AbstractViewContribution } from '@theia/core/lib/browser';
|
||||
import { injectable } from '@theia/core/shared/inversify';
|
||||
import { TestResultWidget } from './test-result-widget';
|
||||
import { nls } from '@theia/core';
|
||||
|
||||
@injectable()
|
||||
export class TestResultViewContribution extends AbstractViewContribution<TestResultWidget> {
|
||||
constructor() {
|
||||
super({
|
||||
widgetId: TestResultWidget.ID,
|
||||
widgetName: nls.localizeByDefault('Test Results'),
|
||||
defaultWidgetOptions: {
|
||||
area: 'bottom'
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
139
packages/test/src/browser/view/test-result-widget.ts
Normal file
139
packages/test/src/browser/view/test-result-widget.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
// *****************************************************************************
|
||||
// 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 { BaseWidget, LabelProvider, Message, OpenerService, codicon } from '@theia/core/lib/browser';
|
||||
import { inject, injectable, postConstruct } from '@theia/core/shared/inversify';
|
||||
import { TestOutputUIModel } from './test-output-ui-model';
|
||||
import { Disposable, DisposableCollection, nls } from '@theia/core';
|
||||
import { TestFailure, TestMessage, TestMessageStackFrame } from '../test-service';
|
||||
import { MarkdownRenderer } from '@theia/core/lib/browser/markdown-rendering/markdown-renderer';
|
||||
import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
|
||||
import { URI } from '@theia/core/lib/common/uri';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service';
|
||||
import { NavigationLocation, Position } from '@theia/editor/lib/browser/navigation/navigation-location';
|
||||
@injectable()
|
||||
export class TestResultWidget extends BaseWidget {
|
||||
|
||||
static readonly ID = 'test-result-widget';
|
||||
|
||||
@inject(TestOutputUIModel) uiModel: TestOutputUIModel;
|
||||
@inject(MarkdownRenderer) markdownRenderer: MarkdownRenderer;
|
||||
@inject(OpenerService) openerService: OpenerService;
|
||||
@inject(FileService) fileService: FileService;
|
||||
@inject(NavigationLocationService) navigationService: NavigationLocationService;
|
||||
@inject(LabelProvider) protected readonly labelProvider: LabelProvider;
|
||||
|
||||
protected toDisposeOnRender = new DisposableCollection();
|
||||
protected input: TestMessage[] = [];
|
||||
protected content: HTMLDivElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.addClass('theia-test-result-view');
|
||||
this.id = TestResultWidget.ID;
|
||||
this.title.label = nls.localizeByDefault('Test Results');
|
||||
this.title.caption = nls.localizeByDefault('Test Results');
|
||||
this.title.iconClass = codicon('checklist');
|
||||
this.title.closable = true;
|
||||
this.scrollOptions = {
|
||||
minScrollbarLength: 35,
|
||||
};
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
init(): void {
|
||||
this.toDispose.pushAll([Disposable.create(() => this.toDisposeOnRender.dispose()),
|
||||
this.uiModel.onDidChangeSelectedTestState(e => {
|
||||
if (TestFailure.is(e)) {
|
||||
this.setInput(e.messages);
|
||||
}
|
||||
})]);
|
||||
}
|
||||
|
||||
protected override onAfterAttach(msg: Message): void {
|
||||
super.onAfterAttach(msg);
|
||||
this.content = this.node.ownerDocument.createElement('div');
|
||||
this.node.append(this.content);
|
||||
}
|
||||
|
||||
setInput(messages: TestMessage[]): void {
|
||||
this.input = messages;
|
||||
this.update();
|
||||
}
|
||||
|
||||
protected override onUpdateRequest(msg: Message): void {
|
||||
this.render();
|
||||
super.onUpdateRequest(msg);
|
||||
}
|
||||
|
||||
render(): void {
|
||||
this.toDisposeOnRender.dispose();
|
||||
this.toDisposeOnRender = new DisposableCollection();
|
||||
this.content.innerHTML = '';
|
||||
this.input.forEach(message => {
|
||||
if (MarkdownString.is(message.message)) {
|
||||
const line = this.markdownRenderer.render(message.message);
|
||||
this.content.append(line.element);
|
||||
this.toDisposeOnRender.push(line);
|
||||
} else {
|
||||
this.content.append(this.node.ownerDocument.createTextNode(message.message));
|
||||
}
|
||||
if (message.stackTrace) {
|
||||
const stackTraceElement = this.node.ownerDocument.createElement('div');
|
||||
message.stackTrace.map(frame => this.renderFrame(frame, stackTraceElement));
|
||||
this.content.append(stackTraceElement);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
renderFrame(stackFrame: TestMessageStackFrame, stackTraceElement: HTMLElement): void {
|
||||
const frameElement = stackTraceElement.ownerDocument.createElement('div');
|
||||
frameElement.classList.add('debug-frame');
|
||||
frameElement.append(` ${nls.localize('theia/test/stackFrameAt', 'at')} ${stackFrame.label}`);
|
||||
|
||||
// Add URI information as clickable links
|
||||
if (stackFrame.uri) {
|
||||
frameElement.append(' (');
|
||||
const uri = new URI(stackFrame.uri);
|
||||
|
||||
const link = this.node.ownerDocument.createElement('a');
|
||||
let content = `${this.labelProvider.getName(uri)}`;
|
||||
if (stackFrame.position) {
|
||||
// Display Position as a 1-based position, similar to Monaco ones.
|
||||
const monacoPosition = {
|
||||
lineNumber: stackFrame.position.line + 1,
|
||||
column: stackFrame.position.character + 1
|
||||
};
|
||||
content += `:${monacoPosition.lineNumber}:${monacoPosition.column}`;
|
||||
}
|
||||
link.textContent = content;
|
||||
link.href = `${uri}`;
|
||||
link.onclick = () => this.openUriInWorkspace(uri, stackFrame.position);
|
||||
frameElement.append(link);
|
||||
frameElement.append(')');
|
||||
}
|
||||
stackTraceElement.append(frameElement);
|
||||
}
|
||||
|
||||
async openUriInWorkspace(uri: URI, position?: Position): Promise<void> {
|
||||
this.fileService.resolve(uri).then(stat => {
|
||||
if (stat.isFile) {
|
||||
this.navigationService.reveal(NavigationLocation.create(uri, position ?? { line: 0, character: 0 }));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
89
packages/test/src/browser/view/test-run-view-contribution.ts
Normal file
89
packages/test/src/browser/view/test-run-view-contribution.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// *****************************************************************************
|
||||
// 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 { AbstractViewContribution, Widget } from '@theia/core/lib/browser';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { TestRun, TestService } from '../test-service';
|
||||
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
|
||||
import { TestRunTreeWidget } from './test-run-widget';
|
||||
import { TEST_VIEW_CONTAINER_ID, TestViewCommands } from './test-view-contribution';
|
||||
import { CommandRegistry, MenuModelRegistry, nls } from '@theia/core';
|
||||
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
|
||||
|
||||
export const TEST_RUNS_CONTEXT_MENU = ['test-runs-context-menu'];
|
||||
export const TEST_RUNS_INLINE_MENU = [...TEST_RUNS_CONTEXT_MENU, 'inline'];
|
||||
|
||||
@injectable()
|
||||
export class TestRunViewContribution extends AbstractViewContribution<TestRunTreeWidget> implements TabBarToolbarContribution {
|
||||
|
||||
@inject(TestService) protected readonly testService: TestService;
|
||||
@inject(ContextKeyService) protected readonly contextKeys: ContextKeyService;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
viewContainerId: TEST_VIEW_CONTAINER_ID,
|
||||
widgetId: TestRunTreeWidget.ID,
|
||||
widgetName: nls.localize('theia/test/testRuns', 'Test Runs'),
|
||||
defaultWidgetOptions: {
|
||||
area: 'left',
|
||||
rank: 200,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerToolbarItems(registry: TabBarToolbarRegistry): void {
|
||||
registry.registerItem({
|
||||
id: TestViewCommands.CLEAR_ALL_RESULTS.id,
|
||||
command: TestViewCommands.CLEAR_ALL_RESULTS.id,
|
||||
priority: 1
|
||||
});
|
||||
}
|
||||
|
||||
override registerMenus(menus: MenuModelRegistry): void {
|
||||
super.registerMenus(menus);
|
||||
menus.registerMenuAction(TEST_RUNS_CONTEXT_MENU, {
|
||||
commandId: TestViewCommands.CANCEL_RUN.id
|
||||
});
|
||||
}
|
||||
|
||||
override registerCommands(commands: CommandRegistry): void {
|
||||
super.registerCommands(commands);
|
||||
commands.registerCommand(TestViewCommands.CANCEL_RUN, {
|
||||
isEnabled: t => TestRun.is(t) && t.isRunning,
|
||||
isVisible: t => TestRun.is(t),
|
||||
execute: t => {
|
||||
if (TestRun.is(t)) {
|
||||
t.cancel();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.CLEAR_ALL_RESULTS, {
|
||||
isEnabled: w => this.withWidget(w, () => true),
|
||||
isVisible: w => this.withWidget(w, () => true),
|
||||
execute: () => {
|
||||
this.testService.clearResults();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected withWidget<T>(widget: Widget | undefined = this.tryGetWidget(), cb: (widget: TestRunTreeWidget) => T): T | false {
|
||||
if (widget instanceof TestRunTreeWidget && widget.id === TestRunTreeWidget.ID) {
|
||||
return cb(widget);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
271
packages/test/src/browser/view/test-run-widget.tsx
Normal file
271
packages/test/src/browser/view/test-run-widget.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
// *****************************************************************************
|
||||
// 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<TestItem, TestItemNode>;
|
||||
}
|
||||
|
||||
@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<string, Disposable>();
|
||||
|
||||
private runs = new Map<TestRun, RunInfo>();
|
||||
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<TreeNode[]> {
|
||||
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;
|
||||
|
||||
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.model.onSelectionChanged(() => {
|
||||
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) {
|
||||
this.uiModel.selectedOutputSource = {
|
||||
get output(): readonly TestOutputItem[] {
|
||||
return node.parent.run.getOutput(node.item);
|
||||
},
|
||||
onDidAddTestOutput: Event.map(node.parent.run.onDidChangeTestOutput, evt => evt.filter(item => item[0] === node.item).map(item => item[1]))
|
||||
};
|
||||
this.uiModel.selectedTestState = node.parent.run.getTestState(node.item);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override renderTree(model: TreeModel): React.ReactNode {
|
||||
if (CompositeTreeNode.is(this.model.root) && this.model.root.children.length > 0) {
|
||||
return super.renderTree(model);
|
||||
}
|
||||
return <div className='theia-widget-noInfo noMarkers'>{nls.localizeByDefault('No tests have been found in this workspace yet.')}</div>;
|
||||
}
|
||||
|
||||
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 <div className={this.getTestStateClass(state)}></div >;
|
||||
} else if (node instanceof TestRunNode) {
|
||||
const icon = node.run.isRunning ? `${codicon('sync')} codicon-modifier-spin running` : codicon('circle');
|
||||
return <div className={icon}></div >;
|
||||
} 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
|
||||
}
|
||||
}
|
||||
360
packages/test/src/browser/view/test-tree-widget.tsx
Normal file
360
packages/test/src/browser/view/test-tree-widget.tsx
Normal file
@@ -0,0 +1,360 @@
|
||||
// *****************************************************************************
|
||||
// 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, ExpandableTreeNode, TreeNode, TreeImpl, NodeProps,
|
||||
TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, SelectableTreeNode
|
||||
} from '@theia/core/lib/browser/tree';
|
||||
import { ACTION_ITEM, ContextMenuRenderer, KeybindingRegistry, 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, TestItem, TestService } from '../test-service';
|
||||
import * as React from '@theia/core/shared/react';
|
||||
import { DeltaKind, TreeDelta } from '../../common/tree-delta';
|
||||
import { AcceleratorSource, CommandMenu, CommandRegistry, Disposable, DisposableCollection, Event, MenuModelRegistry, nls } from '@theia/core';
|
||||
import { TestExecutionStateManager } from './test-execution-state-manager';
|
||||
import { TestOutputUIModel } from './test-output-ui-model';
|
||||
import { TEST_VIEW_INLINE_MENU } from './test-view-contribution';
|
||||
|
||||
const ROOT_ID = 'TestTree';
|
||||
|
||||
export interface TestRoot extends CompositeTreeNode {
|
||||
children: TestControllerNode[];
|
||||
}
|
||||
export namespace TestRoot {
|
||||
export function is(node: unknown): node is TestRoot {
|
||||
return CompositeTreeNode.is(node) && node.id === ROOT_ID;
|
||||
}
|
||||
}
|
||||
export interface TestControllerNode extends ExpandableTreeNode {
|
||||
controller: TestController;
|
||||
}
|
||||
|
||||
export namespace TestControllerNode {
|
||||
export function is(node: unknown): node is TestControllerNode {
|
||||
return ExpandableTreeNode.is(node) && 'controller' in node;
|
||||
}
|
||||
}
|
||||
|
||||
export interface TestItemNode extends TreeNode {
|
||||
controller: TestController;
|
||||
testItem: TestItem;
|
||||
}
|
||||
|
||||
export namespace TestItemNode {
|
||||
export function is(node: unknown): node is TestItemNode {
|
||||
return TreeNode.is(node) && 'testItem' in node;
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class TestTree extends TreeImpl {
|
||||
@inject(TestService) protected readonly testService: TestService;
|
||||
|
||||
private controllerListeners = new Map<string, Disposable>();
|
||||
|
||||
@postConstruct()
|
||||
init(): void {
|
||||
this.testService.getControllers().forEach(controller => this.addController(controller));
|
||||
this.testService.onControllersChanged(e => {
|
||||
e.removed?.forEach(controller => {
|
||||
this.controllerListeners.get(controller)?.dispose();
|
||||
});
|
||||
|
||||
e.added?.forEach(controller => this.addController(controller));
|
||||
|
||||
this.refresh(this.root as CompositeTreeNode);
|
||||
});
|
||||
}
|
||||
|
||||
protected addController(controller: TestController): void {
|
||||
const listeners = new DisposableCollection();
|
||||
this.controllerListeners.set(controller.id, listeners);
|
||||
listeners.push(controller.onItemsChanged(delta => {
|
||||
this.processDeltas(controller, controller, delta);
|
||||
}));
|
||||
}
|
||||
|
||||
protected override async resolveChildren(parent: CompositeTreeNode): Promise<TreeNode[]> {
|
||||
if (TestItemNode.is(parent)) {
|
||||
parent.testItem.resolveChildren();
|
||||
return Promise.resolve(parent.testItem.tests.map(test => this.createTestNode(parent.controller, parent, test)));
|
||||
} else if (TestControllerNode.is(parent)) {
|
||||
return Promise.resolve(parent.controller.tests.map(test => this.createTestNode(parent.controller, parent, test)));
|
||||
} else if (TestRoot.is(parent)) {
|
||||
return Promise.resolve(this.testService.getControllers().map(controller => this.createControllerNode(parent, controller)));
|
||||
} else {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
createControllerNode(parent: CompositeTreeNode, controller: TestController): TestControllerNode {
|
||||
const node: TestControllerNode = {
|
||||
id: controller.id,
|
||||
name: controller.label,
|
||||
controller: controller,
|
||||
expanded: false,
|
||||
children: [],
|
||||
parent: parent
|
||||
};
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
protected processDeltas(controller: TestController, parent: TestItem | TestController, deltas: TreeDelta<string, TestItem>[]): void {
|
||||
deltas.forEach(delta => this.processDelta(controller, parent, delta));
|
||||
}
|
||||
|
||||
protected processDelta(controller: TestController, parent: TestItem | TestController, delta: TreeDelta<string, TestItem>): void {
|
||||
if (delta.type === DeltaKind.ADDED || delta.type === DeltaKind.REMOVED) {
|
||||
let node;
|
||||
if (parent === controller && delta.path.length === 1) {
|
||||
node = this.getNode(this.computeId([controller.id]));
|
||||
} else {
|
||||
const item = this.findInParent(parent, delta.path.slice(0, delta.path.length - 1), 0);
|
||||
if (item) {
|
||||
node = this.getNode(this.computeId(this.computePath(controller, item as TestItem)));
|
||||
}
|
||||
}
|
||||
if (node) {
|
||||
this.refresh(node as CompositeTreeNode); // we only have composite tree nodes in this tree
|
||||
} else {
|
||||
console.warn('delta for unknown test item');
|
||||
}
|
||||
} else {
|
||||
const item = this.findInParent(parent, delta.path, 0);
|
||||
if (item) {
|
||||
if (delta.type === DeltaKind.CHANGED) {
|
||||
this.fireChanged();
|
||||
}
|
||||
if (delta.childDeltas) {
|
||||
this.processDeltas(controller, item, delta.childDeltas);
|
||||
}
|
||||
} else {
|
||||
console.warn('delta for unknown test item');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected findInParent(root: TestItem | TestController, path: string[], startIndex: number): TestItem | TestController | undefined {
|
||||
if (startIndex >= path.length) {
|
||||
return root;
|
||||
}
|
||||
const child = root.tests.find(candidate => candidate.id === path[startIndex]);
|
||||
if (!child) {
|
||||
return undefined;
|
||||
}
|
||||
return this.findInParent(child, path, startIndex + 1);
|
||||
}
|
||||
|
||||
protected computePath(controller: TestController, item: TestItem): string[] {
|
||||
const result: string[] = [controller.id];
|
||||
let current: TestItem | undefined = item;
|
||||
while (current) {
|
||||
result.unshift(current.id);
|
||||
current = current.parent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
protected computeId(path: string[]): string {
|
||||
return path.map(id => id.replace('/', '//')).join('/');
|
||||
}
|
||||
|
||||
createTestNode(controller: TestController, parent: CompositeTreeNode, test: TestItem): TestItemNode {
|
||||
const previous = this.getNode(test.id);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const result: any = {
|
||||
id: this.computeId(this.computePath(controller, test)),
|
||||
name: test.label,
|
||||
controller: controller,
|
||||
testItem: test,
|
||||
expanded: ExpandableTreeNode.is(previous) ? previous.expanded : undefined,
|
||||
selected: false,
|
||||
children: [] as TestItemNode[],
|
||||
parent: parent
|
||||
};
|
||||
result.children = test.tests.map(t => this.createTestNode(controller, result, t));
|
||||
if (result.children.length === 0 && !test.canResolveChildren) {
|
||||
delete result.expanded;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
@injectable()
|
||||
export class TestTreeWidget extends TreeWidget {
|
||||
|
||||
static ID = 'test-tree-widget';
|
||||
|
||||
static TEST_CONTEXT_MENU = ['RESOURCE_CONTEXT_MENU'];
|
||||
|
||||
@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 uiModel: TestOutputUIModel;
|
||||
@inject(MenuModelRegistry) protected readonly menus: MenuModelRegistry;
|
||||
@inject(CommandRegistry) readonly commands: CommandRegistry;
|
||||
@inject(KeybindingRegistry) protected readonly keybindings: KeybindingRegistry;
|
||||
|
||||
constructor(
|
||||
@inject(TreeProps) props: TreeProps,
|
||||
@inject(TreeModel) model: TreeModel,
|
||||
@inject(ContextMenuRenderer) contextMenuRenderer: ContextMenuRenderer,
|
||||
) {
|
||||
super(props, model, contextMenuRenderer);
|
||||
this.id = TestTreeWidget.ID;
|
||||
this.title.label = nls.localizeByDefault('Test Explorer');
|
||||
this.title.caption = nls.localizeByDefault('Test Explorer');
|
||||
this.title.iconClass = codicon('beaker');
|
||||
this.title.closable = true;
|
||||
}
|
||||
|
||||
@postConstruct()
|
||||
protected override init(): void {
|
||||
super.init();
|
||||
this.addClass('theia-test-view');
|
||||
this.model.root = {
|
||||
id: ROOT_ID,
|
||||
parent: undefined,
|
||||
visible: false,
|
||||
children: []
|
||||
} as TestRoot;
|
||||
|
||||
this.uiModel.onDidChangeActiveTestRun(e => this.update());
|
||||
this.uiModel.onDidChangeActiveTestState(() => this.update());
|
||||
|
||||
this.model.onSelectionChanged(() => {
|
||||
const that = this;
|
||||
const node = this.model.selectedNodes[0];
|
||||
if (TestItemNode.is(node)) {
|
||||
const run = that.uiModel.getActiveTestRun(node.controller);
|
||||
if (run) {
|
||||
const output = run?.getOutput(node.testItem);
|
||||
if (output) {
|
||||
this.uiModel.selectedOutputSource = {
|
||||
output: output,
|
||||
onDidAddTestOutput: Event.map(run.onDidChangeTestOutput, evt => evt.filter(item => item[0] === node.testItem).map(item => item[1]))
|
||||
};
|
||||
}
|
||||
this.uiModel.selectedTestState = run.getTestState(node.testItem);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected override renderTree(model: TreeModel): React.ReactNode {
|
||||
if (TestRoot.is(model.root) && model.root.children.length > 0) {
|
||||
return super.renderTree(model);
|
||||
}
|
||||
return <div className='theia-widget-noInfo noMarkers'>{nls.localizeByDefault('No tests have been found in this workspace yet.')}</div>;
|
||||
}
|
||||
|
||||
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`;
|
||||
case TestExecutionState.Running: return `${codicon('sync-spin')} running`;
|
||||
default: return codicon('circle');
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderIcon(node: TreeNode, props: NodeProps): React.ReactNode {
|
||||
if (TestItemNode.is(node)) {
|
||||
const currentRun = this.uiModel.getActiveTestRun(node.controller);
|
||||
let state;
|
||||
if (currentRun) {
|
||||
state = currentRun.getTestState(node.testItem)?.state;
|
||||
if (!state) {
|
||||
state = this.stateManager.getComputedState(currentRun, node.testItem);
|
||||
}
|
||||
}
|
||||
return <div className={this.getTestStateClass(state)}></div >;
|
||||
} else {
|
||||
return super.renderIcon(node, props);
|
||||
}
|
||||
}
|
||||
|
||||
protected override renderTailDecorations(node: TreeNode, props: NodeProps): React.ReactNode {
|
||||
if (TestItemNode.is(node)) {
|
||||
const testItem = node.testItem;
|
||||
return this.contextKeys.with({ view: this.id, controllerId: node.controller.id, testId: testItem.id, testItemHasUri: !!testItem.uri }, () => {
|
||||
const menu = this.menus.getMenu(TEST_VIEW_INLINE_MENU)!; // we register items into this menu, so we know it exists
|
||||
const args = [node.testItem];
|
||||
const inlineCommands = menu.children.filter((item): item is CommandMenu => CommandMenu.is(item));
|
||||
const tailDecorations = super.renderTailDecorations(node, props);
|
||||
return <React.Fragment>
|
||||
{inlineCommands.length > 0 && <div className={TREE_NODE_SEGMENT_CLASS + ' flex'}>
|
||||
{inlineCommands.map((item, index) => this.renderInlineCommand(item, index, this.focusService.hasFocus(node), args))}
|
||||
</div>}
|
||||
{tailDecorations !== undefined && <div className={TREE_NODE_SEGMENT_CLASS + ' flex'}>{tailDecorations}</div>}
|
||||
</React.Fragment>;
|
||||
});
|
||||
} else {
|
||||
return super.renderTailDecorations(node, props);
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
protected renderInlineCommand(actionMenuNode: CommandMenu, index: number, tabbable: boolean, args: any[]): React.ReactNode {
|
||||
if (!actionMenuNode.icon || !actionMenuNode.isVisible(TEST_VIEW_INLINE_MENU, this.contextKeys, this.node, ...args)) {
|
||||
return false;
|
||||
}
|
||||
const className = [TREE_NODE_SEGMENT_CLASS, TREE_NODE_TAIL_CLASS, actionMenuNode.icon, ACTION_ITEM, 'theia-test-tree-inline-action'].join(' ');
|
||||
const tabIndex = tabbable ? 0 : undefined;
|
||||
const titleString = actionMenuNode.label + (AcceleratorSource.is(actionMenuNode) ? actionMenuNode.getAccelerator(undefined).join(' ') : '');
|
||||
|
||||
return <div key={index} className={className} title={titleString} tabIndex={tabIndex} onClick={e => {
|
||||
e.stopPropagation();
|
||||
actionMenuNode.run(TEST_VIEW_INLINE_MENU, ...args);
|
||||
}} />;
|
||||
}
|
||||
|
||||
protected resolveKeybindingForCommand(command: string | undefined): string {
|
||||
let result = '';
|
||||
if (command) {
|
||||
const bindings = this.keybindings.getKeybindingsForCommand(command);
|
||||
let found = false;
|
||||
if (bindings && bindings.length > 0) {
|
||||
bindings.forEach(binding => {
|
||||
if (!found && this.keybindings.isEnabledInScope(binding, this.node)) {
|
||||
found = true;
|
||||
result = ` (${this.keybindings.acceleratorFor(binding, '+')})`;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
protected override toContextMenuArgs(node: SelectableTreeNode): (TestItem)[] {
|
||||
if (TestItemNode.is(node)) {
|
||||
return [node.testItem];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
override storeState(): object {
|
||||
return {}; // don't store any state for now
|
||||
}
|
||||
}
|
||||
340
packages/test/src/browser/view/test-view-contribution.ts
Normal file
340
packages/test/src/browser/view/test-view-contribution.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
// *****************************************************************************
|
||||
// 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 { AbstractViewContribution, FrontendApplicationContribution, ViewContainerTitleOptions, Widget, codicon } from '@theia/core/lib/browser';
|
||||
import { Command, CommandRegistry, MenuModelRegistry, nls } from '@theia/core';
|
||||
import { inject, injectable } from '@theia/core/shared/inversify';
|
||||
import { TestItem, TestRunProfileKind, TestService } from '../test-service';
|
||||
import { ContextKeyService } from '@theia/core/lib/browser/context-key-service';
|
||||
import { TestTreeWidget } from './test-tree-widget';
|
||||
import { TabBarToolbarContribution, TabBarToolbarRegistry } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
|
||||
import { TestCommandId } from '../constants';
|
||||
import { NavigationLocationService } from '@theia/editor/lib/browser/navigation/navigation-location-service';
|
||||
import { NavigationLocation } from '@theia/editor/lib/browser/navigation/navigation-location';
|
||||
import { FileService } from '@theia/filesystem/lib/browser/file-service';
|
||||
import { FileNavigatorCommands } from '@theia/navigator/lib/browser/file-navigator-commands';
|
||||
export const PLUGIN_TEST_VIEW_TITLE_MENU = ['plugin_test', 'title'];
|
||||
|
||||
export namespace TestViewCommands {
|
||||
/**
|
||||
* Command which refreshes all test.
|
||||
*/
|
||||
export const REFRESH: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.RefreshTestsAction,
|
||||
label: 'Refresh Tests',
|
||||
category: 'Test',
|
||||
iconClass: codicon('refresh')
|
||||
});
|
||||
|
||||
/**
|
||||
* Command which cancels the refresh
|
||||
*/
|
||||
export const CANCEL_REFRESH: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.CancelTestRefreshAction,
|
||||
label: 'Cancel Test Refresh',
|
||||
category: 'Test',
|
||||
iconClass: codicon('stop')
|
||||
});
|
||||
|
||||
export const RUN_ALL_TESTS: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.RunAllAction,
|
||||
label: 'Run All Tests',
|
||||
category: 'Test',
|
||||
iconClass: codicon('run-all')
|
||||
});
|
||||
|
||||
export const DEBUG_ALL_TESTS: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.DebugAllAction,
|
||||
label: 'Debug Tests',
|
||||
category: 'Test',
|
||||
iconClass: codicon('debug-all')
|
||||
});
|
||||
|
||||
export const RUN_TEST: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.RunAction,
|
||||
label: 'Run Test',
|
||||
category: 'Test',
|
||||
iconClass: codicon('run')
|
||||
});
|
||||
|
||||
export const RUN_TEST_WITH_PROFILE: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.RunUsingProfileAction,
|
||||
category: 'Test',
|
||||
label: 'Execute Using Profile...'
|
||||
});
|
||||
|
||||
export const DEBUG_TEST: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.DebugAction,
|
||||
label: 'Debug Test',
|
||||
category: 'Test',
|
||||
iconClass: codicon('debug-alt')
|
||||
});
|
||||
|
||||
export const CANCEL_ALL_RUNS: Command = Command.toLocalizedCommand({
|
||||
id: 'testing.cancelAllRuns',
|
||||
label: 'Cancel All Test Runs',
|
||||
category: 'Test',
|
||||
iconClass: codicon('debug-stop')
|
||||
}, 'theia/test/cancelAllTestRuns', nls.getDefaultKey('Test'));
|
||||
|
||||
export const CANCEL_RUN: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.CancelTestRunAction,
|
||||
label: 'Cancel Test Run',
|
||||
category: 'Test',
|
||||
iconClass: codicon('debug-stop')
|
||||
});
|
||||
|
||||
export const GOTO_TEST: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.GoToTest,
|
||||
label: 'Go to Test',
|
||||
category: 'Test',
|
||||
iconClass: codicon('go-to-file')
|
||||
});
|
||||
|
||||
export const CONFIGURE_PROFILES: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.ConfigureTestProfilesAction,
|
||||
label: 'Configure Test Profiles',
|
||||
category: 'Test'
|
||||
});
|
||||
|
||||
export const SELECT_DEFAULT_PROFILES: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.SelectDefaultTestProfiles,
|
||||
label: 'Select Default Profile',
|
||||
category: 'Test'
|
||||
});
|
||||
|
||||
export const CLEAR_ALL_RESULTS: Command = Command.toDefaultLocalizedCommand({
|
||||
id: TestCommandId.ClearTestResultsAction,
|
||||
label: 'Clear All Results',
|
||||
category: 'Test',
|
||||
iconClass: codicon('trash')
|
||||
});
|
||||
}
|
||||
|
||||
export const TEST_VIEW_CONTEXT_MENU = ['test-view-context-menu'];
|
||||
export const TEST_VIEW_INLINE_MENU = [...TEST_VIEW_CONTEXT_MENU, 'inline'];
|
||||
|
||||
export const TEST_VIEW_CONTAINER_ID = 'test-view-container';
|
||||
export const TEST_VIEW_CONTAINER_TITLE_OPTIONS: ViewContainerTitleOptions = {
|
||||
label: nls.localizeByDefault('Testing'),
|
||||
iconClass: codicon('beaker'),
|
||||
closeable: true
|
||||
};
|
||||
|
||||
@injectable()
|
||||
export class TestViewContribution extends AbstractViewContribution<TestTreeWidget> implements
|
||||
FrontendApplicationContribution, TabBarToolbarContribution {
|
||||
|
||||
@inject(TestService) protected readonly testService: TestService;
|
||||
@inject(ContextKeyService) protected readonly contextKeys: ContextKeyService;
|
||||
@inject(NavigationLocationService) navigationService: NavigationLocationService;
|
||||
@inject(FileService) fileSystem: FileService;
|
||||
|
||||
constructor() {
|
||||
super({
|
||||
viewContainerId: TEST_VIEW_CONTAINER_ID,
|
||||
widgetId: TestTreeWidget.ID,
|
||||
widgetName: nls.localizeByDefault('Test Explorer'),
|
||||
defaultWidgetOptions: {
|
||||
area: 'left',
|
||||
rank: 600,
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async initializeLayout(): Promise<void> {
|
||||
await this.openView({ activate: false });
|
||||
}
|
||||
|
||||
override registerCommands(commands: CommandRegistry): void {
|
||||
super.registerCommands(commands);
|
||||
commands.registerCommand(TestViewCommands.REFRESH, {
|
||||
isEnabled: w => this.withWidget(w, () => !this.testService.isRefreshing),
|
||||
isVisible: w => this.withWidget(w, () => !this.testService.isRefreshing),
|
||||
execute: () => this.testService.refresh()
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.CANCEL_REFRESH, {
|
||||
isEnabled: w => this.withWidget(w, () => this.testService.isRefreshing),
|
||||
isVisible: w => this.withWidget(w, () => this.testService.isRefreshing),
|
||||
execute: () => this.testService.cancelRefresh()
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.RUN_ALL_TESTS, {
|
||||
isEnabled: w => this.withWidget(w, () => true),
|
||||
isVisible: w => this.withWidget(w, () => true),
|
||||
execute: () => this.testService.runAllTests(TestRunProfileKind.Run)
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.DEBUG_ALL_TESTS, {
|
||||
isEnabled: w => this.withWidget(w, () => true),
|
||||
isVisible: w => this.withWidget(w, () => true),
|
||||
execute: () => this.testService.runAllTests(TestRunProfileKind.Debug)
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.RUN_TEST, {
|
||||
isEnabled: t => TestItem.is(t),
|
||||
isVisible: t => TestItem.is(t),
|
||||
execute: t => {
|
||||
this.testService.runTests(TestRunProfileKind.Run, [t]);
|
||||
}
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.SELECT_DEFAULT_PROFILES, {
|
||||
isEnabled: t => TestItem.is(t),
|
||||
isVisible: t => TestItem.is(t),
|
||||
execute: () => {
|
||||
this.testService.selectDefaultProfile();
|
||||
}
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.DEBUG_TEST, {
|
||||
isEnabled: t => TestItem.is(t),
|
||||
isVisible: t => TestItem.is(t),
|
||||
execute: t => {
|
||||
this.testService.runTests(TestRunProfileKind.Debug, [t]);
|
||||
}
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.RUN_TEST_WITH_PROFILE, {
|
||||
isEnabled: t => TestItem.is(t),
|
||||
isVisible: t => TestItem.is(t),
|
||||
execute: t => {
|
||||
this.testService.runTestsWithProfile([t]);
|
||||
}
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.CANCEL_ALL_RUNS, {
|
||||
isEnabled: w => this.withWidget(w, () => true),
|
||||
isVisible: w => this.withWidget(w, () => true),
|
||||
execute: () => this.cancelAllRuns()
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.GOTO_TEST, {
|
||||
isEnabled: t => TestItem.is(t) && !!t.uri,
|
||||
isVisible: t => TestItem.is(t) && !!t.uri,
|
||||
execute: t => {
|
||||
if (TestItem.is(t)) {
|
||||
this.fileSystem.resolve(t.uri!).then(stat => {
|
||||
if (stat.isFile) {
|
||||
this.navigationService.reveal(NavigationLocation.create(t.uri!, t.range ? t.range.start : { line: 0, character: 0 }));
|
||||
} else {
|
||||
commands.executeCommand(FileNavigatorCommands.REVEAL_IN_NAVIGATOR.id, t.uri!);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
commands.registerCommand(TestViewCommands.CONFIGURE_PROFILES, {
|
||||
execute: () => {
|
||||
this.testService.configureProfile();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected cancelAllRuns(): void {
|
||||
this.testService.getControllers().forEach(controller => controller.testRuns.forEach(run => run.cancel()));
|
||||
}
|
||||
|
||||
override registerMenus(menus: MenuModelRegistry): void {
|
||||
super.registerMenus(menus);
|
||||
menus.registerMenuAction(TEST_VIEW_INLINE_MENU, {
|
||||
commandId: TestViewCommands.RUN_TEST.id,
|
||||
order: 'a'
|
||||
});
|
||||
menus.registerMenuAction(TEST_VIEW_INLINE_MENU, {
|
||||
commandId: TestViewCommands.DEBUG_TEST.id,
|
||||
order: 'aa'
|
||||
});
|
||||
menus.registerMenuAction(TEST_VIEW_INLINE_MENU, {
|
||||
commandId: TestViewCommands.GOTO_TEST.id,
|
||||
order: 'aaa'
|
||||
});
|
||||
|
||||
menus.registerMenuAction(TEST_VIEW_CONTEXT_MENU, {
|
||||
commandId: TestViewCommands.RUN_TEST_WITH_PROFILE.id,
|
||||
order: 'aaaa'
|
||||
});
|
||||
|
||||
menus.registerMenuAction(TEST_VIEW_CONTEXT_MENU, {
|
||||
commandId: TestViewCommands.SELECT_DEFAULT_PROFILES.id,
|
||||
order: 'aaaaa'
|
||||
});
|
||||
|
||||
menus.registerSubmenu([...PLUGIN_TEST_VIEW_TITLE_MENU, TestViewCommands.RUN_ALL_TESTS.id], '', {
|
||||
contextKeyOverlay: {
|
||||
'testing.profile.context.group': 'run'
|
||||
}
|
||||
});
|
||||
|
||||
menus.registerSubmenu([...PLUGIN_TEST_VIEW_TITLE_MENU, TestViewCommands.DEBUG_ALL_TESTS.id], '', {
|
||||
contextKeyOverlay: {
|
||||
'testing.profile.context.group': 'debug'
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
registerToolbarItems(toolbar: TabBarToolbarRegistry): void {
|
||||
toolbar.registerItem({
|
||||
id: TestViewCommands.REFRESH.id,
|
||||
command: TestViewCommands.REFRESH.id,
|
||||
priority: 0,
|
||||
onDidChange: this.testService.onDidChangeIsRefreshing
|
||||
});
|
||||
|
||||
toolbar.registerItem({
|
||||
id: TestViewCommands.CANCEL_REFRESH.id,
|
||||
command: TestViewCommands.CANCEL_REFRESH.id,
|
||||
priority: 0,
|
||||
onDidChange: this.testService.onDidChangeIsRefreshing
|
||||
});
|
||||
|
||||
toolbar.registerItem({
|
||||
id: TestViewCommands.RUN_ALL_TESTS.id,
|
||||
command: TestViewCommands.RUN_ALL_TESTS.id,
|
||||
menuPath: PLUGIN_TEST_VIEW_TITLE_MENU,
|
||||
priority: 1,
|
||||
isVisible(widget): boolean {
|
||||
return widget instanceof TestTreeWidget && widget.id === TestTreeWidget.ID;
|
||||
}
|
||||
});
|
||||
|
||||
toolbar.registerItem({
|
||||
id: TestViewCommands.DEBUG_ALL_TESTS.id,
|
||||
command: TestViewCommands.DEBUG_ALL_TESTS.id,
|
||||
menuPath: PLUGIN_TEST_VIEW_TITLE_MENU,
|
||||
priority: 2,
|
||||
isVisible(widget): boolean {
|
||||
return widget instanceof TestTreeWidget && widget.id === TestTreeWidget.ID;
|
||||
}
|
||||
});
|
||||
|
||||
toolbar.registerItem({
|
||||
id: TestViewCommands.CANCEL_ALL_RUNS.id,
|
||||
command: TestViewCommands.CANCEL_ALL_RUNS.id,
|
||||
priority: 3
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
protected withWidget<T>(widget: Widget | undefined = this.tryGetWidget(), cb: (widget: TestTreeWidget) => T): T | false {
|
||||
if (widget instanceof TestTreeWidget && widget.id === TestTreeWidget.ID) {
|
||||
return cb(widget);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
136
packages/test/src/browser/view/test-view-frontend-module.ts
Normal file
136
packages/test/src/browser/view/test-view-frontend-module.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
// *****************************************************************************
|
||||
// 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 '../../../src/browser/style/index.css';
|
||||
|
||||
import { interfaces, ContainerModule, Container } from '@theia/core/shared/inversify';
|
||||
import {
|
||||
bindViewContribution, FrontendApplicationContribution,
|
||||
WidgetFactory, ViewContainer,
|
||||
WidgetManager, createTreeContainer
|
||||
} from '@theia/core/lib/browser';
|
||||
import { TestTree, TestTreeWidget } from './test-tree-widget';
|
||||
import { TestViewContribution, TEST_VIEW_CONTAINER_ID, TEST_VIEW_CONTAINER_TITLE_OPTIONS, TEST_VIEW_CONTEXT_MENU } from './test-view-contribution';
|
||||
import { TestService, TestContribution, DefaultTestService } from '../test-service';
|
||||
import { bindContributionProvider } from '@theia/core';
|
||||
import { TabBarToolbarContribution } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
|
||||
import { TestExecutionStateManager } from './test-execution-state-manager';
|
||||
import { TestResultWidget } from './test-result-widget';
|
||||
import { TestOutputWidget } from './test-output-widget';
|
||||
import { TestOutputViewContribution } from './test-output-view-contribution';
|
||||
import { TestOutputUIModel } from './test-output-ui-model';
|
||||
import { TestRunTree, TestRunTreeWidget } from './test-run-widget';
|
||||
import { TestResultViewContribution } from './test-result-view-contribution';
|
||||
import { TEST_RUNS_CONTEXT_MENU, TestRunViewContribution } from './test-run-view-contribution';
|
||||
import { TestContextKeyService } from './test-context-key-service';
|
||||
import { DefaultTestExecutionProgressService, TestExecutionProgressService } from '../test-execution-progress-service';
|
||||
import { bindTestPreferences } from '../../common/test-preferences';
|
||||
|
||||
export default new ContainerModule(bind => {
|
||||
bindTestPreferences(bind);
|
||||
bindContributionProvider(bind, TestContribution);
|
||||
bind(TestContextKeyService).toSelf().inSingletonScope();
|
||||
bind(TestService).to(DefaultTestService).inSingletonScope();
|
||||
|
||||
bind(WidgetFactory).toDynamicValue(({ container }) => ({
|
||||
id: TestOutputWidget.ID,
|
||||
createWidget: () => container.get<TestOutputWidget>(TestOutputWidget)
|
||||
})).inSingletonScope();
|
||||
|
||||
bind(TestOutputWidget).toSelf();
|
||||
|
||||
bind(WidgetFactory).toDynamicValue(({ container }) => ({
|
||||
id: TestResultWidget.ID,
|
||||
createWidget: () => container.get<TestResultWidget>(TestResultWidget)
|
||||
})).inSingletonScope();
|
||||
|
||||
bind(TestResultWidget).toSelf();
|
||||
|
||||
bind(TestTreeWidget).toDynamicValue(({ container }) => {
|
||||
const child = createTestTreeContainer(container);
|
||||
return child.get(TestTreeWidget);
|
||||
});
|
||||
bind(WidgetFactory).toDynamicValue(({ container }) => ({
|
||||
id: TestTreeWidget.ID,
|
||||
createWidget: () => container.get<TestTreeWidget>(TestTreeWidget)
|
||||
})).inSingletonScope();
|
||||
|
||||
bind(TestRunTreeWidget).toDynamicValue(({ container }) => {
|
||||
const child = createTestRunContainer(container);
|
||||
return child.get(TestRunTreeWidget);
|
||||
});
|
||||
bind(WidgetFactory).toDynamicValue(({ container }) => ({
|
||||
id: TestRunTreeWidget.ID,
|
||||
createWidget: () => container.get<TestRunTreeWidget>(TestRunTreeWidget)
|
||||
})).inSingletonScope();
|
||||
|
||||
bind(WidgetFactory).toDynamicValue(({ container }) => ({
|
||||
id: TEST_VIEW_CONTAINER_ID,
|
||||
createWidget: async () => {
|
||||
const viewContainer = container.get<ViewContainer.Factory>(ViewContainer.Factory)({
|
||||
id: TEST_VIEW_CONTAINER_ID,
|
||||
progressLocationId: 'test'
|
||||
});
|
||||
viewContainer.setTitleOptions(TEST_VIEW_CONTAINER_TITLE_OPTIONS);
|
||||
let widget = await container.get(WidgetManager).getOrCreateWidget(TestTreeWidget.ID);
|
||||
viewContainer.addWidget(widget, {
|
||||
canHide: false,
|
||||
initiallyCollapsed: false
|
||||
});
|
||||
widget = await container.get(WidgetManager).getOrCreateWidget(TestRunTreeWidget.ID);
|
||||
viewContainer.addWidget(widget, {
|
||||
canHide: true,
|
||||
initiallyCollapsed: false,
|
||||
}); return viewContainer;
|
||||
}
|
||||
})).inSingletonScope();
|
||||
|
||||
bindViewContribution(bind, TestViewContribution);
|
||||
bindViewContribution(bind, TestRunViewContribution);
|
||||
bindViewContribution(bind, TestResultViewContribution);
|
||||
bindViewContribution(bind, TestOutputViewContribution);
|
||||
bind(FrontendApplicationContribution).toService(TestViewContribution);
|
||||
bind(TabBarToolbarContribution).toService(TestViewContribution);
|
||||
bind(TabBarToolbarContribution).toService(TestRunViewContribution);
|
||||
bind(TestExecutionStateManager).toSelf().inSingletonScope();
|
||||
bind(TestOutputUIModel).toSelf().inSingletonScope();
|
||||
bind(TestExecutionProgressService).to(DefaultTestExecutionProgressService).inSingletonScope();
|
||||
});
|
||||
|
||||
export function createTestTreeContainer(parent: interfaces.Container): Container {
|
||||
return createTreeContainer(parent, {
|
||||
tree: TestTree,
|
||||
props: {
|
||||
virtualized: false,
|
||||
search: true,
|
||||
contextMenuPath: TEST_VIEW_CONTEXT_MENU
|
||||
},
|
||||
widget: TestTreeWidget,
|
||||
});
|
||||
}
|
||||
|
||||
export function createTestRunContainer(parent: interfaces.Container): Container {
|
||||
return createTreeContainer(parent, {
|
||||
tree: TestRunTree,
|
||||
props: {
|
||||
virtualized: false,
|
||||
search: true,
|
||||
multiSelect: false,
|
||||
contextMenuPath: TEST_RUNS_CONTEXT_MENU
|
||||
},
|
||||
widget: TestRunTreeWidget
|
||||
});
|
||||
}
|
||||
223
packages/test/src/common/collections.ts
Normal file
223
packages/test/src/common/collections.ts
Normal file
@@ -0,0 +1,223 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2022 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 { Event } from '@theia/core';
|
||||
import { CollectionDelta, TreeDeltaBuilder } from './tree-delta';
|
||||
import { Emitter } from '@theia/core/shared/vscode-languageserver-protocol';
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export function observableProperty(observationFunction: string): (target: any, property: string) => any {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return (target: any, property: string): any => {
|
||||
Reflect.defineProperty(target, property, {
|
||||
// @ts-ignore
|
||||
get(): unknown { return this['_' + property]; },
|
||||
set(v: unknown): void {
|
||||
// @ts-ignore
|
||||
this[observationFunction](property, v);
|
||||
// @ts-ignore
|
||||
this['_' + property] = v;
|
||||
}
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
export class ChangeBatcher {
|
||||
private handle: NodeJS.Timeout | undefined;
|
||||
private startTime: number | undefined;
|
||||
constructor(private emitBatch: () => void, readonly timeoutMs: number) {
|
||||
}
|
||||
|
||||
changeOccurred(): void {
|
||||
if (!this.startTime) {
|
||||
this.startTime = Date.now();
|
||||
this.handle = setTimeout(() => {
|
||||
this.flush();
|
||||
}, this.timeoutMs);
|
||||
} else {
|
||||
if (Date.now() - this.startTime > this.timeoutMs) {
|
||||
this.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
flush(): void {
|
||||
if (this.handle) {
|
||||
clearTimeout(this.handle);
|
||||
this.handle = undefined;
|
||||
}
|
||||
this.startTime = undefined;
|
||||
this.emitBatch();
|
||||
}
|
||||
}
|
||||
|
||||
export class SimpleObservableCollection<V> {
|
||||
private _values: V[] = [];
|
||||
|
||||
constructor(private equals: (left: V, right: V) => boolean = (left, right) => left === right) {
|
||||
}
|
||||
|
||||
add(value: V): boolean {
|
||||
if (!this._values.find(v => this.equals(v, value))) {
|
||||
this._values.push(value);
|
||||
this.onChangeEmitter.fire({ added: [value] });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
remove(value: V): boolean {
|
||||
const index = this._values.findIndex(v => this.equals(v, value));
|
||||
if (index >= 0) {
|
||||
this._values.splice(index, 1);
|
||||
this.onChangeEmitter.fire({ removed: [value] });
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private onChangeEmitter = new Emitter<CollectionDelta<V, V>>();
|
||||
onChanged: Event<CollectionDelta<V, V>> = this.onChangeEmitter.event;
|
||||
get values(): readonly V[] {
|
||||
return this._values;
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
const copy = this._values;
|
||||
this._values = [];
|
||||
this.onChangeEmitter.fire({ removed: copy });
|
||||
}
|
||||
}
|
||||
|
||||
abstract class AbstractIndexedCollection<K, T> {
|
||||
private keys: Map<K, T> = new Map();
|
||||
private _values: T[] | undefined;
|
||||
|
||||
abstract add(item: T): T | undefined;
|
||||
|
||||
get values(): readonly T[] {
|
||||
if (!this._values) {
|
||||
this._values = [...this.keys.values()];
|
||||
}
|
||||
return this._values;
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.keys.size;
|
||||
}
|
||||
|
||||
has(key: K): boolean {
|
||||
return this.keys.has(key);
|
||||
}
|
||||
|
||||
get(key: K): T | undefined {
|
||||
return this.keys.get(key);
|
||||
}
|
||||
|
||||
protected doAdd(key: K, value: T): T | undefined {
|
||||
const previous = this.keys.get(key);
|
||||
if (previous !== undefined) {
|
||||
return previous;
|
||||
} else {
|
||||
this.keys.set(key, value);
|
||||
this._values = undefined;
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
remove(key: K): T | undefined {
|
||||
const previous = this.keys.get(key);
|
||||
if (previous !== undefined) {
|
||||
this.keys.delete(key);
|
||||
this._values = undefined;
|
||||
return previous;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export class TreeCollection<K, T, P> extends AbstractIndexedCollection<K, T> implements Iterable<[K, T]> {
|
||||
|
||||
constructor(protected readonly owner: T | P,
|
||||
protected readonly pathOf: (v: T) => K[],
|
||||
protected readonly deltaBuilder: (v: T | undefined) => TreeDeltaBuilder<K, T> | undefined) {
|
||||
super();
|
||||
}
|
||||
|
||||
add(item: T): T | undefined {
|
||||
const path = this.pathOf(item);
|
||||
const previous = this.doAdd(path[path.length - 1], item);
|
||||
const deltaBuilder = this.deltaBuilder(item);
|
||||
if (deltaBuilder) {
|
||||
if (previous) {
|
||||
deltaBuilder.reportChanged(path, item);
|
||||
} else {
|
||||
deltaBuilder.reportAdded(path, item);
|
||||
}
|
||||
}
|
||||
return previous;
|
||||
}
|
||||
|
||||
override remove(key: K): T | undefined {
|
||||
const toRemove = this.get(key);
|
||||
if (toRemove) {
|
||||
const deltaBuilder = this.deltaBuilder(toRemove);
|
||||
const path = this.pathOf(toRemove);
|
||||
super.remove(key);
|
||||
if (deltaBuilder) {
|
||||
deltaBuilder.reportRemoved(path);
|
||||
}
|
||||
}
|
||||
return toRemove;
|
||||
}
|
||||
|
||||
entries(): Iterator<[K, T], unknown, undefined> {
|
||||
return this[Symbol.iterator]();
|
||||
}
|
||||
|
||||
[Symbol.iterator](): Iterator<[K, T], unknown, undefined> {
|
||||
const iter = this.values.entries();
|
||||
const that = this;
|
||||
return {
|
||||
next(..._args): IteratorResult<[K, T]> {
|
||||
const res = iter.next();
|
||||
if (res.done) {
|
||||
return { done: true, value: res.value };
|
||||
} else {
|
||||
const path = that.pathOf(res.value[1]);
|
||||
const result: [K, T] = [path[path.length - 1], res.value[1]];
|
||||
return {
|
||||
done: false,
|
||||
value: result
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
}
|
||||
}
|
||||
export function groupBy<K, T>(items: Iterable<T>, keyOf: (item: T) => K): Map<K, T[]> {
|
||||
const result = new Map<K, T[]>();
|
||||
for (const item of items) {
|
||||
const key = keyOf(item);
|
||||
let values = result.get(key);
|
||||
if (!values) {
|
||||
values = [];
|
||||
result.set(key, values);
|
||||
}
|
||||
values.push(item);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
60
packages/test/src/common/test-preferences.ts
Normal file
60
packages/test/src/common/test-preferences.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2024 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 { interfaces } from '@theia/core/shared/inversify';
|
||||
import { createPreferenceProxy, PreferenceProxy } from '@theia/core/lib/common/preferences/preference-proxy';
|
||||
import { PreferenceScope } from '@theia/core/lib/common/preferences/preference-scope';
|
||||
import { nls } from '@theia/core/lib/common/nls';
|
||||
import { PreferenceContribution, PreferenceSchema } from '@theia/core/lib/common/preferences/preference-schema';
|
||||
import { PreferenceService } from '@theia/core';
|
||||
|
||||
export const TestConfigSchema: PreferenceSchema = {
|
||||
properties: {
|
||||
'testing.openTesting': {
|
||||
type: 'string',
|
||||
enum: ['neverOpen', 'openOnTestStart'],
|
||||
enumDescriptions: [
|
||||
nls.localizeByDefault('Never automatically open the testing views'),
|
||||
nls.localizeByDefault('Open the test results view when tests start'),
|
||||
],
|
||||
description: nls.localizeByDefault('Controls when the testing view should open.'),
|
||||
default: 'neverOpen',
|
||||
scope: PreferenceScope.Folder,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface TestConfiguration {
|
||||
'testing.openTesting': 'neverOpen' | 'openOnTestStart';
|
||||
}
|
||||
|
||||
export const TestPreferenceContribution = Symbol('TestPreferenceContribution');
|
||||
export const TestPreferences = Symbol('TestPreferences');
|
||||
export type TestPreferences = PreferenceProxy<TestConfiguration>;
|
||||
|
||||
export function createTestPreferences(preferences: PreferenceService, schema: PreferenceSchema = TestConfigSchema): TestPreferences {
|
||||
return createPreferenceProxy(preferences, schema);
|
||||
}
|
||||
|
||||
export const bindTestPreferences = (bind: interfaces.Bind): void => {
|
||||
bind(TestPreferences).toDynamicValue(ctx => {
|
||||
const preferences = ctx.container.get<PreferenceService>(PreferenceService);
|
||||
const contribution = ctx.container.get<PreferenceContribution>(TestPreferenceContribution);
|
||||
return createTestPreferences(preferences, contribution.schema);
|
||||
}).inSingletonScope();
|
||||
bind(TestPreferenceContribution).toConstantValue({ schema: TestConfigSchema });
|
||||
bind(PreferenceContribution).toService(TestPreferenceContribution);
|
||||
};
|
||||
166
packages/test/src/common/tree-delta.spec.ts
Normal file
166
packages/test/src/common/tree-delta.spec.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2022 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 { DeltaKind, TreeDelta, TreeDeltaBuilderImpl } from './tree-delta';
|
||||
import * as chai from 'chai';
|
||||
|
||||
const expect = chai.expect;
|
||||
|
||||
interface TestType {
|
||||
id: string;
|
||||
prop: number;
|
||||
}
|
||||
|
||||
describe('TreeDeltaBuilder tests', () => {
|
||||
|
||||
it('should split paths', () => {
|
||||
const builder = new TreeDeltaBuilderImpl<string, TestType>();
|
||||
|
||||
builder.reportAdded(['a', 'b', 'c'], {
|
||||
id: 'c',
|
||||
prop: 17
|
||||
});
|
||||
|
||||
builder.reportRemoved(['a', 'b', 'd', 'e']);
|
||||
|
||||
const expected: TreeDelta<string, TestType> = {
|
||||
path: ['a', 'b'],
|
||||
type: DeltaKind.NONE,
|
||||
childDeltas: [
|
||||
{
|
||||
path: ['d', 'e'],
|
||||
type: DeltaKind.REMOVED,
|
||||
},
|
||||
{
|
||||
type: DeltaKind.ADDED,
|
||||
path: ['c'],
|
||||
value: { id: 'c', prop: 17 },
|
||||
},
|
||||
]
|
||||
};
|
||||
|
||||
expect(builder.currentDelta).deep.equal([
|
||||
expected
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge add/remove child', () => {
|
||||
const builder = new TreeDeltaBuilderImpl<string, TestType>();
|
||||
|
||||
builder.reportAdded(['a', 'b', 'c'], {
|
||||
id: 'c',
|
||||
prop: 17
|
||||
});
|
||||
|
||||
builder.reportRemoved(['a', 'b', 'c', 'd']);
|
||||
|
||||
expect(builder.currentDelta).deep.equal([{
|
||||
path: ['a', 'b', 'c'],
|
||||
type: DeltaKind.ADDED,
|
||||
value: { id: 'c', prop: 17 },
|
||||
}]);
|
||||
});
|
||||
|
||||
it('should merge change', () => {
|
||||
const builder = new TreeDeltaBuilderImpl<string, TestType>();
|
||||
|
||||
builder.reportChanged(['a', 'b', 'c'], { id: 'c', prop: 17 });
|
||||
builder.reportChanged(['a', 'b', 'c'], { prop: 18 });
|
||||
builder.reportChanged(['a', 'b', 'c'], { prop: 19 });
|
||||
|
||||
expect(builder.currentDelta).deep.equal([
|
||||
{
|
||||
type: DeltaKind.CHANGED,
|
||||
path: ['a', 'b', 'c'],
|
||||
value: { id: 'c', prop: 19 },
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should merge add/change', () => {
|
||||
const builder = new TreeDeltaBuilderImpl<string, TestType>();
|
||||
|
||||
const obj = {
|
||||
id: 'c',
|
||||
prop: 17
|
||||
};
|
||||
|
||||
builder.reportAdded(['a', 'b', 'c'], obj);
|
||||
|
||||
obj.prop = 18;
|
||||
|
||||
builder.reportChanged(['a', 'b', 'c'], { prop: 18 });
|
||||
|
||||
expect(builder.currentDelta).deep.equal([
|
||||
{
|
||||
type: DeltaKind.ADDED,
|
||||
path: ['a', 'b', 'c'],
|
||||
value: { id: 'c', prop: 18 },
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle adds/delete', () => {
|
||||
const builder = new TreeDeltaBuilderImpl<string, TestType>();
|
||||
builder.reportAdded(['a', 'b'], { id: 'c', prop: 14 });
|
||||
builder.reportRemoved(['a', 'b']);
|
||||
expect(builder.currentDelta).deep.equal([]);
|
||||
builder.reportAdded(['a', 'b'], { id: 'c', prop: 20 });
|
||||
expect(builder.currentDelta).deep.equal([{
|
||||
path: ['a', 'b'],
|
||||
type: DeltaKind.ADDED,
|
||||
value: { id: 'c', prop: 20 }
|
||||
}]);
|
||||
});
|
||||
|
||||
it('should handle delete/add', () => {
|
||||
const builder = new TreeDeltaBuilderImpl<string, TestType>();
|
||||
builder.reportRemoved(['a', 'b']);
|
||||
builder.reportAdded(['a', 'b'], { id: 'c', prop: 14 });
|
||||
expect(builder.currentDelta).deep.equal([{
|
||||
path: ['a', 'b'],
|
||||
type: DeltaKind.CHANGED,
|
||||
value: { id: 'c', prop: 14 }
|
||||
}]);
|
||||
builder.reportRemoved(['a', 'b']);
|
||||
expect(builder.currentDelta).deep.equal([{
|
||||
path: ['a', 'b'],
|
||||
type: DeltaKind.REMOVED,
|
||||
}]);
|
||||
});
|
||||
|
||||
it('should handle changed below changed', () => {
|
||||
const builder = new TreeDeltaBuilderImpl<string, TestType>();
|
||||
builder.reportChanged(['a', 'b', 'c', 'e'], { id: 'e', prop: 14 });
|
||||
builder.reportChanged(['a', 'b', 'c', 'd'], { id: 'd', prop: 23 });
|
||||
builder.reportChanged(['a', 'b', 'c'], { id: 'c', prop: 27 });
|
||||
expect(builder.currentDelta).deep.equal([{
|
||||
path: ['a', 'b', 'c'],
|
||||
type: DeltaKind.CHANGED,
|
||||
value: { id: 'c', prop: 27 },
|
||||
childDeltas: [
|
||||
{
|
||||
path: ['d'],
|
||||
type: DeltaKind.CHANGED,
|
||||
value: { id: 'd', prop: 23 }
|
||||
}, {
|
||||
path: ['e'],
|
||||
type: DeltaKind.CHANGED,
|
||||
value: { id: 'e', prop: 14 }
|
||||
}]
|
||||
}]);
|
||||
});
|
||||
|
||||
});
|
||||
259
packages/test/src/common/tree-delta.ts
Normal file
259
packages/test/src/common/tree-delta.ts
Normal file
@@ -0,0 +1,259 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2022 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 { Emitter, Event } from '@theia/core';
|
||||
import { ChangeBatcher } from './collections';
|
||||
|
||||
export interface CollectionDelta<K, T> {
|
||||
added?: T[];
|
||||
removed?: K[];
|
||||
}
|
||||
|
||||
export enum DeltaKind {
|
||||
NONE, ADDED, REMOVED, CHANGED
|
||||
}
|
||||
|
||||
export interface TreeDelta<K, T> {
|
||||
path: K[];
|
||||
type: DeltaKind;
|
||||
value?: Partial<T>;
|
||||
childDeltas?: TreeDelta<K, T>[];
|
||||
}
|
||||
|
||||
export interface TreeDeltaBuilder<K, T> {
|
||||
reportAdded(path: K[], added: T): void;
|
||||
reportRemoved(path: K[]): void;
|
||||
reportChanged(path: K[], change: Partial<T>): void;
|
||||
}
|
||||
|
||||
export class MappingTreeDeltaBuilder<K, T, V> implements TreeDeltaBuilder<K, V> {
|
||||
constructor(private readonly wrapped: TreeDeltaBuilder<K, T>, private readonly map: (value: V) => T, private readonly mapPartial: (value: Partial<V>) => Partial<T>) { }
|
||||
reportAdded(path: K[], added: V): void {
|
||||
this.wrapped.reportAdded(path, this.map(added));
|
||||
}
|
||||
reportRemoved(path: K[]): void {
|
||||
this.wrapped.reportRemoved(path);
|
||||
}
|
||||
reportChanged(path: K[], change: Partial<V>): void {
|
||||
this.wrapped.reportChanged(path, this.mapPartial(change));
|
||||
}
|
||||
}
|
||||
|
||||
export class TreeDeltaBuilderImpl<K, T> {
|
||||
protected _currentDelta: TreeDelta<K, T>[] = [];
|
||||
|
||||
get currentDelta(): TreeDelta<K, T>[] {
|
||||
return this._currentDelta;
|
||||
}
|
||||
|
||||
reportAdded(path: K[], added: T): void {
|
||||
this.findNode(path, (parentCollection, nodeIndex, residual) => {
|
||||
if (residual.length === 0) {
|
||||
// we matched an exact node
|
||||
const child = parentCollection[nodeIndex];
|
||||
if (child.type === DeltaKind.REMOVED) {
|
||||
child.type = DeltaKind.CHANGED;
|
||||
} else if (child.type === DeltaKind.NONE) {
|
||||
child.type = DeltaKind.ADDED;
|
||||
}
|
||||
child.value = added;
|
||||
} else {
|
||||
this.insert(parentCollection, nodeIndex, {
|
||||
path: residual,
|
||||
type: DeltaKind.ADDED,
|
||||
value: added,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
reportRemoved(path: K[]): void {
|
||||
this.findNode(path, (parentCollection, nodeIndex, residual) => {
|
||||
if (residual.length === 0) {
|
||||
// we matched an exact node
|
||||
const child = parentCollection[nodeIndex];
|
||||
if (child.type === DeltaKind.CHANGED) {
|
||||
child.type = DeltaKind.REMOVED;
|
||||
delete child.value;
|
||||
} else if (child.type === DeltaKind.ADDED) {
|
||||
parentCollection.splice(nodeIndex, 1);
|
||||
} else if (child.type === DeltaKind.NONE) {
|
||||
child.type = DeltaKind.REMOVED;
|
||||
}
|
||||
} else {
|
||||
this.insert(parentCollection, nodeIndex, {
|
||||
path: residual,
|
||||
type: DeltaKind.REMOVED,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
reportChanged(path: K[], change: Partial<T>): void {
|
||||
this.findNode(path, (parentCollection, nodeIndex, residual) => {
|
||||
if (residual.length === 0) {
|
||||
// we matched an exact node
|
||||
const child = parentCollection[nodeIndex];
|
||||
if (child.type === DeltaKind.NONE) {
|
||||
child.type = DeltaKind.CHANGED;
|
||||
child.value = change;
|
||||
} else if (child.type === DeltaKind.CHANGED) {
|
||||
Object.assign(child.value!, change);
|
||||
}
|
||||
} else {
|
||||
this.insert(parentCollection, nodeIndex, {
|
||||
path: residual,
|
||||
type: DeltaKind.CHANGED,
|
||||
value: change,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
private insert(parentCollection: TreeDelta<K, T>[], nodeIndex: number, delta: TreeDelta<K, T>): void {
|
||||
if (nodeIndex < 0) {
|
||||
parentCollection.push(delta);
|
||||
} else {
|
||||
const child = parentCollection[nodeIndex];
|
||||
const prefixLength = computePrefixLength(delta.path, child.path);
|
||||
|
||||
if (prefixLength === delta.path.length) {
|
||||
child.path = child.path.slice(prefixLength);
|
||||
delta.childDeltas = [child];
|
||||
parentCollection[nodeIndex] = delta;
|
||||
} else {
|
||||
const newNode: TreeDelta<K, T> = {
|
||||
path: child.path.slice(0, prefixLength),
|
||||
type: DeltaKind.NONE,
|
||||
childDeltas: []
|
||||
};
|
||||
parentCollection[nodeIndex] = newNode;
|
||||
delta.path = delta.path.slice(prefixLength);
|
||||
newNode.childDeltas!.push(delta);
|
||||
child.path = child.path.slice(prefixLength);
|
||||
newNode.childDeltas!.push(child);
|
||||
if (newNode.path.length === 0) {
|
||||
console.log('newNode');
|
||||
}
|
||||
}
|
||||
if (delta.path.length === 0) {
|
||||
console.log('delta');
|
||||
}
|
||||
|
||||
if (child.path.length === 0) {
|
||||
console.log('child');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private findNode(path: K[], handler: (parentCollection: TreeDelta<K, T>[], nodeIndex: number, residualPath: K[]) => void): void {
|
||||
doFindNode(this._currentDelta, path, handler);
|
||||
}
|
||||
}
|
||||
|
||||
function doFindNode<K, T>(rootCollection: TreeDelta<K, T>[], path: K[],
|
||||
handler: (parentCollection: TreeDelta<K, T>[], nodeIndex: number, residualPath: K[]) => void): void {
|
||||
// handler parameters:
|
||||
// parent collection: the collection the node index refers to, if valid
|
||||
// nodeIndex: the index of the node that has a common path prefix with the path of the path we're searching
|
||||
// residual path: the path that has not been consumed looking for the path: if empty, we found the exact node
|
||||
|
||||
let commonPrefixLength = 0;
|
||||
const childIndex = rootCollection.findIndex(delta => {
|
||||
commonPrefixLength = computePrefixLength(delta.path, path);
|
||||
return commonPrefixLength > 0;
|
||||
});
|
||||
if (childIndex >= 0) {
|
||||
// we know which child to insert into
|
||||
const child = rootCollection[childIndex];
|
||||
|
||||
if (commonPrefixLength === child.path.length) {
|
||||
// we matched a child
|
||||
if (commonPrefixLength === path.length) {
|
||||
// it's an exact match: we already have a node for the given path
|
||||
handler(rootCollection, childIndex, []);
|
||||
return;
|
||||
}
|
||||
// we know the node is below the child
|
||||
if (child.type === DeltaKind.REMOVED || child.type === DeltaKind.ADDED) {
|
||||
// there will be no children deltas beneath added/remove nodes
|
||||
return;
|
||||
}
|
||||
if (!child.childDeltas) {
|
||||
child.childDeltas = [];
|
||||
}
|
||||
doFindNode(child.childDeltas, path.slice(child.path.length), handler);
|
||||
} else {
|
||||
handler(rootCollection, childIndex, path);
|
||||
}
|
||||
} else {
|
||||
// we have no node to insert into
|
||||
handler(rootCollection, -1, path);
|
||||
}
|
||||
}
|
||||
|
||||
function computePrefixLength<K>(left: K[], right: K[]): number {
|
||||
let i = 0;
|
||||
while (i < left.length && i < right.length && left[i] === right[i]) {
|
||||
i++;
|
||||
}
|
||||
|
||||
return i;
|
||||
}
|
||||
|
||||
export class AccumulatingTreeDeltaEmitter<K, T> extends TreeDeltaBuilderImpl<K, T> {
|
||||
private batcher: ChangeBatcher;
|
||||
private onDidFlushEmitter: Emitter<TreeDelta<K, T>[]> = new Emitter();
|
||||
onDidFlush: Event<TreeDelta<K, T>[]> = this.onDidFlushEmitter.event;
|
||||
|
||||
constructor(timeoutMillis: number) {
|
||||
super();
|
||||
this.batcher = new ChangeBatcher(() => this.doEmitDelta(), timeoutMillis);
|
||||
}
|
||||
|
||||
flush(): void {
|
||||
this.batcher.flush();
|
||||
}
|
||||
|
||||
doEmitDelta(): void {
|
||||
const batch = this._currentDelta;
|
||||
this._currentDelta = [];
|
||||
this.onDidFlushEmitter.fire(batch);
|
||||
}
|
||||
|
||||
override reportAdded(path: K[], added: T): void {
|
||||
super.reportAdded(path, added);
|
||||
// console.debug(`reported added, now: ${JSON.stringify(path, undefined, 3)}`);
|
||||
// logging levels don't work in plugin host: https://github.com/eclipse-theia/theia/issues/12234
|
||||
this.batcher.changeOccurred();
|
||||
}
|
||||
|
||||
override reportChanged(path: K[], change: Partial<T>): void {
|
||||
super.reportChanged(path, change);
|
||||
// console.debug(`reported changed, now: ${JSON.stringify(path, undefined, 3)}`);
|
||||
// logging levels don't work in plugin host: https://github.com/eclipse-theia/theia/issues/12234
|
||||
this.batcher.changeOccurred();
|
||||
}
|
||||
|
||||
override reportRemoved(path: K[]): void {
|
||||
super.reportRemoved(path);
|
||||
// console.debug(`reported removed, now: ${JSON.stringify(path, undefined, 3)}`);
|
||||
// logging levels don't work in plugin host: https://github.com/eclipse-theia/theia/issues/12234
|
||||
this.batcher.changeOccurred();
|
||||
}
|
||||
}
|
||||
22
packages/test/src/node/test-backend-module.ts
Normal file
22
packages/test/src/node/test-backend-module.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
// *****************************************************************************
|
||||
// Copyright (C) 2025 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 { ContainerModule } from '@theia/core/shared/inversify';
|
||||
import { bindTestPreferences } from '../common/test-preferences';
|
||||
|
||||
// *****************************************************************************
|
||||
export default new ContainerModule(bind => {
|
||||
bindTestPreferences(bind);
|
||||
});
|
||||
28
packages/test/tsconfig.json
Normal file
28
packages/test/tsconfig.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"extends": "../../configs/base.tsconfig",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"rootDir": "src",
|
||||
"outDir": "lib"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"references": [
|
||||
{
|
||||
"path": "../core"
|
||||
},
|
||||
{
|
||||
"path": "../editor"
|
||||
},
|
||||
{
|
||||
"path": "../filesystem"
|
||||
},
|
||||
{
|
||||
"path": "../navigator"
|
||||
},
|
||||
{
|
||||
"path": "../terminal"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user