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) {