diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8d8ff1b8e4171..f834dd8885b86 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@
- [plugin] move stubbed API TerminalShellIntegration into main API [#14168](https://github.com/eclipse-theia/theia/pull/14168) - Contributed on behalf of STMicroelectronics
- [plugin] support evolution on proposed API extensionAny [#14199](https://github.com/eclipse-theia/theia/pull/14199) - Contributed on behalf of STMicroelectronics
+- [test] support TestMessage stack traces [#14154](https://github.com/eclipse-theia/theia/pull/14154) - Contributed on behalf of STMicroelectronics
[Breaking Changes:](#breaking_changes_1.54.0) -->
- [core] Updated AuthenticationService to handle multiple accounts per provider [#14149](https://github.com/eclipse-theia/theia/pull/14149) - Contributed on behalf of STMicroelectronics
diff --git a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts
index 7a4ecdae2f2b4..de2450bff21bb 100644
--- a/packages/plugin-ext/src/common/plugin-api-rpc-model.ts
+++ b/packages/plugin-ext/src/common/plugin-api-rpc-model.ts
@@ -72,6 +72,17 @@ export interface Range {
readonly endColumn: number;
}
+export interface Position {
+ /**
+ * line number (starts at 1)
+ */
+ readonly lineNumber: number,
+ /**
+ * column (starts at 1)
+ */
+ readonly column: number
+}
+
export { MarkdownStringDTO as MarkdownString };
export interface SerializedDocumentFilter {
diff --git a/packages/plugin-ext/src/common/test-types.ts b/packages/plugin-ext/src/common/test-types.ts
index af87290c87672..56c2ec4015262 100644
--- a/packages/plugin-ext/src/common/test-types.ts
+++ b/packages/plugin-ext/src/common/test-types.ts
@@ -27,6 +27,7 @@ import { MarkdownString } from '@theia/core/lib/common/markdown-rendering';
import { UriComponents } from './uri-components';
import { Location, Range } from './plugin-api-rpc-model';
import { isObject } from '@theia/core';
+import * as languageProtocol from '@theia/core/shared/vscode-languageserver-protocol';
export enum TestRunProfileKind {
Run = 1,
@@ -74,17 +75,30 @@ export interface TestFailureDTO extends TestStateChangeDTO {
readonly duration?: number;
}
+export namespace TestFailureDTO {
+ export function is(ref: unknown): ref is TestFailureDTO {
+ return isObject(ref)
+ && (ref.state === TestExecutionState.Failed || ref.state === TestExecutionState.Errored);
+ }
+}
export interface TestSuccessDTO extends TestStateChangeDTO {
readonly state: TestExecutionState.Passed;
readonly duration?: number;
}
+export interface TestMessageStackFrameDTO {
+ uri?: languageProtocol.DocumentUri;
+ position?: languageProtocol.Position;
+ label: string;
+}
+
export interface TestMessageDTO {
readonly expected?: string;
readonly actual?: string;
- readonly location?: Location;
+ readonly location?: languageProtocol.Location;
readonly message: string | MarkdownString;
readonly contextValue?: string;
+ readonly stackTrace?: TestMessageStackFrameDTO[];
}
export interface TestItemDTO {
diff --git a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts
index 9e79ae892cd96..2f1dc3c7e49ab 100644
--- a/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts
+++ b/packages/plugin-ext/src/main/browser/menus/plugin-menu-command-adapter.ts
@@ -32,7 +32,6 @@ import { TreeViewWidget } from '../view/tree-view-widget';
import { CodeEditorWidgetUtil, codeToTheiaMappings, ContributionPoint } from './vscode-theia-menu-mappings';
import { TAB_BAR_TOOLBAR_CONTEXT_MENU } from '@theia/core/lib/browser/shell/tab-bar-toolbar';
import { TestItem, TestMessage } from '@theia/test/lib/browser/test-service';
-import { fromLocation } from '../hierarchy/hierarchy-types-converters';
export type ArgumentAdapter = (...args: unknown[]) => unknown[];
@@ -315,7 +314,8 @@ export class PluginMenuCommandAdapter implements MenuCommandAdapter {
actual: testMessage.actual,
expected: testMessage.expected,
contextValue: testMessage.contextValue,
- location: testMessage.location ? fromLocation(testMessage.location) : undefined
+ location: testMessage.location,
+ stackTrace: testMessage.stackTrace
};
return [TestMessageArg.create(testItemReference, testMessageDTO)];
}
diff --git a/packages/plugin-ext/src/main/browser/test-main.ts b/packages/plugin-ext/src/main/browser/test-main.ts
index cd50fd4813ddf..3662373142758 100644
--- a/packages/plugin-ext/src/main/browser/test-main.ts
+++ b/packages/plugin-ext/src/main/browser/test-main.ts
@@ -27,7 +27,10 @@ import { CancellationToken, Disposable, Event, URI } from '@theia/core';
import { MAIN_RPC_CONTEXT, TestControllerUpdate, TestingExt, TestingMain } from '../../common';
import { RPCProtocol } from '../../common/rpc-protocol';
import { interfaces } from '@theia/core/shared/inversify';
-import { TestExecutionState, TestItemDTO, TestItemReference, TestOutputDTO, TestRunDTO, TestRunProfileDTO, TestStateChangeDTO } from '../../common/test-types';
+import {
+ TestExecutionState, TestItemDTO, TestItemReference, TestOutputDTO,
+ TestRunDTO, TestRunProfileDTO, TestStateChangeDTO
+} from '../../common/test-types';
import { TestRunProfileKind } from '../../plugin/types-impl';
import { CommandRegistryMainImpl } from './command-registry-main';
diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts
index bb19d777fb058..f32102a2aa1bf 100644
--- a/packages/plugin-ext/src/plugin/plugin-context.ts
+++ b/packages/plugin-ext/src/plugin/plugin-context.ts
@@ -187,6 +187,7 @@ import {
TestTag,
TestRunRequest,
TestMessage,
+ TestMessageStackFrame,
ExtensionKind,
InlineCompletionItem,
InlineCompletionList,
@@ -1463,6 +1464,7 @@ export function createAPIFactory(
TestTag,
TestRunRequest,
TestMessage,
+ TestMessageStackFrame,
ExtensionKind,
InlineCompletionItem,
InlineCompletionList,
diff --git a/packages/plugin-ext/src/plugin/tests.ts b/packages/plugin-ext/src/plugin/tests.ts
index 101532e86b2ed..514bcbbe4fabc 100644
--- a/packages/plugin-ext/src/plugin/tests.ts
+++ b/packages/plugin-ext/src/plugin/tests.ts
@@ -40,10 +40,12 @@ import { TestItemImpl, TestItemCollection } from './test-item';
import { AccumulatingTreeDeltaEmitter, TreeDelta } from '@theia/test/lib/common/tree-delta';
import {
TestItemDTO, TestOutputDTO, TestExecutionState, TestRunProfileDTO,
- TestRunProfileKind, TestRunRequestDTO, TestStateChangeDTO, TestItemReference, TestMessageArg, TestMessageDTO
+ TestRunProfileKind, TestRunRequestDTO, TestStateChangeDTO, TestItemReference, TestMessageArg, TestMessageDTO,
+ TestMessageStackFrameDTO
} from '../common/test-types';
+import * as protocol from '@theia/core/shared/vscode-languageserver-protocol';
import { ChangeBatcher, observableProperty } from '@theia/test/lib/common/collections';
-import { TestRunRequest } from './types-impl';
+import { Location, Position, Range, TestRunRequest, URI } from './types-impl';
import { MarkdownString } from '../common/plugin-api-rpc-model';
type RefreshHandler = (token: theia.CancellationToken) => void | theia.Thenable;
@@ -374,7 +376,36 @@ export class TestingExtImpl implements TestingExt {
actualOutput: testMessage.actual,
expectedOutput: testMessage.expected,
contextValue: testMessage.contextValue,
- location: testMessage.location ? Convert.toLocation(testMessage.location) : undefined
+ location: this.toLocation(testMessage.location),
+ stackTrace: testMessage.stackTrace ? testMessage.stackTrace.map(frame => this.toStackFrame(frame)) : undefined
+ };
+ }
+
+ toLocation(location: protocol.Location | undefined): Location | undefined {
+ if (!location) {
+ return undefined;
+ }
+ return new Location(URI.parse(location.uri), this.toRange(location.range));
+ }
+
+ toRange(range: protocol.Range): Range {
+ return new Range(this.toPosition(range.start), this.toPosition(range.end));
+ }
+
+ toPosition(position: protocol.Position): Position;
+ toPosition(position: protocol.Position | undefined): Position | undefined;
+ toPosition(position: protocol.Position | undefined): Position | undefined {
+ if (!position) {
+ return undefined;
+ }
+ return new Position(position.line, position.character);
+ }
+
+ toStackFrame(stackFrame: TestMessageStackFrameDTO): theia.TestMessageStackFrame {
+ return {
+ label: stackFrame.label,
+ position: this.toPosition(stackFrame.position),
+ uri: stackFrame.uri ? URI.parse(stackFrame.uri) : undefined
};
}
diff --git a/packages/plugin-ext/src/plugin/type-converters.ts b/packages/plugin-ext/src/plugin/type-converters.ts
index 7419d07e9da07..c25ac586a7b78 100644
--- a/packages/plugin-ext/src/plugin/type-converters.ts
+++ b/packages/plugin-ext/src/plugin/type-converters.ts
@@ -34,7 +34,7 @@ import { BinaryBuffer } from '@theia/core/lib/common/buffer';
import { CellRange, isTextStreamMime } from '@theia/notebook/lib/common';
import { MarkdownString as MarkdownStringDTO } from '@theia/core/lib/common/markdown-rendering';
-import { TestItemDTO, TestMessageDTO } from '../common/test-types';
+import { TestItemDTO, TestMessageDTO, TestMessageStackFrameDTO } from '../common/test-types';
import { PluginIconPath } from './plugin-icon-path';
const SIDE_GROUP = -2;
@@ -134,12 +134,21 @@ export function fromRange(range: theia.Range | undefined): model.Range | undefin
endColumn: end.character + 1
};
}
-
-export function fromPosition(position: types.Position | theia.Position): Position {
+export function fromPosition(position: types.Position | theia.Position): Position;
+export function fromPosition(position: types.Position | theia.Position | undefined): Position | undefined;
+export function fromPosition(position: types.Position | theia.Position | undefined): Position | undefined {
+ if (!position) {
+ return undefined;
+ }
return { lineNumber: position.line + 1, column: position.character + 1 };
}
-export function toPosition(position: Position): types.Position {
+export function toPosition(position: Position): types.Position;
+export function toPosition(position: Position | undefined): types.Position | undefined;
+export function toPosition(position: Position | undefined): types.Position | undefined {
+ if (!position) {
+ return undefined;
+ }
return new types.Position(position.lineNumber - 1, position.column - 1);
}
@@ -474,6 +483,18 @@ export function fromLocation(location: theia.Location | undefined): model.Locati
};
}
+export function fromLocationToLanguageServerLocation(location: theia.Location): lstypes.Location;
+export function fromLocationToLanguageServerLocation(location: theia.Location | undefined): lstypes.Location | undefined;
+export function fromLocationToLanguageServerLocation(location: theia.Location | undefined): lstypes.Location | undefined {
+ if (!location) {
+ return undefined;
+ }
+ return {
+ uri: location.uri.toString(),
+ range: location.range
+ };
+}
+
export function fromTextDocumentShowOptions(options: theia.TextDocumentShowOptions): model.TextDocumentShowOptions {
if (options.selection) {
return {
@@ -1697,15 +1718,26 @@ export namespace TestMessage {
return message.map(msg => TestMessage.from(msg)[0]);
}
return [{
- location: fromLocation(message.location),
+ location: fromLocationToLanguageServerLocation(message.location),
message: fromMarkdown(message.message)!,
expected: message.expectedOutput,
actual: message.actualOutput,
- contextValue: message.contextValue
+ contextValue: message.contextValue,
+ stackTrace: message.stackTrace && message.stackTrace.map(frame => TestMessageStackFrame.from(frame))
}];
}
}
+export namespace TestMessageStackFrame {
+ export function from(stackTrace: theia.TestMessageStackFrame): TestMessageStackFrameDTO {
+ return {
+ label: stackTrace.label,
+ position: stackTrace.position,
+ uri: stackTrace?.uri?.toString()
+ };
+ }
+}
+
export namespace TestItem {
export function from(test: theia.TestItem): TestItemDTO {
return TestItem.fromPartial(test);
diff --git a/packages/plugin-ext/src/plugin/types-impl.ts b/packages/plugin-ext/src/plugin/types-impl.ts
index 1aee501f2e810..d7a0734259657 100644
--- a/packages/plugin-ext/src/plugin/types-impl.ts
+++ b/packages/plugin-ext/src/plugin/types-impl.ts
@@ -3036,7 +3036,7 @@ export class DebugThread implements theia.DebugThread {
}
export class DebugStackFrame implements theia.DebugStackFrame {
- constructor(readonly session: theia.DebugSession, readonly threadId: number, readonly frameId: number) { }
+ constructor(readonly session: theia.DebugSession, readonly threadId: number, readonly frameId: number) { }
}
@es5ClassCompat
@@ -3350,6 +3350,7 @@ export class TestMessage implements theia.TestMessage {
public actualOutput?: string;
public location?: theia.Location;
public contextValue?: string;
+ public stackTrace?: theia.TestMessageStackFrame[] | undefined;
public static diff(message: string | theia.MarkdownString, expected: string, actual: string): theia.TestMessage {
const msg = new TestMessage(message);
@@ -3366,6 +3367,14 @@ export class TestCoverageCount {
constructor(public covered: number, public total: number) { }
}
+export class TestMessageStackFrame implements theia.TestMessageStackFrame {
+ constructor(
+ public label: string,
+ public uri?: theia.Uri,
+ public position?: Position
+ ) { }
+}
+
@es5ClassCompat
export class FileCoverage {
diff --git a/packages/plugin/src/theia.d.ts b/packages/plugin/src/theia.d.ts
index 6caa690fdacf1..f5a88f4e5070f 100644
--- a/packages/plugin/src/theia.d.ts
+++ b/packages/plugin/src/theia.d.ts
@@ -16966,6 +16966,34 @@ export module '@theia/plugin' {
error: string | MarkdownString | undefined;
}
+ /**
+ * A stack frame found in the {@link TestMessage.stackTrace}.
+ */
+ export class TestMessageStackFrame {
+ /**
+ * The location of this stack frame. This should be provided as a URI if the
+ * location of the call frame can be accessed by the editor.
+ */
+ uri?: Uri;
+
+ /**
+ * Position of the stack frame within the file.
+ */
+ position?: Position;
+
+ /**
+ * The name of the stack frame, typically a method or function name.
+ */
+ label: string;
+
+ /**
+ * @param label The name of the stack frame
+ * @param file The file URI of the stack frame
+ * @param position The position of the stack frame within the file
+ */
+ constructor(label: string, uri?: Uri, position?: Position);
+ }
+
/**
* Message associated with the test state. Can be linked to a specific
* source range -- useful for assertion failures, for example.
@@ -17022,6 +17050,11 @@ export module '@theia/plugin' {
*/
contextValue?: string;
+ /**
+ * The stack trace associated with the message or failure.
+ */
+ stackTrace?: TestMessageStackFrame[];
+
/**
* Creates a new TestMessage that will present as a diff in the editor.
* @param message Message to display to the user.
diff --git a/packages/test/src/browser/style/index.css b/packages/test/src/browser/style/index.css
index f919706ee4318..c880f8c433ac4 100644
--- a/packages/test/src/browser/style/index.css
+++ b/packages/test/src/browser/style/index.css
@@ -18,25 +18,29 @@
}
.theia-test-view .passed,
-.theia-test-result-view .passed {
+.theia-test-run-view .passed {
color: var(--theia-successBackground);
}
.theia-test-view .failed,
-.theia-test-result-view .failed {
+.theia-test-run-view .failed {
color: var(--theia-editorError-foreground);
}
.theia-test-view .errored,
-.theia-test-result-view .errored {
+.theia-test-run-view .errored {
color: var(--theia-editorError-foreground);
}
.theia-test-view .queued,
-.theia-test-result-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;
}
\ No newline at end of file
diff --git a/packages/test/src/browser/test-service.ts b/packages/test/src/browser/test-service.ts
index 210ca558c58c7..c2fb29f2744d0 100644
--- a/packages/test/src/browser/test-service.ts
+++ b/packages/test/src/browser/test-service.ts
@@ -15,7 +15,7 @@
// *****************************************************************************
import { CancellationToken, ContributionProvider, Disposable, Emitter, Event, QuickPickService, isObject, nls } from '@theia/core/lib/common';
-import { CancellationTokenSource, Location, Range } from '@theia/core/shared/vscode-languageserver-protocol';
+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';
@@ -56,9 +56,16 @@ export enum TestExecutionState {
export interface TestMessage {
readonly expected?: string;
readonly actual?: string;
- readonly location: Location;
+ 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 {
@@ -367,7 +374,7 @@ export class DefaultTestService implements TestService {
selectDefaultProfile(): void {
this.pickProfileKind().then(kind => {
- const profiles = this.getControllers().flatMap(c => c.testRunProfiles).filter(profile => profile.kind === 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
diff --git a/packages/test/src/browser/view/test-result-widget.ts b/packages/test/src/browser/view/test-result-widget.ts
index 4df73d7914e97..a1f154acc9f50 100644
--- a/packages/test/src/browser/view/test-result-widget.ts
+++ b/packages/test/src/browser/view/test-result-widget.ts
@@ -14,14 +14,17 @@
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
-import { BaseWidget, Message, codicon } from '@theia/core/lib/browser';
+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 { DisposableCollection, nls } from '@theia/core';
-import { TestFailure, TestMessage } from '../test-service';
+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 {
@@ -29,6 +32,10 @@ export class TestResultWidget extends BaseWidget {
@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[] = [];
@@ -36,6 +43,7 @@ export class TestResultWidget extends BaseWidget {
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');
@@ -83,6 +91,48 @@ export class TestResultWidget extends BaseWidget {
} 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 {
+ this.fileService.resolve(uri).then(stat => {
+ if (stat.isFile) {
+ this.navigationService.reveal(NavigationLocation.create(uri, position ?? { line: 0, character: 0 }));
+ }
});
}
diff --git a/packages/test/src/browser/view/test-run-widget.tsx b/packages/test/src/browser/view/test-run-widget.tsx
index c45a6454be7e8..02001bd4347cf 100644
--- a/packages/test/src/browser/view/test-run-widget.tsx
+++ b/packages/test/src/browser/view/test-run-widget.tsx
@@ -198,7 +198,7 @@ export class TestRunTreeWidget extends TreeWidget {
@postConstruct()
protected override init(): void {
super.init();
- this.addClass('theia-test-result-view');
+ this.addClass('theia-test-run-view');
this.model.onSelectionChanged(() => {
const node = this.model.selectedNodes[0];
if (node instanceof TestRunNode) {