From 8bfe22d080ba7a3169675d2c565cc8725aa314dd Mon Sep 17 00:00:00 2001 From: Remi Schnekenburger Date: Wed, 11 Sep 2024 16:20:41 +0200 Subject: [PATCH 1/4] Support TestMessageStackFrame API Also properly convert TestState back from TestStateChangeDTO. fixes #14111 contributed on behalf of STMicroelectronics Signed-off-by: Remi Schnekenburger --- CHANGELOG.md | 2 + .../src/common/plugin-api-rpc-model.ts | 11 +++ packages/plugin-ext/src/common/test-types.ts | 15 +++- .../plugin-ext/src/main/browser/test-main.ts | 68 +++++++++++++++++-- .../plugin-ext/src/plugin/plugin-context.ts | 2 + packages/plugin-ext/src/plugin/tests.ts | 16 ++++- .../plugin-ext/src/plugin/type-converters.ts | 30 ++++++-- packages/plugin-ext/src/plugin/types-impl.ts | 11 ++- packages/plugin/src/theia.d.ts | 33 +++++++++ packages/test/src/browser/test-service.ts | 13 +++- .../src/browser/view/test-result-widget.ts | 41 ++++++++++- 11 files changed, 221 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 49cd5cf7bab25..9cd718168c2ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ ## 1.53.0 - 08/29/2024 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 })); + } }); } From 298f0462d774389507a7e7b65fcb2b8ac73f86b9 Mon Sep 17 00:00:00 2001 From: Remi Schnekenburger Date: Mon, 16 Sep 2024 17:33:02 +0200 Subject: [PATCH 2/4] address review comments --- packages/plugin-ext/src/common/test-types.ts | 2 +- packages/test/src/browser/style/index.css | 12 ++++++--- .../src/browser/view/test-result-widget.ts | 27 ++++++++++++------- .../test/src/browser/view/test-run-widget.tsx | 2 +- 4 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/plugin-ext/src/common/test-types.ts b/packages/plugin-ext/src/common/test-types.ts index abaaf91c1407d..2ed452e28ad03 100644 --- a/packages/plugin-ext/src/common/test-types.ts +++ b/packages/plugin-ext/src/common/test-types.ts @@ -77,7 +77,7 @@ export interface TestFailureDTO extends TestStateChangeDTO { export namespace TestFailureDTO { export function is(ref: unknown): ref is TestFailureDTO { return isObject(ref) - && ref.state === (TestExecutionState.Failed || TestExecutionState.Errored); + && (ref.state === TestExecutionState.Failed || ref.state === TestExecutionState.Errored); } } export interface TestSuccessDTO extends TestStateChangeDTO { 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/view/test-result-widget.ts b/packages/test/src/browser/view/test-result-widget.ts index 07b2a7412ba09..8ad10cec161d0 100644 --- a/packages/test/src/browser/view/test-result-widget.ts +++ b/packages/test/src/browser/view/test-result-widget.ts @@ -44,6 +44,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'); @@ -92,27 +93,35 @@ export class TestResultWidget extends BaseWidget { this.content.append(this.node.ownerDocument.createTextNode(message.message)); } if (message.stackTrace) { - message.stackTrace.map(frame => this.renderFrame(frame)); + const stackTraceElement = this.node.ownerDocument.createElement('div'); + message.stackTrace.map(frame => this.renderFrame(frame, stackTraceElement)); + this.content.append(stackTraceElement); } }); } - renderFrame(stackFrame: TestMessageStackFrame): void { - const frameElement = this.node.ownerDocument.createElement('div'); - frameElement.append(this.node.ownerDocument.createTextNode(stackFrame.label)); + 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 = stackFrame.uri; - frameElement.append(' from '); + const link = this.node.ownerDocument.createElement('a'); - link.textContent = `${this.labelProvider.getName(uri)}`; - link.title = `${uri}`; + let content = `${this.labelProvider.getName(uri)}`; + if (stackFrame.position) { + content += `:${stackFrame.position.line}:${stackFrame.position.character}`; + } + link.textContent = content; + link.href = `${uri}`; link.onclick = () => this.openUriInWorkspace(uri, stackFrame.position); frameElement.append(link); + frameElement.append(')'); } - this.content.append(frameElement); + stackTraceElement.append(frameElement); } async openUriInWorkspace(uri: URI, position?: Position): Promise { 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) { From 5ccfa86e1f60f75a688ef78197d18d56801562f0 Mon Sep 17 00:00:00 2001 From: Remi Schnekenburger Date: Tue, 17 Sep 2024 17:17:49 +0200 Subject: [PATCH 3/4] address review comments - remove conversion on test package - adapt DTOs to allow 1-to-1 mapping on browser side - add required convertes on plugin-ext side --- packages/plugin-ext/src/common/test-types.ts | 9 +-- .../menus/plugin-menu-command-adapter.ts | 4 +- .../plugin-ext/src/main/browser/test-main.ts | 65 ++----------------- packages/plugin-ext/src/plugin/tests.ts | 29 +++++++-- .../plugin-ext/src/plugin/type-converters.ts | 18 ++++- packages/test/src/browser/test-service.ts | 4 +- .../src/browser/view/test-result-widget.ts | 2 +- 7 files changed, 56 insertions(+), 75 deletions(-) diff --git a/packages/plugin-ext/src/common/test-types.ts b/packages/plugin-ext/src/common/test-types.ts index 2ed452e28ad03..56c2ec4015262 100644 --- a/packages/plugin-ext/src/common/test-types.ts +++ b/packages/plugin-ext/src/common/test-types.ts @@ -25,8 +25,9 @@ import { MarkdownString } from '@theia/core/lib/common/markdown-rendering'; import { UriComponents } from './uri-components'; -import { Location, Range, Position } from './plugin-api-rpc-model'; +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, @@ -86,15 +87,15 @@ export interface TestSuccessDTO extends TestStateChangeDTO { } export interface TestMessageStackFrameDTO { - uri?: UriComponents; - position?: Position; + 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[]; 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 8759f46832435..3662373142758 100644 --- a/packages/plugin-ext/src/main/browser/test-main.ts +++ b/packages/plugin-ext/src/main/browser/test-main.ts @@ -16,25 +16,23 @@ import { SimpleObservableCollection, TreeCollection, observableProperty } from '@theia/test/lib/common/collections'; import { - TestController, TestItem, TestOutputItem, TestRun, TestRunProfile, TestService, TestState, TestStateChangedEvent, TestMessage, - TestFailure, TestMessageStackFrame + TestController, TestItem, TestOutputItem, TestRun, TestRunProfile, TestService, TestState, TestStateChangedEvent } 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, 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 { Emitter, Location, Range } from '@theia/core/shared/vscode-languageserver-protocol'; +import { Range as PluginRange, Location as PluginLocation } 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, TestFailureDTO, TestItemDTO, TestItemReference, TestMessageDTO, TestMessageStackFrameDTO, TestOutputDTO, + TestExecutionState, TestItemDTO, TestItemReference, 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 { @@ -338,10 +336,8 @@ class TestRunImpl implements TestRun { const item = this.controller.findItem(change.itemPath); if (item) { const oldState = this.testStates.get(item); - // 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}); + this.testStates.set(item, change); + stateEvents.push({ test: item, oldState: oldState, newState: change }); } }); const outputEvents: [TestItem | undefined, TestOutputItem][] = []; @@ -375,55 +371,6 @@ 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/tests.ts b/packages/plugin-ext/src/plugin/tests.ts index 8608eb10a6c8b..514bcbbe4fabc 100644 --- a/packages/plugin-ext/src/plugin/tests.ts +++ b/packages/plugin-ext/src/plugin/tests.ts @@ -43,8 +43,9 @@ import { 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, URI } 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; @@ -375,16 +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: Convert.toPosition(stackFrame.position), - uri: stackFrame.uri ? URI.revive(stackFrame.uri) : undefined + 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 bc23520c4b4ce..c25ac586a7b78 100644 --- a/packages/plugin-ext/src/plugin/type-converters.ts +++ b/packages/plugin-ext/src/plugin/type-converters.ts @@ -483,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 { @@ -1706,7 +1718,7 @@ 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, @@ -1720,8 +1732,8 @@ export namespace TestMessageStackFrame { export function from(stackTrace: theia.TestMessageStackFrame): TestMessageStackFrameDTO { return { label: stackTrace.label, - position: stackTrace.position && fromPosition(stackTrace.position), - uri: stackTrace?.uri + position: stackTrace.position, + uri: stackTrace?.uri?.toString() }; } } diff --git a/packages/test/src/browser/test-service.ts b/packages/test/src/browser/test-service.ts index df43d6af1fa17..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, Position } 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'; @@ -64,7 +64,7 @@ export interface TestMessage { export interface TestMessageStackFrame { readonly label: string, - readonly uri?: URI, + readonly uri?: DocumentUri, readonly position?: Position, } diff --git a/packages/test/src/browser/view/test-result-widget.ts b/packages/test/src/browser/view/test-result-widget.ts index 8ad10cec161d0..cb042898dfbcc 100644 --- a/packages/test/src/browser/view/test-result-widget.ts +++ b/packages/test/src/browser/view/test-result-widget.ts @@ -108,7 +108,7 @@ export class TestResultWidget extends BaseWidget { // Add URI information as clickable links if (stackFrame.uri) { frameElement.append(' ('); - const uri = stackFrame.uri; + const uri = new URI(stackFrame.uri); const link = this.node.ownerDocument.createElement('a'); let content = `${this.labelProvider.getName(uri)}`; From fd2dd2e95c050b9fb2504bb95260d83ded59bedf Mon Sep 17 00:00:00 2001 From: Remi Schnekenburger Date: Mon, 23 Sep 2024 11:14:49 +0200 Subject: [PATCH 4/4] Adapt position shown to the user for the link to 1-based monaco position --- packages/test/src/browser/view/test-result-widget.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/test/src/browser/view/test-result-widget.ts b/packages/test/src/browser/view/test-result-widget.ts index cb042898dfbcc..a1f154acc9f50 100644 --- a/packages/test/src/browser/view/test-result-widget.ts +++ b/packages/test/src/browser/view/test-result-widget.ts @@ -25,7 +25,6 @@ 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 { @@ -113,7 +112,12 @@ export class TestResultWidget extends BaseWidget { const link = this.node.ownerDocument.createElement('a'); let content = `${this.labelProvider.getName(uri)}`; if (stackFrame.position) { - content += `:${stackFrame.position.line}:${stackFrame.position.character}`; + // 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}`;