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..abaaf91c1407d 100644 --- a/packages/plugin-ext/src/common/test-types.ts +++ b/packages/plugin-ext/src/common/test-types.ts @@ -25,7 +25,7 @@ import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; import { UriComponents } from './uri-components'; -import { Location, Range } from './plugin-api-rpc-model'; +import { Location, Range, Position } from './plugin-api-rpc-model'; import { isObject } from '@theia/core'; export enum TestRunProfileKind { @@ -74,17 +74,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 || TestExecutionState.Errored); + } +} export interface TestSuccessDTO extends TestStateChangeDTO { readonly state: TestExecutionState.Passed; readonly duration?: number; } +export interface TestMessageStackFrameDTO { + uri?: UriComponents; + position?: Position; + label: string; +} + export interface TestMessageDTO { readonly expected?: string; readonly actual?: string; readonly location?: Location; readonly message: string | MarkdownString; readonly contextValue?: string; + readonly stackTrace?: TestMessageStackFrameDTO[]; } export interface TestItemDTO { diff --git a/packages/plugin-ext/src/main/browser/test-main.ts b/packages/plugin-ext/src/main/browser/test-main.ts index cd50fd4813ddf..8759f46832435 100644 --- a/packages/plugin-ext/src/main/browser/test-main.ts +++ b/packages/plugin-ext/src/main/browser/test-main.ts @@ -16,20 +16,25 @@ import { SimpleObservableCollection, TreeCollection, observableProperty } from '@theia/test/lib/common/collections'; import { - TestController, TestItem, TestOutputItem, TestRun, TestRunProfile, TestService, TestState, TestStateChangedEvent + TestController, TestItem, TestOutputItem, TestRun, TestRunProfile, TestService, TestState, TestStateChangedEvent, TestMessage, + TestFailure, TestMessageStackFrame } from '@theia/test/lib/browser/test-service'; import { TestExecutionProgressService } from '@theia/test/lib/browser/test-execution-progress-service'; import { AccumulatingTreeDeltaEmitter, CollectionDelta, DeltaKind, TreeDelta, TreeDeltaBuilder } from '@theia/test/lib/common/tree-delta'; -import { Emitter, Location, Range } from '@theia/core/shared/vscode-languageserver-protocol'; -import { Range as PluginRange, Location as PluginLocation } from '../../common/plugin-api-rpc-model'; +import { Emitter, Location, Range, Position } from '@theia/core/shared/vscode-languageserver-protocol'; +import { Range as PluginRange, Location as PluginLocation, Position as PluginPosition } from '../../common/plugin-api-rpc-model'; import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; 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, TestFailureDTO, TestItemDTO, TestItemReference, TestMessageDTO, TestMessageStackFrameDTO, TestOutputDTO, + TestRunDTO, TestRunProfileDTO, TestStateChangeDTO +} from '../../common/test-types'; import { TestRunProfileKind } from '../../plugin/types-impl'; import { CommandRegistryMainImpl } from './command-registry-main'; +import { UriComponents } from '../../common/uri-components'; export class TestItemCollection extends TreeCollection { override add(item: TestItemImpl): TestItemImpl | undefined { @@ -333,8 +338,10 @@ class TestRunImpl implements TestRun { const item = this.controller.findItem(change.itemPath); if (item) { const oldState = this.testStates.get(item); - this.testStates.set(item, change); - stateEvents.push({ test: item, oldState: oldState, newState: change }); + // convert back changes (for example, convert back location from DTO) + const convertedState = convertTestState(change); + this.testStates.set(item, convertedState); + stateEvents.push({ test: item, oldState: oldState, newState: convertedState}); } }); const outputEvents: [TestItem | undefined, TestOutputItem][] = []; @@ -368,6 +375,55 @@ class TestRunImpl implements TestRun { } } +function convertTestState(testStateDto: TestStateChangeDTO): TestState | TestFailure { + if (TestFailureDTO.is(testStateDto)) { + return { + state: testStateDto.state, + messages: testStateDto.messages.map(message => convertTestMessage(message)), + duration: testStateDto.duration + }; + } + return { + state: testStateDto.state + }; +} + +function convertTestMessage(dto: TestMessageDTO): TestMessage { + return { + message: dto.message, + actual: dto.actual, + location: dto.location ? convertLocation(dto.location) : undefined, + contextValue: dto.contextValue, + expected: dto.expected, + stackTrace: dto.stackTrace && dto.stackTrace.map(stackFrame => convertStackFrame(stackFrame)) + }; +} + +function convertStackFrame(dto: TestMessageStackFrameDTO): TestMessageStackFrame { + return { + label: dto.label, + position: convertPosition(dto.position), + uri: convertURI(dto.uri), + }; +} + +function convertPosition(position: PluginPosition | undefined): Position | undefined { + if (!position) { + return undefined; + } + return { + line: position.lineNumber - 1, + character: position.column - 1 + }; +} + +function convertURI(uri: UriComponents | undefined): URI | undefined { + if (!uri) { + return undefined; + } + return URI.fromComponents(uri); +} + function convertLocation(location: PluginLocation | undefined): Location | undefined { if (!location) { return undefined; diff --git a/packages/plugin-ext/src/plugin/plugin-context.ts b/packages/plugin-ext/src/plugin/plugin-context.ts index 8a36492244edc..166387629d234 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, @@ -1460,6 +1461,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..8608eb10a6c8b 100644 --- a/packages/plugin-ext/src/plugin/tests.ts +++ b/packages/plugin-ext/src/plugin/tests.ts @@ -40,10 +40,11 @@ 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 { ChangeBatcher, observableProperty } from '@theia/test/lib/common/collections'; -import { TestRunRequest } from './types-impl'; +import { TestRunRequest, URI } from './types-impl'; import { MarkdownString } from '../common/plugin-api-rpc-model'; type RefreshHandler = (token: theia.CancellationToken) => void | theia.Thenable; @@ -374,7 +375,16 @@ export class TestingExtImpl implements TestingExt { actualOutput: testMessage.actual, expectedOutput: testMessage.expected, contextValue: testMessage.contextValue, - location: testMessage.location ? Convert.toLocation(testMessage.location) : undefined + location: testMessage.location ? Convert.toLocation(testMessage.location) : undefined, + stackTrace: testMessage.stackTrace ? testMessage.stackTrace.map(frame => this.toStackFrame(frame)) : undefined + }; + } + + toStackFrame(stackFrame: TestMessageStackFrameDTO): theia.TestMessageStackFrame { + return { + label: stackFrame.label, + position: Convert.toPosition(stackFrame.position), + uri: stackFrame.uri ? URI.revive(stackFrame.uri) : undefined }; } diff --git a/packages/plugin-ext/src/plugin/type-converters.ts b/packages/plugin-ext/src/plugin/type-converters.ts index 557b412ed4e43..ceb95a09f02cf 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); } @@ -1681,11 +1690,22 @@ export namespace TestMessage { 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 && fromPosition(stackTrace.position), + uri: stackTrace?.uri + }; + } +} + 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 4ce0f20f46c2a..3cc49e47cb6e4 100644 --- a/packages/plugin/src/theia.d.ts +++ b/packages/plugin/src/theia.d.ts @@ -16573,6 +16573,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. @@ -16629,6 +16657,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/test-service.ts b/packages/test/src/browser/test-service.ts index 210ca558c58c7..df43d6af1fa17 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 } 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?: URI, + 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..07b2a7412ba09 100644 --- a/packages/test/src/browser/view/test-result-widget.ts +++ b/packages/test/src/browser/view/test-result-widget.ts @@ -14,13 +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 +33,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[] = []; @@ -83,6 +91,35 @@ export class TestResultWidget extends BaseWidget { } else { this.content.append(this.node.ownerDocument.createTextNode(message.message)); } + if (message.stackTrace) { + message.stackTrace.map(frame => this.renderFrame(frame)); + } + }); + } + + renderFrame(stackFrame: TestMessageStackFrame): void { + const frameElement = this.node.ownerDocument.createElement('div'); + frameElement.append(this.node.ownerDocument.createTextNode(stackFrame.label)); + + // Add URI information as clickable links + if (stackFrame.uri) { + + const uri = stackFrame.uri; + frameElement.append(' from '); + const link = this.node.ownerDocument.createElement('a'); + link.textContent = `${this.labelProvider.getName(uri)}`; + link.title = `${uri}`; + link.onclick = () => this.openUriInWorkspace(uri, stackFrame.position); + frameElement.append(link); + } + this.content.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 })); + } }); }