From 3a357075aa1f987b7d33d58c4d2d2fab684b1ae6 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Wed, 26 Aug 2026 15:28:04 +0300 Subject: [PATCH 01/15] Add graphical view functionality and enhance language client integration --- .vscode-test.mjs | 3 + package.json | 13 +- src/aspectValidation.ts | 2 +- src/extension.ts | 41 ++ src/graphicalView.ts | 611 +++++++++++++++++++ src/graphicalViewPanel.ts | 101 +++ src/graphicalViewProtocol.ts | 147 +++++ src/languageClient.ts | 63 +- src/test/graphicalViewController.test.ts | 346 +++++++++++ src/test/graphicalViewTestHarness.ts | 286 +++++++++ src/test/languageClientGraphicalView.test.ts | 141 +++++ 11 files changed, 1744 insertions(+), 10 deletions(-) create mode 100644 src/graphicalView.ts create mode 100644 src/graphicalViewPanel.ts create mode 100644 src/graphicalViewProtocol.ts create mode 100644 src/test/graphicalViewController.test.ts create mode 100644 src/test/graphicalViewTestHarness.ts create mode 100644 src/test/languageClientGraphicalView.test.ts diff --git a/.vscode-test.mjs b/.vscode-test.mjs index d320b89..b36d3f4 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,7 +1,10 @@ import { defineConfig } from '@vscode/test-cli'; +const vscodeExecutablePath = process.env.VSCODE_EXECUTABLE_PATH; + export default defineConfig({ files: 'out/test/**/*.test.js', + ...(vscodeExecutablePath ? { useInstallation: { fromPath: vscodeExecutablePath } } : {}), launchArgs: [ '--user-data-dir=/tmp/extension-vscode-test-user-data', '--extensions-dir=/tmp/extension-vscode-test-extensions' diff --git a/package.json b/package.json index 5c65ebf..87ade76 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,12 @@ "command": "turtle.selectSammCliExecutable", "title": "Select SAMM CLI Executable", "category": "Turtle" + }, + { + "command": "turtle.openGraphicalView", + "title": "Open Graphical View", + "category": "Turtle", + "enablement": "editorLangId == turtle" } ], "languages": [ @@ -113,6 +119,11 @@ "command": "turtle.validateDocumentNow", "when": "resourceLangId == turtle", "group": "1_modification" + }, + { + "command": "turtle.openGraphicalView", + "when": "resourceLangId == turtle", + "group": "1_modification@2" } ] }, @@ -172,4 +183,4 @@ "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" } -} \ No newline at end of file +} diff --git a/src/aspectValidation.ts b/src/aspectValidation.ts index 9dbf802..125d94b 100644 --- a/src/aspectValidation.ts +++ b/src/aspectValidation.ts @@ -30,7 +30,7 @@ export interface DiagnosticReport { } export interface RequestClient { - sendRequest(method: string, params?: unknown): Thenable; + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Thenable; } export interface ValidationWindow { diff --git a/src/extension.ts b/src/extension.ts index 8bc4388..f396302 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -18,6 +18,8 @@ import { SammCliDownloader } from './sammCliDownloader'; import { TurtleExtensionSettings } from './settings'; import { TurtleLanguageClient } from './languageClient'; import type { ExtensionLogger } from './outputChannel'; +import { GraphicalViewController } from './graphicalView'; +import { VscodeGraphicalViewPanelFactory } from './graphicalViewPanel'; const SELECT_EXECUTABLE_COMMAND = 'turtle.selectSammCliExecutable'; const SELECT_EXECUTABLE_TITLE = 'Select SAMM CLI Executable'; @@ -27,6 +29,7 @@ let settings: TurtleExtensionSettings; let languageServer: TurtleLanguageServer | undefined; let languageClient: TurtleLanguageClient; let aspectValidationController: AspectValidationController; +let graphicalViewController: GraphicalViewController; let sammCliDownloader: SammCliDownloader; let outputChannel: ExtensionLogger; @@ -43,6 +46,40 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { languageClient = new TurtleLanguageClient(outputChannel, settings.getSammCliLspServerPort(), settings.getLanguageClientTraceLevel()); aspectValidationController = new AspectValidationController(createUnavailableClient(), vscode.window, vscode.workspace, outputChannel); aspectValidationController.register(context); + graphicalViewController = new GraphicalViewController( + undefined, + new VscodeGraphicalViewPanelFactory(), + vscode.commands, + vscode.window, + { + onDidSaveTextDocument: listener => vscode.workspace.onDidSaveTextDocument(listener), + onDidChangeDocumentAvailability: listener => + vscode.Disposable.from( + vscode.workspace.onDidOpenTextDocument(document => listener(document.uri.toString(), true)), + vscode.workspace.onDidCloseTextDocument(document => listener(document.uri.toString(), false)), + vscode.window.tabGroups.onDidChangeTabs(event => { + for (const tab of event.closed) { + if (tab.input instanceof vscode.TabInputText) { + const sourceUri = tab.input.uri.toString(); + const remainsOpen = vscode.window.tabGroups.all.some(group => + group.tabs.some( + candidate => + candidate.input instanceof vscode.TabInputText && + candidate.input.uri.toString() === sourceUri, + ), + ); + if (!remainsOpen) { + listener(sourceUri, false); + } + } + } + }), + ), + isDocumentAvailable: uri => vscode.workspace.textDocuments.some(document => document.uri.toString() === uri), + }, + outputChannel, + ); + graphicalViewController.register(context); context.subscriptions.push( vscode.commands.registerCommand(SELECT_EXECUTABLE_COMMAND, async () => { @@ -112,6 +149,7 @@ async function restartLanguageServices(reason: string): Promise { outputChannel.info(`Restarting language services (${reason}).`); aspectValidationController.setClient(createUnavailableClient()); + graphicalViewController.setClient(undefined); await languageClient.disconnect(); await stopLanguageServer(); @@ -124,6 +162,7 @@ async function restartLanguageServices(reason: string): Promise { } catch (error) { await stopLanguageServer().catch(() => undefined); aspectValidationController.setClient(createUnavailableClient()); + graphicalViewController.setClient(undefined); throw error; } @@ -132,6 +171,7 @@ async function restartLanguageServices(reason: string): Promise { await nextClient.connect(); languageClient = nextClient; aspectValidationController.setClient(nextClient); + graphicalViewController.setClient(nextClient); } type SammCliQuickPickItem = vscode.QuickPickItem & { @@ -257,6 +297,7 @@ function createUnavailableClient(): RequestClient { } export async function deactivate(): Promise { + graphicalViewController?.dispose(); await restartChain; await languageClient.disconnect(); await stopLanguageServer(); diff --git a/src/graphicalView.ts b/src/graphicalView.ts new file mode 100644 index 0000000..f412aa1 --- /dev/null +++ b/src/graphicalView.ts @@ -0,0 +1,611 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as vscode from 'vscode'; +import type {ExtensionLogger} from './outputChannel'; +import { + GraphicalViewRenderResult, + GraphicalViewRenderWarning, + GraphicalViewRequestClient, + GraphicalViewTarget, + isGraphicalViewRenderResult, +} from './graphicalViewProtocol'; + +export const OPEN_GRAPHICAL_VIEW_COMMAND = 'turtle.openGraphicalView'; + +export type GraphicalViewStatus = + | Readonly<{kind: 'loading'; message: string}> + | Readonly<{kind: 'ready'; message: string}> + | Readonly<{kind: 'stale'; reason: string; message: string}> + | Readonly<{kind: 'unsupported'; message: string}> + | Readonly<{kind: 'disconnected'; message: string}>; + +export interface GraphicalViewAcceptedResult { + readonly uri: string; + readonly svg: string; + readonly targets: readonly Readonly[]; + readonly warnings: readonly GraphicalViewRenderWarning[]; + readonly targetById: ReadonlyMap>; +} + +export type GraphicalViewDelivery = + | Readonly<{type: 'status'; uri: string; status: GraphicalViewStatus}> + | Readonly<{type: 'render'; uri: string; svg: string; warnings: readonly GraphicalViewRenderWarning[]}>; + +export type GraphicalViewPanelMessage = Readonly<{type: 'ready'}> | Readonly<{type: 'refresh'}> | Readonly<{type: 'navigate'; targetId: string}>; + +export interface GraphicalViewPanelAdapter extends vscode.Disposable { + readonly visible: boolean; + reveal(): void; + deliver(delivery: GraphicalViewDelivery): void; + onDidDispose(listener: () => void): vscode.Disposable; + onDidChangeVisibility(listener: (visible: boolean) => void): vscode.Disposable; + onDidReceiveMessage(listener: (message: unknown) => void): vscode.Disposable; +} + +export interface GraphicalViewPanelFactory { + create(sourceUri: string): GraphicalViewPanelAdapter; +} + +export interface GraphicalViewDocument { + readonly languageId: string; + readonly uri: vscode.Uri; +} + +export interface GraphicalViewWindow { + readonly activeTextEditor: {readonly document: GraphicalViewDocument} | undefined; + showWarningMessage(message: string): Thenable; +} + +export interface GraphicalViewWorkspace { + onDidSaveTextDocument(listener: (document: GraphicalViewDocument) => void): vscode.Disposable; + onDidChangeDocumentAvailability(listener: (sourceUri: string, available: boolean) => void): vscode.Disposable; + isDocumentAvailable(uri: string): boolean; +} + +export interface GraphicalViewCommands { + registerCommand(command: string, callback: () => unknown): vscode.Disposable; +} + +export interface GraphicalViewControllerContext { + subscriptions: vscode.Disposable[]; +} + +export interface GraphicalViewPanelSnapshot { + readonly sourceUri: string; + readonly sequence: number; + readonly visible: boolean; + readonly disposed: boolean; + readonly status: GraphicalViewStatus; + readonly lastSuccess: GraphicalViewAcceptedResult | undefined; +} + +interface PanelState { + readonly sourceUri: string; + readonly panel: GraphicalViewPanelAdapter; + readonly subscriptions: vscode.Disposable[]; + sequence: number; + sourceAvailable: boolean; + visible: boolean; + disposed: boolean; + cancellation: vscode.CancellationTokenSource | undefined; + status: GraphicalViewStatus; + lastSuccess: GraphicalViewAcceptedResult | undefined; +} + +const DISCONNECTED_STATUS: GraphicalViewStatus = Object.freeze({ + kind: 'disconnected', + message: 'The Turtle language server is disconnected. Reconnect and use Refresh to try again.', +}); + +export class GraphicalViewController implements vscode.Disposable { + private readonly panels = new Map(); + private readonly subscriptions: vscode.Disposable[] = []; + private clientSubscription: vscode.Disposable | undefined; + private registered = false; + private disposed = false; + + constructor( + private client: GraphicalViewRequestClient | undefined, + private readonly panelFactory: GraphicalViewPanelFactory, + private readonly commands: GraphicalViewCommands, + private readonly window: GraphicalViewWindow, + private readonly workspace: GraphicalViewWorkspace, + private readonly outputChannel: ExtensionLogger, + private readonly createCancellationSource: () => vscode.CancellationTokenSource = () => new vscode.CancellationTokenSource(), + ) { + this.subscribeToClient(); + } + + register(context: GraphicalViewControllerContext): void { + if (this.registered || this.disposed) { + return; + } + this.registered = true; + + this.subscriptions.push( + this.commands.registerCommand(OPEN_GRAPHICAL_VIEW_COMMAND, () => this.openGraphicalView(this.window.activeTextEditor?.document)), + this.workspace.onDidSaveTextDocument(document => this.handleSave(document)), + this.workspace.onDidChangeDocumentAvailability((sourceUri, available) => + this.handleDocumentAvailability(sourceUri, available), + ), + ); + context.subscriptions.push(this); + } + + async openGraphicalView(document: GraphicalViewDocument | undefined): Promise { + if (!document || document.languageId !== 'turtle') { + await this.window.showWarningMessage('Open a Turtle file before opening the graphical view.'); + return undefined; + } + + const sourceUri = document.uri.toString(); + const existing = this.panels.get(sourceUri); + if (existing && !existing.disposed) { + existing.panel.reveal(); + return this.snapshot(existing); + } + + const panel = this.panelFactory.create(sourceUri); + const state: PanelState = { + sourceUri, + panel, + subscriptions: [], + sequence: 0, + sourceAvailable: this.workspace.isDocumentAvailable(sourceUri), + visible: panel.visible, + disposed: false, + cancellation: undefined, + status: Object.freeze({kind: 'loading', message: 'Preparing graphical view...'}), + lastSuccess: undefined, + }; + this.panels.set(sourceUri, state); + state.subscriptions.push( + panel.onDidDispose(() => this.disposePanelState(state, false)), + panel.onDidChangeVisibility(visible => this.handleVisibilityChange(state, visible)), + panel.onDidReceiveMessage(message => this.handlePanelMessage(state, message)), + ); + void this.requestRender(state, 'initial'); + return this.snapshot(state); + } + + setClient(client: GraphicalViewRequestClient | undefined): void { + if (this.disposed) { + return; + } + + this.clientSubscription?.dispose(); + this.clientSubscription = undefined; + this.client = client; + for (const state of this.panels.values()) { + this.invalidate(state); + this.setStatus(state, client?.isGraphicalViewAvailable() ? availableAgainStatus(state) : DISCONNECTED_STATUS); + } + this.subscribeToClient(); + } + + getPanelCount(): number { + return this.panels.size; + } + + getPanelState(sourceUri: string): GraphicalViewPanelSnapshot | undefined { + const state = this.panels.get(sourceUri); + return state ? this.snapshot(state) : undefined; + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.clientSubscription?.dispose(); + this.clientSubscription = undefined; + for (const subscription of this.subscriptions.splice(0)) { + subscription.dispose(); + } + for (const state of [...this.panels.values()]) { + this.disposePanelState(state, true); + } + } + + private subscribeToClient(): void { + if (!this.client || this.disposed) { + return; + } + this.clientSubscription = this.client.onDidChangeGraphicalViewAvailability(available => this.handleClientAvailability(available)); + } + + private handleClientAvailability(available: boolean): void { + for (const state of this.panels.values()) { + if (!available) { + this.invalidate(state); + this.setStatus(state, DISCONNECTED_STATUS); + } else { + this.setStatus(state, availableAgainStatus(state)); + } + } + } + + private handleSave(document: GraphicalViewDocument): void { + const state = this.panels.get(document.uri.toString()); + if (!state || state.disposed) { + return; + } + + if (!state.visible) { + this.invalidate(state); + this.setStatus(state, retainedAfterHiddenSaveStatus(state)); + return; + } + + void this.requestRender(state, 'save'); + } + + private handleDocumentAvailability(sourceUri: string, available: boolean): void { + const state = this.panels.get(sourceUri); + if (!state || state.disposed || state.sourceAvailable === available) { + return; + } + + state.sourceAvailable = available; + if (available) { + return; + } + + this.invalidate(state); + this.setStatus(state, retainedAfterSourceLossStatus(state)); + } + + private handleVisibilityChange(state: PanelState, visible: boolean): void { + if (!this.isCurrent(state)) { + return; + } + state.visible = visible; + if (visible) { + this.deliverCurrentState(state); + } + } + + private handlePanelMessage(state: PanelState, message: unknown): void { + if (!this.isCurrent(state) || !isPanelMessage(message)) { + return; + } + + switch (message.type) { + case 'ready': + this.deliverCurrentState(state); + return; + case 'refresh': + if (state.visible) { + void this.requestRender(state, 'manual'); + } + return; + case 'navigate': + // Task 4 validates current-sidecar membership and resolves navigation. + return; + } + } + + private async requestRender(state: PanelState, trigger: 'initial' | 'manual' | 'save'): Promise { + if (!this.isCurrent(state)) { + return; + } + + this.cancelCurrent(state); + const sequence = ++state.sequence; + const sourceUri = state.sourceUri; + + state.sourceAvailable = this.workspace.isDocumentAvailable(sourceUri); + if (!state.sourceAvailable) { + this.setStatus( + state, + Object.freeze({ + kind: 'stale', + reason: 'sourceUnavailable', + message: 'The source document is not available to the language server. Reopen it and use Refresh.', + }), + ); + return; + } + + const client = this.client; + if (!client?.isGraphicalViewAvailable()) { + this.setStatus(state, DISCONNECTED_STATUS); + return; + } + + const cancellation = this.createCancellationSource(); + state.cancellation = cancellation; + this.setStatus(state, Object.freeze({kind: 'loading', message: `Rendering graphical view (${trigger})...`})); + + try { + const result = await client.renderGraphicalView({uri: sourceUri}, cancellation.token); + if (!this.isCurrentRequest(state, sourceUri, sequence, cancellation)) { + return; + } + state.cancellation = undefined; + cancellation.dispose(); + this.handleRenderResult(state, result); + } catch (error) { + if (!this.isCurrentRequest(state, sourceUri, sequence, cancellation)) { + return; + } + state.cancellation = undefined; + cancellation.dispose(); + this.handleRenderFailure(state, error); + } + } + + private handleRenderResult(state: PanelState, result: unknown): void { + if (!isGraphicalViewRenderResult(result)) { + this.setStale(state, 'invalidResponse', 'The language server returned an invalid graphical-view response.'); + return; + } + + if (result.uri !== state.sourceUri) { + this.setStale(state, 'uriMismatch', 'The language server returned a graphical view for a different source document.'); + return; + } + + if (result.svg === undefined || result.svg === null) { + this.handleWarningResult(state, result.warnings); + return; + } + + const accepted = createAcceptedResult(result, result.svg); + state.lastSuccess = accepted; + state.status = Object.freeze({kind: 'ready', message: 'Graphical view is up to date.'}); + state.panel.deliver(Object.freeze({type: 'render', uri: accepted.uri, svg: accepted.svg, warnings: accepted.warnings})); + this.deliverStatus(state); + } + + private handleWarningResult(state: PanelState, warnings: readonly GraphicalViewRenderWarning[]): void { + const warning = warnings[0]; + switch (warning) { + case 'timeout': + this.setStale(state, warning, 'Graphical rendering timed out. The last successful diagram is retained.'); + return; + case 'modelTooLarge': + this.setStale(state, warning, 'The model is too large for graphical rendering. The last successful diagram is retained.'); + return; + case 'missingDocument': + this.setStale(state, warning, 'The source document is unavailable. The last successful diagram is retained.'); + return; + case 'unsupportedUri': + this.setStale(state, warning, 'The source URI is not supported for graphical rendering.'); + return; + case 'temporarilyUnresolvable': + default: + this.setStale(state, warning ?? 'renderFailure', 'The model could not be loaded or parsed. The last successful diagram is retained.'); + } + } + + private handleRenderFailure(state: PanelState, error: unknown): void { + if (isMethodNotFound(error)) { + this.setStatus( + state, + Object.freeze({ + kind: 'unsupported', + message: 'Graphical view is not supported by the current server build. The last successful diagram is retained.', + }), + ); + return; + } + + const detail = error instanceof Error ? error.message : String(error); + this.setStale(state, classifyFailure(detail), `Graphical rendering failed: ${detail}. The last successful diagram is retained.`); + } + + private setStale(state: PanelState, reason: string, message: string): void { + this.setStatus(state, Object.freeze({kind: 'stale', reason, message})); + } + + private setStatus(state: PanelState, status: GraphicalViewStatus): void { + if (!this.isCurrent(state)) { + return; + } + state.status = status; + this.deliverStatus(state); + } + + private deliverCurrentState(state: PanelState): void { + this.deliverStatus(state); + const accepted = state.lastSuccess; + if (accepted) { + state.panel.deliver(Object.freeze({type: 'render', uri: accepted.uri, svg: accepted.svg, warnings: accepted.warnings})); + } + } + + private deliverStatus(state: PanelState): void { + state.panel.deliver(Object.freeze({type: 'status', uri: state.sourceUri, status: state.status})); + } + + private invalidate(state: PanelState): void { + if (!this.isCurrent(state)) { + return; + } + this.cancelCurrent(state); + state.sequence += 1; + } + + private cancelCurrent(state: PanelState): void { + const cancellation = state.cancellation; + state.cancellation = undefined; + if (cancellation) { + cancellation.cancel(); + cancellation.dispose(); + } + } + + private isCurrentRequest( + state: PanelState, + sourceUri: string, + sequence: number, + cancellation: vscode.CancellationTokenSource, + ): boolean { + return this.isCurrent(state) && state.sourceUri === sourceUri && state.sequence === sequence && state.cancellation === cancellation; + } + + private isCurrent(state: PanelState): boolean { + return !this.disposed && !state.disposed && this.panels.get(state.sourceUri) === state; + } + + private disposePanelState(state: PanelState, disposePanel: boolean): void { + if (state.disposed) { + return; + } + this.cancelCurrent(state); + state.sequence += 1; + state.disposed = true; + this.panels.delete(state.sourceUri); + for (const subscription of state.subscriptions.splice(0)) { + subscription.dispose(); + } + if (disposePanel) { + state.panel.dispose(); + } + } + + private snapshot(state: PanelState): GraphicalViewPanelSnapshot { + return Object.freeze({ + sourceUri: state.sourceUri, + sequence: state.sequence, + visible: state.visible, + disposed: state.disposed, + status: state.status, + lastSuccess: state.lastSuccess, + }); + } +} + +function createAcceptedResult(result: GraphicalViewRenderResult, svg: string): GraphicalViewAcceptedResult { + const targets = Object.freeze(result.targets.map(target => Object.freeze({...target}))); + const warnings = Object.freeze([...result.warnings]); + const targetById = new ImmutableTargetMap(targets.map(target => [target.id, target])); + return Object.freeze({uri: result.uri, svg, targets, warnings, targetById}); +} + +class ImmutableTargetMap implements ReadonlyMap { + private readonly map: Map; + + constructor(entries: readonly (readonly [K, V])[]) { + this.map = new Map(entries); + Object.freeze(this); + } + + get size(): number { + return this.map.size; + } + + get(key: K): V | undefined { + return this.map.get(key); + } + + has(key: K): boolean { + return this.map.has(key); + } + + forEach(callbackfn: (value: V, key: K, map: ReadonlyMap) => void, thisArg?: unknown): void { + this.map.forEach((value, key) => callbackfn.call(thisArg, value, key, this)); + } + + entries(): MapIterator<[K, V]> { + return this.map.entries(); + } + + keys(): MapIterator { + return this.map.keys(); + } + + values(): MapIterator { + return this.map.values(); + } + + [Symbol.iterator](): MapIterator<[K, V]> { + return this.entries(); + } + + get [Symbol.toStringTag](): string { + return 'ImmutableTargetMap'; + } +} + +function availableAgainStatus(state: PanelState): GraphicalViewStatus { + return Object.freeze({ + kind: 'stale', + reason: 'clientAvailable', + message: state.lastSuccess + ? 'The language server is available again. The retained diagram remains visible; use Refresh to update it.' + : 'The language server is available. Use Refresh to render the graphical view.', + }); +} + +function retainedAfterHiddenSaveStatus(state: PanelState): GraphicalViewStatus { + if (state.lastSuccess) { + return Object.freeze({kind: 'ready', message: 'Showing the retained graphical-view snapshot.'}); + } + return Object.freeze({ + kind: 'stale', + reason: 'noSnapshot', + message: 'No graphical-view snapshot is available. Reveal the panel and use Refresh to render one.', + }); +} + +function retainedAfterSourceLossStatus(state: PanelState): GraphicalViewStatus { + if (state.lastSuccess) { + return Object.freeze({kind: 'ready', message: 'Showing the retained graphical-view snapshot.'}); + } + return Object.freeze({ + kind: 'stale', + reason: 'sourceUnavailable', + message: 'The source document is no longer open. Reopen it and use Refresh to render the graphical view.', + }); +} + +function isPanelMessage(value: unknown): value is GraphicalViewPanelMessage { + if (!isRecord(value)) { + return false; + } + const keys = Object.keys(value); + if ((value.type === 'ready' || value.type === 'refresh') && keys.length === 1) { + return true; + } + return value.type === 'navigate' && keys.length === 2 && typeof value.targetId === 'string'; +} + +function isMethodNotFound(error: unknown): boolean { + if (isRecord(error) && error.code === -32601) { + return true; + } + return error instanceof Error && /method\s+not\s+found/i.test(error.message); +} + +function classifyFailure(message: string): string { + if (/timeout|timed out/i.test(message)) { + return 'timeout'; + } + if (/parse|syntax/i.test(message)) { + return 'parse'; + } + if (/load/i.test(message)) { + return 'loading'; + } + if (/transport|connection|socket/i.test(message)) { + return 'transport'; + } + return 'error'; +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/graphicalViewPanel.ts b/src/graphicalViewPanel.ts new file mode 100644 index 0000000..0bbda48 --- /dev/null +++ b/src/graphicalViewPanel.ts @@ -0,0 +1,101 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import {randomBytes} from 'node:crypto'; +import * as vscode from 'vscode'; +import {GraphicalViewDelivery, GraphicalViewPanelAdapter, GraphicalViewPanelFactory} from './graphicalView'; + +const VIEW_TYPE = 'turtle.graphicalView'; + +export class VscodeGraphicalViewPanelFactory implements GraphicalViewPanelFactory { + create(sourceUri: string): GraphicalViewPanelAdapter { + const uri = vscode.Uri.parse(sourceUri, true); + const name = uri.path.split('/').filter(Boolean).at(-1) ?? 'Aspect Model'; + const panel = vscode.window.createWebviewPanel(VIEW_TYPE, `Graphical View: ${name}`, vscode.ViewColumn.Beside, { + enableScripts: true, + enableForms: false, + enableCommandUris: false, + localResourceRoots: [], + }); + return new VscodeGraphicalViewPanel(panel); + } +} + +class VscodeGraphicalViewPanel implements GraphicalViewPanelAdapter { + constructor(private readonly panel: vscode.WebviewPanel) { + panel.webview.html = createShellHtml(panel.webview); + } + + get visible(): boolean { + return this.panel.visible; + } + + reveal(): void { + this.panel.reveal(undefined, false); + } + + deliver(delivery: GraphicalViewDelivery): void { + void this.panel.webview.postMessage(delivery); + } + + onDidDispose(listener: () => void): vscode.Disposable { + return this.panel.onDidDispose(listener); + } + + onDidChangeVisibility(listener: (visible: boolean) => void): vscode.Disposable { + return this.panel.onDidChangeViewState(event => listener(event.webviewPanel.visible)); + } + + onDidReceiveMessage(listener: (message: unknown) => void): vscode.Disposable { + return this.panel.webview.onDidReceiveMessage(listener); + } + + dispose(): void { + this.panel.dispose(); + } +} + +function createShellHtml(webview: vscode.Webview): string { + const nonce = randomBytes(16).toString('hex'); + return ` + + + + + + Graphical View + + + +

Preparing graphical view...

+

Diagram rendering is prepared. Secure SVG display is completed in Task 4.

+ + +`; +} diff --git a/src/graphicalViewProtocol.ts b/src/graphicalViewProtocol.ts new file mode 100644 index 0000000..a86a838 --- /dev/null +++ b/src/graphicalViewProtocol.ts @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import type * as vscode from 'vscode'; + +export const GRAPHICAL_VIEW_RENDER_REQUEST = 'turtle/graphicalView/render'; +export const GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST = 'turtle/graphicalView/resolveTarget'; +export const GRAPHICAL_VIEW_MARKER_PATTERN = /^gv-header-[a-z0-9]{16,32}$/; + +export type GraphicalViewRenderWarning = + | 'unsupportedUri' + | 'missingDocument' + | 'modelTooLarge' + | 'timeout' + | 'temporarilyUnresolvable'; + +export type GraphicalViewResolveTargetWarning = 'notFound' | 'ambiguous' | 'unsupportedUri' | 'temporarilyUnresolvable'; + +export interface GraphicalViewRenderParams { + uri: string; +} + +export interface GraphicalViewTarget { + id: string; + kind: 'elementHeader'; + elementUrn: string; +} + +export interface GraphicalViewRenderResult { + uri: string; + svg?: string | null; + targets: GraphicalViewTarget[]; + warnings: GraphicalViewRenderWarning[]; +} + +export interface GraphicalViewResolveTargetParams { + sourceUri: string; + elementUrn: string; +} + +export interface GraphicalViewPosition { + line: number; + character: number; +} + +export interface GraphicalViewLocation { + uri: string; + range: { + start: GraphicalViewPosition; + end: GraphicalViewPosition; + }; +} + +export interface GraphicalViewResolveTargetResult { + location?: GraphicalViewLocation | null; + warning?: GraphicalViewResolveTargetWarning | null; +} + +export interface GraphicalViewRequestClient { + isGraphicalViewAvailable(): boolean; + onDidChangeGraphicalViewAvailability(listener: (available: boolean) => void): vscode.Disposable; + renderGraphicalView(params: GraphicalViewRenderParams, token: vscode.CancellationToken): Thenable; + resolveGraphicalViewTarget( + params: GraphicalViewResolveTargetParams, + token?: vscode.CancellationToken, + ): Thenable; +} + +const RENDER_WARNINGS: ReadonlySet = new Set([ + 'unsupportedUri', + 'missingDocument', + 'modelTooLarge', + 'timeout', + 'temporarilyUnresolvable', +]); +const ASPECT_MODEL_URN_PATTERN = /^urn:samm:[^\s#]+#[^\s#]+$/; +const SVG_MARKER_PATTERN = /\bid\s*=\s*(["'])(gv-header-[a-z0-9]{16,32})\1/g; + +export function isGraphicalViewRenderResult(value: unknown): value is GraphicalViewRenderResult { + if (!isRecord(value) || typeof value.uri !== 'string' || !Array.isArray(value.targets) || !Array.isArray(value.warnings)) { + return false; + } + + if (value.svg !== undefined && value.svg !== null && typeof value.svg !== 'string') { + return false; + } + + if (!value.warnings.every(warning => typeof warning === 'string' && RENDER_WARNINGS.has(warning))) { + return false; + } + + if (!value.targets.every(isGraphicalViewTarget)) { + return false; + } + + if (value.svg === undefined || value.svg === null) { + return value.targets.length === 0 && value.warnings.length > 0; + } + + return value.svg.trim().length > 0 && hasConsistentSidecar(value.svg, value.targets); +} + +function isGraphicalViewTarget(value: unknown): value is GraphicalViewTarget { + return ( + isRecord(value) && + typeof value.id === 'string' && + GRAPHICAL_VIEW_MARKER_PATTERN.test(value.id) && + value.kind === 'elementHeader' && + typeof value.elementUrn === 'string' && + ASPECT_MODEL_URN_PATTERN.test(value.elementUrn) + ); +} + +function hasConsistentSidecar(svg: string, targets: GraphicalViewTarget[]): boolean { + const targetIds = new Set(); + for (const target of targets) { + if (targetIds.has(target.id)) { + return false; + } + targetIds.add(target.id); + } + + const svgIds = new Set(); + for (const match of svg.matchAll(SVG_MARKER_PATTERN)) { + const id = match[2]; + if (svgIds.has(id)) { + return false; + } + svgIds.add(id); + } + + return targetIds.size === svgIds.size && [...targetIds].every(id => svgIds.has(id)); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} diff --git a/src/languageClient.ts b/src/languageClient.ts index 2e12733..2858351 100644 --- a/src/languageClient.ts +++ b/src/languageClient.ts @@ -14,21 +14,49 @@ import { Trace } from 'vscode-jsonrpc'; import * as net from 'node:net'; import * as vscode from 'vscode'; -import { LanguageClient, LanguageClientOptions, State, StreamInfo } from 'vscode-languageclient/node'; +import {LanguageClient, LanguageClientOptions, State, StateChangeEvent, StreamInfo} from 'vscode-languageclient/node'; import type { RequestClient } from './aspectValidation'; +import { + GRAPHICAL_VIEW_RENDER_REQUEST, + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + GraphicalViewRenderParams, + GraphicalViewRenderResult, + GraphicalViewRequestClient, + GraphicalViewResolveTargetParams, + GraphicalViewResolveTargetResult, +} from './graphicalViewProtocol'; import type { ExtensionLogger } from './outputChannel'; const CLIENT_START_TIMEOUT_MS = 5000; -export class TurtleLanguageClient implements RequestClient { - private client: LanguageClient; +export interface LanguageClientAdapter { + readonly state: State; + setTrace(value: Trace): void; + start(): Promise; + stop(): Promise; + onDidChangeState(listener: (event: StateChangeEvent) => void): vscode.Disposable; + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Promise; +} + +export class TurtleLanguageClient implements RequestClient, GraphicalViewRequestClient { + private client: LanguageClientAdapter; + private readonly graphicalViewAvailability = new vscode.EventEmitter(); + private lastGraphicalViewAvailability = false; constructor( private outputChannel: ExtensionLogger, private readonly serverPort: number, - private readonly traceLevel: 'off' | 'messages' | 'verbose' = 'off' + private readonly traceLevel: 'off' | 'messages' | 'verbose' = 'off', + clientFactory?: () => LanguageClientAdapter, ) { - this.client = this.initLanguageClient(this.serverPort); + this.client = clientFactory ? clientFactory() : this.initLanguageClient(this.serverPort); + this.client.onDidChangeState(event => { + const available = event.newState === State.Running; + if (available !== this.lastGraphicalViewAvailability) { + this.lastGraphicalViewAvailability = available; + this.graphicalViewAvailability.fire(available); + } + }); } private toTrace(level: 'off' | 'messages' | 'verbose'): Trace { @@ -39,7 +67,7 @@ export class TurtleLanguageClient implements RequestClient { } } - private initLanguageClient(serverPort: number): LanguageClient { + private initLanguageClient(serverPort: number): LanguageClientAdapter { const serverOptions = async (): Promise => new Promise((resolve, reject) => { const socket = net.connect({ host: '127.0.0.1', port: serverPort }, () => { resolve({ reader: socket, writer: socket }); @@ -100,12 +128,31 @@ export class TurtleLanguageClient implements RequestClient { await this.client.stop(); } - sendRequest(method: string, params?: unknown): Promise { + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Promise { if (this.client.state === State.Stopped) { return Promise.reject(new Error('The Turtle language client is not connected.')); } - return this.client.sendRequest(method, params) as Promise; + return this.client.sendRequest(method, params, token) as Promise; + } + + isGraphicalViewAvailable(): boolean { + return this.client.state === State.Running; + } + + onDidChangeGraphicalViewAvailability(listener: (available: boolean) => void): vscode.Disposable { + return this.graphicalViewAvailability.event(listener); + } + + renderGraphicalView(params: GraphicalViewRenderParams, token: vscode.CancellationToken): Promise { + return this.sendRequest(GRAPHICAL_VIEW_RENDER_REQUEST, params, token); + } + + resolveGraphicalViewTarget( + params: GraphicalViewResolveTargetParams, + token?: vscode.CancellationToken, + ): Promise { + return this.sendRequest(GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, params, token); } } diff --git a/src/test/graphicalViewController.test.ts b/src/test/graphicalViewController.test.ts new file mode 100644 index 0000000..7604c56 --- /dev/null +++ b/src/test/graphicalViewController.test.ts @@ -0,0 +1,346 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {OPEN_GRAPHICAL_VIEW_COMMAND} from '../graphicalView'; +import {GraphicalViewRenderResult} from '../graphicalViewProtocol'; +import { + FakeGraphicalViewClient, + createGraphicalViewDocument, + createGraphicalViewHarness, + flushPromises, + successfulResult, +} from './graphicalViewTestHarness'; + +suite('GraphicalViewController', () => { + test('contributes the guarded Turtle editor command', () => { + const manifest = JSON.parse(readFileSync(join(__dirname, '..', '..', 'package.json'), 'utf8')) as { + contributes: {commands: Array>; menus: {'editor/context': Array>}}; + }; + const command = manifest.contributes.commands.find(candidate => candidate.command === OPEN_GRAPHICAL_VIEW_COMMAND); + const menu = manifest.contributes.menus['editor/context'].find(candidate => candidate.command === OPEN_GRAPHICAL_VIEW_COMMAND); + assert.equal(command?.enablement, 'editorLangId == turtle'); + assert.equal(menu?.when, 'resourceLangId == turtle'); + }); + + test('guards absent and non-Turtle active editors without a panel or request', async () => { + const harness = createGraphicalViewHarness(); + await harness.commands.execute(OPEN_GRAPHICAL_VIEW_COMMAND); + harness.window.activeTextEditor = {document: createGraphicalViewDocument('/tmp/not-turtle.txt', 'plaintext')}; + await harness.commands.execute(OPEN_GRAPHICAL_VIEW_COMMAND); + assert.equal(harness.window.warnings.length, 2); + assert.equal(harness.panels.panels.length, 0); + assert.equal(harness.client.requests.length, 0); + harness.controller.dispose(); + }); + + test('creates one panel and performs the initial typed render', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/initial.ttl'); + harness.window.activeTextEditor = {document}; + await harness.commands.execute(OPEN_GRAPHICAL_VIEW_COMMAND); + assert.equal(harness.panels.panels.length, 1); + assert.deepEqual(harness.client.requests[0].params, {uri: document.uri.toString()}); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.sequence, 1); + + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'ready'); + assert.equal(state?.lastSuccess?.svg, successfulResult(document).svg); + assert.equal(state?.lastSuccess?.targetById.size, 1); + harness.controller.dispose(); + }); + + test('reveals one panel per URI and keeps different URI panels independent', async () => { + const harness = createGraphicalViewHarness(); + const first = track(harness, '/tmp/first.ttl'); + const second = track(harness, '/tmp/second.ttl'); + await harness.controller.openGraphicalView(first); + await harness.controller.openGraphicalView(first); + assert.equal(harness.panels.panels.length, 1); + assert.equal(harness.panels.panels[0].revealCount, 1); + assert.equal(harness.client.requests.length, 1); + + await harness.controller.openGraphicalView(second); + assert.equal(harness.controller.getPanelCount(), 2); + assert.equal(harness.client.requests.length, 2); + assert.equal(harness.client.requests[1].params.uri, second.uri.toString()); + harness.controller.dispose(); + }); + + test('renders on manual Refresh and visible bound-main Save only', async () => { + const harness = createGraphicalViewHarness(); + const main = track(harness, '/tmp/main.ttl'); + const imported = track(harness, '/tmp/import.ttl'); + await harness.controller.openGraphicalView(main); + harness.panels.panels[0].emitMessage({type: 'refresh'}); + assert.equal(harness.client.requests.length, 2); + assert.equal(harness.client.requests[0].token.isCancellationRequested, true); + + harness.workspace.fireSave(imported); + assert.equal(harness.client.requests.length, 2); + harness.workspace.fireSave(main); + assert.equal(harness.client.requests.length, 3); + assert.equal(harness.client.requests[1].token.isCancellationRequested, true); + harness.controller.dispose(); + }); + + test('hidden Save invalidates pending work; typing and reveal do not render', async () => { + const harness = createGraphicalViewHarness(); + const main = track(harness, '/tmp/hidden.ttl'); + await harness.controller.openGraphicalView(main); + harness.client.requests[0].deferred.resolve(successfulResult(main)); + await flushPromises(); + const panel = harness.panels.panels[0]; + panel.emitMessage({type: 'refresh'}); + const pending = harness.client.requests[1]; + panel.setVisible(false); + panel.emitMessage({type: 'refresh'}); + assert.equal(harness.client.requests.length, 2); + const before = harness.controller.getPanelState(main.uri.toString())?.sequence; + harness.workspace.fireSave(main); + assert.equal(harness.client.requests.length, 2); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(harness.controller.getPanelState(main.uri.toString())?.sequence, (before ?? 0) + 1); + assert.equal(harness.controller.getPanelState(main.uri.toString())?.status.kind, 'ready'); + assert.ok(harness.controller.getPanelState(main.uri.toString())?.lastSuccess); + + panel.setVisible(true); + panel.emitMessage({type: 'ready'}); + panel.emitMessage({type: 'navigate', targetId: 'gv-header-0123456789abcdef'}); + assert.equal(harness.client.requests.length, 2); + harness.controller.dispose(); + }); + + test('source editor closure invalidates pending work and ignores its late failure', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/closed.ttl'); + await harness.controller.openGraphicalView(document); + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + const retained = harness.controller.getPanelState(document.uri.toString())?.lastSuccess; + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const pending = harness.client.requests[1]; + const sequence = harness.controller.getPanelState(document.uri.toString())?.sequence ?? 0; + + harness.workspace.closeSourceEditor(document); + assert.equal(harness.controller.getPanelCount(), 1); + assert.equal(harness.client.requests.length, 2); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.sequence, sequence + 1); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.lastSuccess, retained); + + pending.deferred.reject(new Error('late transport failure')); + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'ready'); + assert.equal(state?.lastSuccess, retained); + harness.controller.dispose(); + }); + + test('bound-source loss invalidates pending work and ignores its late success', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/lost.ttl'); + await harness.controller.openGraphicalView(document); + const retainedResult = successfulResult(document, 'aaaaaaaaaaaaaaaa'); + harness.client.requests[0].deferred.resolve(retainedResult); + await flushPromises(); + const retained = harness.controller.getPanelState(document.uri.toString())?.lastSuccess; + + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const pending = harness.client.requests[1]; + const sequence = harness.controller.getPanelState(document.uri.toString())?.sequence ?? 0; + harness.workspace.loseDocument(document); + assert.equal(harness.client.requests.length, 2); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.sequence, sequence + 1); + + pending.deferred.resolve(successfulResult(document, 'bbbbbbbbbbbbbbbb')); + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'ready'); + assert.equal(state?.lastSuccess, retained); + assert.equal(state?.lastSuccess?.svg, retainedResult.svg); + harness.controller.dispose(); + }); + + test('cancels superseded work and rejects obsolete success and failure completions', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/sequence.ttl'); + await harness.controller.openGraphicalView(document); + const first = harness.client.requests[0]; + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const second = harness.client.requests[1]; + assert.equal(first.token.isCancellationRequested, true); + + second.deferred.resolve(successfulResult(document, '1111111111111111')); + await flushPromises(); + first.deferred.resolve(successfulResult(document, '2222222222222222')); + await flushPromises(); + assert.match(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.svg ?? '', /1111111111111111/); + + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const obsoleteFailure = harness.client.requests[2]; + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const newest = harness.client.requests[3]; + obsoleteFailure.deferred.reject(new Error('obsolete transport failure')); + await flushPromises(); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'loading'); + newest.deferred.resolve(successfulResult(document, '3333333333333333')); + await flushPromises(); + assert.match(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.svg ?? '', /3333333333333333/); + harness.controller.dispose(); + }); + + test('rejects URI mismatches and malformed, duplicate, invalid-marker, and inconsistent sidecars', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/validation.ttl'); + await harness.controller.openGraphicalView(document); + const good = successfulResult(document); + harness.client.requests[0].deferred.resolve(good); + await flushPromises(); + + const invalidResults: GraphicalViewRenderResult[] = [ + {...good, uri: 'file:///tmp/other.ttl'}, + {...good, targets: [...good.targets, good.targets[0]]}, + {...good, targets: [{...good.targets[0], id: 'bad-marker'}]}, + {...good, targets: [{...good.targets[0], kind: 'notHeader' as 'elementHeader'}]}, + {...good, targets: [{...good.targets[0], elementUrn: 'not-a-urn'}]}, + {...good, svg: ''}, + ]; + + for (const invalid of invalidResults) { + harness.panels.panels[0].emitMessage({type: 'refresh'}); + harness.client.requests.at(-1)?.deferred.resolve(invalid); + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'stale'); + assert.equal(state?.lastSuccess?.svg, good.svg); + assert.equal(state?.lastSuccess?.targetById.get(good.targets[0].id)?.elementUrn, good.targets[0].elementUrn); + } + harness.controller.dispose(); + }); + + test('installs SVG and sidecar atomically and retains them for all failure classes', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/retention.ttl'); + await harness.controller.openGraphicalView(document); + const accepted = successfulResult(document, 'aaaaaaaaaaaaaaaa'); + harness.client.requests[0].deferred.resolve(accepted); + await flushPromises(); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.targetById.has(accepted.targets[0].id), true); + + const failures: Array = [ + {uri: document.uri.toString(), svg: null, targets: [], warnings: ['temporarilyUnresolvable']}, + {uri: document.uri.toString(), targets: [], warnings: ['timeout']}, + {uri: document.uri.toString(), svg: null, targets: [], warnings: ['modelTooLarge']}, + new Error('transport socket closed'), + new Error('generic failure'), + ]; + for (const failure of failures) { + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const request = harness.client.requests.at(-1); + if (failure instanceof Error) { + request?.deferred.reject(failure); + } else { + request?.deferred.resolve(failure); + } + await flushPromises(); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'stale'); + assert.equal(state?.lastSuccess?.svg, accepted.svg); + assert.equal(state?.lastSuccess?.targetById.has(accepted.targets[0].id), true); + } + harness.controller.dispose(); + }); + + test('detects MethodNotFound by code and recovers compatibility state after client replacement', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/unsupported.ttl'); + await harness.controller.openGraphicalView(document); + harness.client.requests[0].deferred.reject(Object.assign(new Error('localized message'), {code: -32601})); + await flushPromises(); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'unsupported'); + + const replacement = new FakeGraphicalViewClient(); + harness.controller.setClient(replacement); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'stale'); + assert.equal(replacement.requests.length, 0); + harness.panels.panels[0].emitMessage({type: 'refresh'}); + assert.equal(replacement.requests.length, 1); + harness.controller.dispose(); + }); + + test('handles explicit replacement and unexpected disconnect/reconnect without automatic render', async () => { + const client = new FakeGraphicalViewClient(); + const harness = createGraphicalViewHarness(client); + const document = track(harness, '/tmp/lifecycle.ttl'); + await harness.controller.openGraphicalView(document); + const pending = client.requests[0]; + harness.controller.setClient(undefined); + assert.equal(pending.token.isCancellationRequested, true); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'disconnected'); + + const replacement = new FakeGraphicalViewClient(); + harness.controller.setClient(replacement); + assert.equal(replacement.requests.length, 0); + replacement.setAvailable(false); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'disconnected'); + replacement.setAvailable(true); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'stale'); + assert.equal(replacement.requests.length, 0); + harness.controller.dispose(); + }); + + test('disposal removes ownership, cancels work, disposes listeners, and rejects late results', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/dispose.ttl'); + await harness.controller.openGraphicalView(document); + const request = harness.client.requests[0]; + const panel = harness.panels.panels[0]; + panel.dispose(); + assert.equal(harness.controller.getPanelCount(), 0); + assert.equal(request.token.isCancellationRequested, true); + assert.equal(panel.disposedListenerCount, 3); + request.deferred.resolve(successfulResult(document)); + await flushPromises(); + assert.equal(harness.controller.getPanelCount(), 0); + harness.controller.dispose(); + }); + + test('multiple panels neither leak nor cross-deliver results or status', async () => { + const harness = createGraphicalViewHarness(); + const first = track(harness, '/tmp/multi-one.ttl'); + const second = track(harness, '/tmp/multi-two.ttl'); + await harness.controller.openGraphicalView(first); + await harness.controller.openGraphicalView(second); + const firstDeliveryCount = harness.panels.panels[0].deliveries.length; + harness.client.requests[1].deferred.resolve(successfulResult(second, 'bbbbbbbbbbbbbbbb')); + await flushPromises(); + assert.equal(harness.panels.panels[0].deliveries.length, firstDeliveryCount); + assert.match(harness.controller.getPanelState(second.uri.toString())?.lastSuccess?.svg ?? '', /bbbbbbbbbbbbbbbb/); + assert.equal(harness.controller.getPanelState(first.uri.toString())?.lastSuccess, undefined); + harness.controller.dispose(); + assert.equal(harness.panels.panels[0].disposeCount, 1); + assert.equal(harness.panels.panels[1].disposeCount, 1); + }); +}); + +function track(harness: ReturnType, filePath: string) { + const document = createGraphicalViewDocument(filePath); + harness.workspace.available.add(document.uri.toString()); + return document; +} diff --git a/src/test/graphicalViewTestHarness.ts b/src/test/graphicalViewTestHarness.ts new file mode 100644 index 0000000..6ea0459 --- /dev/null +++ b/src/test/graphicalViewTestHarness.ts @@ -0,0 +1,286 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as vscode from 'vscode'; +import { + GraphicalViewCommands, + GraphicalViewController, + GraphicalViewDelivery, + GraphicalViewDocument, + GraphicalViewPanelAdapter, + GraphicalViewPanelFactory, + GraphicalViewWindow, + GraphicalViewWorkspace, +} from '../graphicalView'; +import { + GraphicalViewRenderParams, + GraphicalViewRenderResult, + GraphicalViewRequestClient, + GraphicalViewResolveTargetParams, + GraphicalViewResolveTargetResult, +} from '../graphicalViewProtocol'; +import type {ExtensionLogger} from '../outputChannel'; + +export interface RecordedRenderRequest { + readonly params: GraphicalViewRenderParams; + readonly token: vscode.CancellationToken; + readonly deferred: Deferred; +} + +export class FakeGraphicalViewClient implements GraphicalViewRequestClient { + readonly requests: RecordedRenderRequest[] = []; + private readonly listeners = new Set<(available: boolean) => void>(); + + constructor(private available = true) {} + + isGraphicalViewAvailable(): boolean { + return this.available; + } + + onDidChangeGraphicalViewAvailability(listener: (available: boolean) => void): vscode.Disposable { + this.listeners.add(listener); + return new vscode.Disposable(() => this.listeners.delete(listener)); + } + + renderGraphicalView(params: GraphicalViewRenderParams, token: vscode.CancellationToken): Promise { + const deferred = new Deferred(); + this.requests.push({params, token, deferred}); + return deferred.promise; + } + + resolveGraphicalViewTarget( + _params: GraphicalViewResolveTargetParams, + _token?: vscode.CancellationToken, + ): Promise { + return Promise.resolve({location: null, warning: 'temporarilyUnresolvable'}); + } + + setAvailable(available: boolean): void { + this.available = available; + for (const listener of [...this.listeners]) { + listener(available); + } + } +} + +export class FakeGraphicalViewPanel implements GraphicalViewPanelAdapter { + readonly deliveries: GraphicalViewDelivery[] = []; + revealCount = 0; + disposeCount = 0; + disposedListenerCount = 0; + private readonly disposeListeners = new Set<() => void>(); + private readonly visibilityListeners = new Set<(visible: boolean) => void>(); + private readonly messageListeners = new Set<(message: unknown) => void>(); + + constructor(public visible = true) {} + + reveal(): void { + this.revealCount += 1; + this.setVisible(true); + } + + deliver(delivery: GraphicalViewDelivery): void { + this.deliveries.push(delivery); + } + + onDidDispose(listener: () => void): vscode.Disposable { + this.disposeListeners.add(listener); + return this.listenerDisposable(this.disposeListeners, listener); + } + + onDidChangeVisibility(listener: (visible: boolean) => void): vscode.Disposable { + this.visibilityListeners.add(listener); + return this.listenerDisposable(this.visibilityListeners, listener); + } + + onDidReceiveMessage(listener: (message: unknown) => void): vscode.Disposable { + this.messageListeners.add(listener); + return this.listenerDisposable(this.messageListeners, listener); + } + + setVisible(visible: boolean): void { + if (this.visible === visible) { + return; + } + this.visible = visible; + for (const listener of [...this.visibilityListeners]) { + listener(visible); + } + } + + emitMessage(message: unknown): void { + for (const listener of [...this.messageListeners]) { + listener(message); + } + } + + dispose(): void { + if (this.disposeCount > 0) { + return; + } + this.disposeCount += 1; + for (const listener of [...this.disposeListeners]) { + listener(); + } + } + + private listenerDisposable(listeners: Set, listener: T): vscode.Disposable { + return new vscode.Disposable(() => { + if (listeners.delete(listener)) { + this.disposedListenerCount += 1; + } + }); + } +} + +export class FakeGraphicalViewPanelFactory implements GraphicalViewPanelFactory { + readonly panels: FakeGraphicalViewPanel[] = []; + + create(_sourceUri: string): FakeGraphicalViewPanel { + const panel = new FakeGraphicalViewPanel(); + this.panels.push(panel); + return panel; + } +} + +export function createGraphicalViewHarness(client = new FakeGraphicalViewClient()) { + const panels = new FakeGraphicalViewPanelFactory(); + const commands = new FakeCommands(); + const window = new FakeWindow(); + const workspace = new FakeWorkspace(); + const outputChannel = new FakeOutputChannel(); + const controller = new GraphicalViewController(client, panels, commands, window, workspace, outputChannel); + const context = {subscriptions: [] as vscode.Disposable[]}; + controller.register(context); + return {client, commands, context, controller, outputChannel, panels, window, workspace}; +} + +export function createGraphicalViewDocument(filePath: string, languageId = 'turtle'): GraphicalViewDocument { + return {languageId, uri: vscode.Uri.file(filePath)}; +} + +export function successfulResult(document: GraphicalViewDocument, suffix = '0123456789abcdef'): GraphicalViewRenderResult { + const id = `gv-header-${suffix}`; + return { + uri: document.uri.toString(), + svg: `Aspect`, + targets: [{id, kind: 'elementHeader', elementUrn: 'urn:samm:example.graphical:1.0.0#Aspect'}], + warnings: [], + }; +} + +export async function flushPromises(): Promise { + await new Promise(resolve => setImmediate(resolve)); +} + +class FakeCommands implements GraphicalViewCommands { + private readonly callbacks = new Map unknown>(); + + registerCommand(command: string, callback: () => unknown): vscode.Disposable { + this.callbacks.set(command, callback); + return new vscode.Disposable(() => this.callbacks.delete(command)); + } + + async execute(command: string): Promise { + return this.callbacks.get(command)?.(); + } +} + +class FakeWindow implements GraphicalViewWindow { + activeTextEditor: {document: GraphicalViewDocument} | undefined; + readonly warnings: string[] = []; + + showWarningMessage(message: string): Promise { + this.warnings.push(message); + return Promise.resolve(undefined); + } +} + +class FakeWorkspace implements GraphicalViewWorkspace { + readonly available = new Set(); + private saveListener: ((document: GraphicalViewDocument) => void) | undefined; + private availabilityListener: ((sourceUri: string, available: boolean) => void) | undefined; + + onDidSaveTextDocument(listener: (document: GraphicalViewDocument) => void): vscode.Disposable { + this.saveListener = listener; + return new vscode.Disposable(() => { + if (this.saveListener === listener) { + this.saveListener = undefined; + } + }); + } + + onDidChangeDocumentAvailability(listener: (sourceUri: string, available: boolean) => void): vscode.Disposable { + this.availabilityListener = listener; + return new vscode.Disposable(() => { + if (this.availabilityListener === listener) { + this.availabilityListener = undefined; + } + }); + } + + isDocumentAvailable(uri: string): boolean { + return this.available.has(uri); + } + + fireSave(document: GraphicalViewDocument): void { + this.saveListener?.(document); + } + + loseDocument(document: GraphicalViewDocument): void { + this.available.delete(document.uri.toString()); + this.availabilityListener?.(document.uri.toString(), false); + } + + closeSourceEditor(document: GraphicalViewDocument): void { + this.available.delete(document.uri.toString()); + this.availabilityListener?.(document.uri.toString(), false); + } +} + +class FakeOutputChannel implements ExtensionLogger { + readonly lines: string[] = []; + trace(message: string): void { + this.lines.push(`[trace] ${message}`); + } + info(message: string): void { + this.lines.push(`[info] ${message}`); + } + warn(message: string): void { + this.lines.push(`[warn] ${message}`); + } + error(message: string | Error): void { + this.lines.push(`[error] ${message instanceof Error ? message.message : message}`); + } +} + +class Deferred { + readonly promise: Promise; + private resolvePromise!: (value: T) => void; + private rejectPromise!: (reason: unknown) => void; + + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolvePromise = resolve; + this.rejectPromise = reject; + }); + } + + resolve(value: T): void { + this.resolvePromise(value); + } + + reject(reason: unknown): void { + this.rejectPromise(reason); + } +} diff --git a/src/test/languageClientGraphicalView.test.ts b/src/test/languageClientGraphicalView.test.ts new file mode 100644 index 0000000..e3691ac --- /dev/null +++ b/src/test/languageClientGraphicalView.test.ts @@ -0,0 +1,141 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import {Trace} from 'vscode-jsonrpc'; +import {State, StateChangeEvent} from 'vscode-languageclient/node'; +import { + GRAPHICAL_VIEW_RENDER_REQUEST, + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + GraphicalViewRenderResult, + GraphicalViewResolveTargetResult, + isGraphicalViewRenderResult, +} from '../graphicalViewProtocol'; +import {LanguageClientAdapter, TurtleLanguageClient} from '../languageClient'; +import type {ExtensionLogger} from '../outputChannel'; + +suite('TurtleLanguageClient graphical-view integration', () => { + test('forwards exact typed methods and render cancellation token', async () => { + const adapter = new FakeLanguageClientAdapter(); + const client = new TurtleLanguageClient(new FakeLogger(), 1846, 'off', () => adapter); + await client.connect(); + const cancellation = new vscode.CancellationTokenSource(); + + const render = await client.renderGraphicalView({uri: 'file:///model.ttl'}, cancellation.token); + const resolve = await client.resolveGraphicalViewTarget({ + sourceUri: 'file:///model.ttl', + elementUrn: 'urn:samm:example:1.0.0#Aspect', + }); + + assert.equal(render.uri, 'file:///model.ttl'); + assert.equal(resolve.warning, 'notFound'); + assert.equal(adapter.requests[0].method, GRAPHICAL_VIEW_RENDER_REQUEST); + assert.equal(adapter.requests[0].token, cancellation.token); + assert.equal(adapter.requests[1].method, GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST); + cancellation.dispose(); + }); + + test('exposes disconnect and reconnect transitions once per availability change', async () => { + const adapter = new FakeLanguageClientAdapter(); + const client = new TurtleLanguageClient(new FakeLogger(), 1846, 'off', () => adapter); + const events: boolean[] = []; + const subscription = client.onDidChangeGraphicalViewAvailability(available => events.push(available)); + + await client.connect(); + adapter.transition(State.Starting); + adapter.transition(State.Stopped); + adapter.transition(State.Running); + assert.deepEqual(events, [true, false, true]); + subscription.dispose(); + }); + + test('accepts Task 2 render warning JSON with omitted or explicit-null svg', () => { + const omittedSvg = JSON.parse( + '{"uri":"file:///model.ttl","targets":[],"warnings":["timeout"]}', + ) as unknown; + const nullSvg = JSON.parse( + '{"uri":"file:///model.ttl","svg":null,"targets":[],"warnings":["modelTooLarge"]}', + ) as unknown; + const missingSuccessfulSvg = JSON.parse( + '{"uri":"file:///model.ttl","targets":[],"warnings":[]}', + ) as unknown; + + assert.equal(isGraphicalViewRenderResult(omittedSvg), true); + assert.equal(isGraphicalViewRenderResult(nullSvg), true); + assert.equal(isGraphicalViewRenderResult(missingSuccessfulSvg), false); + }); + + test('models omitted and explicit-null resolve result fields from Task 2 JSON', () => { + const omittedLocation = JSON.parse('{"warning":"notFound"}') as GraphicalViewResolveTargetResult; + const omittedWarning = JSON.parse( + '{"location":{"uri":"file:///model.ttl","range":{"start":{"line":1,"character":2},"end":{"line":1,"character":3}}}}', + ) as GraphicalViewResolveTargetResult; + const explicitNulls = JSON.parse('{"location":null,"warning":null}') as GraphicalViewResolveTargetResult; + + assert.equal(omittedLocation.location, undefined); + assert.equal(omittedLocation.warning, 'notFound'); + assert.equal(omittedWarning.warning, undefined); + assert.equal(omittedWarning.location?.uri, 'file:///model.ttl'); + assert.equal(explicitNulls.location, null); + assert.equal(explicitNulls.warning, null); + }); +}); + +class FakeLanguageClientAdapter implements LanguageClientAdapter { + state = State.Stopped; + readonly requests: Array<{method: string; params: unknown; token: vscode.CancellationToken | undefined}> = []; + private readonly listeners = new Set<(event: StateChangeEvent) => void>(); + + setTrace(_value: Trace): void {} + + start(): Promise { + this.transition(State.Running); + return Promise.resolve(); + } + + stop(): Promise { + this.transition(State.Stopped); + return Promise.resolve(); + } + + onDidChangeState(listener: (event: StateChangeEvent) => void): vscode.Disposable { + this.listeners.add(listener); + return new vscode.Disposable(() => this.listeners.delete(listener)); + } + + sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Promise { + this.requests.push({method, params, token}); + if (method === GRAPHICAL_VIEW_RENDER_REQUEST) { + const result: GraphicalViewRenderResult = {uri: 'file:///model.ttl', svg: '', targets: [], warnings: []}; + return Promise.resolve(result as R); + } + const result: GraphicalViewResolveTargetResult = {location: null, warning: 'notFound'}; + return Promise.resolve(result as R); + } + + transition(newState: State): void { + const oldState = this.state; + this.state = newState; + for (const listener of [...this.listeners]) { + listener({oldState, newState}); + } + } +} + +class FakeLogger implements ExtensionLogger { + trace(_message: string): void {} + info(_message: string): void {} + warn(_message: string): void {} + error(_message: string | Error): void {} +} From 4438a2f2f9121d6e6eb7186b2c7f7dbffe572c60 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Thu, 27 Aug 2026 15:21:49 +0300 Subject: [PATCH 02/15] Enhance graphical view functionality and add webview assets --- package-lock.json | 17 + package.json | 6 +- src/extension.ts | 2 +- src/graphicalView.ts | 313 ++++++++++++++++-- src/graphicalViewPanel.ts | 101 ++++-- src/test/graphicalViewController.test.ts | 210 ++++++++++++ src/test/graphicalViewPanel.test.ts | 100 ++++++ src/test/graphicalViewTestHarness.ts | 43 ++- .../graphicalViewWebviewLifecycle.test.ts | 197 +++++++++++ src/webview/RobotoCondensed-NOTICE.txt | 19 ++ src/webview/RobotoCondensed-Regular.ttf | Bin 0 -> 140396 bytes src/webview/sanitizer-contract.js | 155 +++++++++ src/webview/webview.css | 95 ++++++ src/webview/webview.js | 229 +++++++++++++ 14 files changed, 1428 insertions(+), 59 deletions(-) create mode 100644 src/test/graphicalViewPanel.test.ts create mode 100644 src/test/graphicalViewWebviewLifecycle.test.ts create mode 100644 src/webview/RobotoCondensed-NOTICE.txt create mode 100644 src/webview/RobotoCondensed-Regular.ttf create mode 100644 src/webview/sanitizer-contract.js create mode 100644 src/webview/webview.css create mode 100644 src/webview/webview.js diff --git a/package-lock.json b/package-lock.json index 6a2d563..591143d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.0.1", "license": "MPL-2.0", "dependencies": { + "dompurify": "3.4.13", "extract-zip": "^2.0.1", "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" @@ -561,6 +562,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, "node_modules/@types/vscode": { "version": "1.120.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", @@ -1422,6 +1430,15 @@ "node": ">=0.3.1" } }, + "node_modules/dompurify": { + "version": "3.4.13", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", + "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index 87ade76..749b751 100644 --- a/package.json +++ b/package.json @@ -159,14 +159,15 @@ "vscode:prepublish": "npm run build", "watch": "tsc -watch -p ./", "pretest": "npm run build && npm run lint", - "build": "tsc -p tsconfig.json", + "build": "tsc -p tsconfig.json && node scripts/copy-webview-assets.mjs", "build-watch": "tsc -p tsconfig.json --watch", "prettier": "prettier --config .prettierrc --write './src/**/*{.ts,.js,.json}'", "test": "vscode-test --config .vscode-test.mjs", "test:prettier": "prettier --config .prettierrc --list-different './src/**/*{.ts,.js,.json}'", "test:coverage": "vscode-test --config .vscode-test.mjs --coverage --coverage-reporter text --coverage-reporter lcov", "lint": "eslint src --ext .ts", - "lint:fix": "eslint src --ext .ts --fix" + "lint:fix": "eslint src --ext .ts --fix", + "test:webview-assets": "node scripts/verify-webview-assets.mjs" }, "devDependencies": { "@types/jest": "^30.0.0", @@ -179,6 +180,7 @@ "typescript-eslint": "^8.56.1" }, "dependencies": { + "dompurify": "3.4.13", "extract-zip": "^2.0.1", "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" diff --git a/src/extension.ts b/src/extension.ts index f396302..f573ad9 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -48,7 +48,7 @@ export async function activate(ctx: vscode.ExtensionContext): Promise { aspectValidationController.register(context); graphicalViewController = new GraphicalViewController( undefined, - new VscodeGraphicalViewPanelFactory(), + new VscodeGraphicalViewPanelFactory(context.extensionUri), vscode.commands, vscode.window, { diff --git a/src/graphicalView.ts b/src/graphicalView.ts index f412aa1..5c4c09e 100644 --- a/src/graphicalView.ts +++ b/src/graphicalView.ts @@ -14,6 +14,7 @@ import * as vscode from 'vscode'; import type {ExtensionLogger} from './outputChannel'; import { + GRAPHICAL_VIEW_MARKER_PATTERN, GraphicalViewRenderResult, GraphicalViewRenderWarning, GraphicalViewRequestClient, @@ -31,6 +32,7 @@ export type GraphicalViewStatus = | Readonly<{kind: 'disconnected'; message: string}>; export interface GraphicalViewAcceptedResult { + readonly version: number; readonly uri: string; readonly svg: string; readonly targets: readonly Readonly[]; @@ -39,10 +41,15 @@ export interface GraphicalViewAcceptedResult { } export type GraphicalViewDelivery = - | Readonly<{type: 'status'; uri: string; status: GraphicalViewStatus}> - | Readonly<{type: 'render'; uri: string; svg: string; warnings: readonly GraphicalViewRenderWarning[]}>; + | Readonly<{type: 'status'; status: GraphicalViewStatus}> + | Readonly<{type: 'render'; version: number; svg: string}>; -export type GraphicalViewPanelMessage = Readonly<{type: 'ready'}> | Readonly<{type: 'refresh'}> | Readonly<{type: 'navigate'; targetId: string}>; +export type GraphicalViewPanelMessage = + | Readonly<{type: 'ready'}> + | Readonly<{type: 'refresh'}> + | Readonly<{type: 'rendered'; version: number}> + | Readonly<{type: 'renderError'; version: number; reason: 'sanitizationFailed'}> + | Readonly<{type: 'navigate'; version: number; targetId: string}>; export interface GraphicalViewPanelAdapter extends vscode.Disposable { readonly visible: boolean; @@ -65,6 +72,7 @@ export interface GraphicalViewDocument { export interface GraphicalViewWindow { readonly activeTextEditor: {readonly document: GraphicalViewDocument} | undefined; showWarningMessage(message: string): Thenable; + showTextDocument(uri: vscode.Uri, options?: vscode.TextDocumentShowOptions): Thenable; } export interface GraphicalViewWorkspace { @@ -99,8 +107,16 @@ interface PanelState { visible: boolean; disposed: boolean; cancellation: vscode.CancellationTokenSource | undefined; + navigationCancellation: vscode.CancellationTokenSource | undefined; + nextDisplayVersion: number; status: GraphicalViewStatus; lastSuccess: GraphicalViewAcceptedResult | undefined; + pendingDelivery: PendingDelivery | undefined; +} + +interface PendingDelivery { + readonly accepted: GraphicalViewAcceptedResult; + readonly requestSequence: number; } const DISCONNECTED_STATUS: GraphicalViewStatus = Object.freeze({ @@ -166,8 +182,11 @@ export class GraphicalViewController implements vscode.Disposable { visible: panel.visible, disposed: false, cancellation: undefined, + navigationCancellation: undefined, + nextDisplayVersion: 0, status: Object.freeze({kind: 'loading', message: 'Preparing graphical view...'}), lastSuccess: undefined, + pendingDelivery: undefined, }; this.panels.set(sourceUri, state); state.subscriptions.push( @@ -272,7 +291,7 @@ export class GraphicalViewController implements vscode.Disposable { } state.visible = visible; if (visible) { - this.deliverCurrentState(state); + this.deliverStatus(state); } } @@ -290,8 +309,14 @@ export class GraphicalViewController implements vscode.Disposable { void this.requestRender(state, 'manual'); } return; + case 'rendered': + this.handleRendered(state, message.version); + return; + case 'renderError': + this.handleRenderError(state, message.version); + return; case 'navigate': - // Task 4 validates current-sidecar membership and resolves navigation. + void this.navigateToTarget(state, message); return; } } @@ -335,7 +360,7 @@ export class GraphicalViewController implements vscode.Disposable { } state.cancellation = undefined; cancellation.dispose(); - this.handleRenderResult(state, result); + this.handleRenderResult(state, result, sequence); } catch (error) { if (!this.isCurrentRequest(state, sourceUri, sequence, cancellation)) { return; @@ -346,7 +371,7 @@ export class GraphicalViewController implements vscode.Disposable { } } - private handleRenderResult(state: PanelState, result: unknown): void { + private handleRenderResult(state: PanelState, result: unknown, requestSequence: number): void { if (!isGraphicalViewRenderResult(result)) { this.setStale(state, 'invalidResponse', 'The language server returned an invalid graphical-view response.'); return; @@ -362,11 +387,127 @@ export class GraphicalViewController implements vscode.Disposable { return; } - const accepted = createAcceptedResult(result, result.svg); - state.lastSuccess = accepted; - state.status = Object.freeze({kind: 'ready', message: 'Graphical view is up to date.'}); - state.panel.deliver(Object.freeze({type: 'render', uri: accepted.uri, svg: accepted.svg, warnings: accepted.warnings})); - this.deliverStatus(state); + const accepted = createAcceptedResult(result, result.svg, ++state.nextDisplayVersion); + state.pendingDelivery = Object.freeze({accepted, requestSequence}); + state.panel.deliver(Object.freeze({type: 'render', version: accepted.version, svg: accepted.svg})); + } + + private handleRendered(state: PanelState, version: number): void { + const pending = state.pendingDelivery; + if (pending?.accepted.version === version) { + this.cancelNavigation(state); + state.lastSuccess = pending.accepted; + state.pendingDelivery = undefined; + if (state.sequence === pending.requestSequence) { + this.setStatus(state, Object.freeze({kind: 'ready', message: 'Graphical view is up to date.'})); + } + return; + } + + // An acknowledgement for a rehydrated last-successful snapshot must not + // clear a newer stale/error status. + } + + private handleRenderError(state: PanelState, version: number): void { + if (state.pendingDelivery?.accepted.version === version) { + state.pendingDelivery = undefined; + } else if (state.lastSuccess?.version !== version) { + return; + } + this.outputChannel.warn('Graphical view rejected an SVG payload at the secure rendering boundary.'); + this.setStale( + state, + 'sanitizationFailed', + 'The new diagram could not be displayed safely. The last successful diagram is retained.', + ); + } + + private async navigateToTarget( + state: PanelState, + message: Readonly<{type: 'navigate'; version: number; targetId: string}>, + ): Promise { + const accepted = state.lastSuccess; + const target = accepted?.version === message.version ? accepted.targetById.get(message.targetId) : undefined; + if (!accepted || !target || target.kind !== 'elementHeader' || !GRAPHICAL_VIEW_MARKER_PATTERN.test(message.targetId)) { + return; + } + + const client = this.client; + if (!client?.isGraphicalViewAvailable()) { + await this.warnNavigation('The graphical target is temporarily unavailable because the language server is disconnected.'); + return; + } + + this.cancelNavigation(state); + const cancellation = this.createCancellationSource(); + state.navigationCancellation = cancellation; + try { + const response = await client.resolveGraphicalViewTarget( + {sourceUri: state.sourceUri, elementUrn: target.elementUrn}, + cancellation.token, + ); + if (!this.isCurrentNavigation(state, accepted, target, cancellation)) { + return; + } + state.navigationCancellation = undefined; + cancellation.dispose(); + + const resolved = validateResolveTargetResult(response); + if (resolved.kind === 'warning') { + await this.warnNavigation(resolveWarningMessage(resolved.warning)); + return; + } + if (resolved.kind === 'invalid') { + await this.warnNavigation('The language server returned an invalid graphical target location.'); + return; + } + + try { + const editor = await this.window.showTextDocument(resolved.uri, {preview: false}); + if (!this.isCurrentNavigationResult(state, accepted, target)) { + return; + } + const range = new vscode.Range( + resolved.start.line, + resolved.start.character, + resolved.end.line, + resolved.end.character, + ); + editor.selection = new vscode.Selection(range.start, range.end); + editor.revealRange(range, vscode.TextEditorRevealType.InCenterIfOutsideViewport); + } catch (_error) { + await this.warnNavigation('The graphical target could not be opened in an editor.'); + } + } catch (_error) { + if (this.isCurrentNavigation(state, accepted, target, cancellation)) { + state.navigationCancellation = undefined; + cancellation.dispose(); + await this.warnNavigation('The graphical target is temporarily unavailable.'); + } + } + } + + private isCurrentNavigation( + state: PanelState, + accepted: GraphicalViewAcceptedResult, + target: Readonly, + cancellation: vscode.CancellationTokenSource, + ): boolean { + return state.navigationCancellation === cancellation && this.isCurrentNavigationResult(state, accepted, target); + } + + private isCurrentNavigationResult( + state: PanelState, + accepted: GraphicalViewAcceptedResult, + target: Readonly, + ): boolean { + return this.isCurrent(state) + && state.lastSuccess === accepted + && accepted.targetById.get(target.id) === target; + } + + private async warnNavigation(message: string): Promise { + await this.window.showWarningMessage(message); } private handleWarningResult(state: PanelState, warnings: readonly GraphicalViewRenderWarning[]): void { @@ -403,7 +544,9 @@ export class GraphicalViewController implements vscode.Disposable { } const detail = error instanceof Error ? error.message : String(error); - this.setStale(state, classifyFailure(detail), `Graphical rendering failed: ${detail}. The last successful diagram is retained.`); + const reason = classifyFailure(detail); + this.outputChannel.warn(`Graphical view render request failed (${reason}).`); + this.setStale(state, reason, 'Graphical rendering failed. The last successful diagram is retained.'); } private setStale(state: PanelState, reason: string, message: string): void { @@ -420,14 +563,14 @@ export class GraphicalViewController implements vscode.Disposable { private deliverCurrentState(state: PanelState): void { this.deliverStatus(state); - const accepted = state.lastSuccess; + const accepted = state.pendingDelivery?.accepted ?? state.lastSuccess; if (accepted) { - state.panel.deliver(Object.freeze({type: 'render', uri: accepted.uri, svg: accepted.svg, warnings: accepted.warnings})); + state.panel.deliver(Object.freeze({type: 'render', version: accepted.version, svg: accepted.svg})); } } private deliverStatus(state: PanelState): void { - state.panel.deliver(Object.freeze({type: 'status', uri: state.sourceUri, status: state.status})); + state.panel.deliver(Object.freeze({type: 'status', status: state.status})); } private invalidate(state: PanelState): void { @@ -435,6 +578,8 @@ export class GraphicalViewController implements vscode.Disposable { return; } this.cancelCurrent(state); + this.cancelNavigation(state); + state.pendingDelivery = undefined; state.sequence += 1; } @@ -447,6 +592,15 @@ export class GraphicalViewController implements vscode.Disposable { } } + private cancelNavigation(state: PanelState): void { + const cancellation = state.navigationCancellation; + state.navigationCancellation = undefined; + if (cancellation) { + cancellation.cancel(); + cancellation.dispose(); + } + } + private isCurrentRequest( state: PanelState, sourceUri: string, @@ -465,6 +619,7 @@ export class GraphicalViewController implements vscode.Disposable { return; } this.cancelCurrent(state); + this.cancelNavigation(state); state.sequence += 1; state.disposed = true; this.panels.delete(state.sourceUri); @@ -488,11 +643,11 @@ export class GraphicalViewController implements vscode.Disposable { } } -function createAcceptedResult(result: GraphicalViewRenderResult, svg: string): GraphicalViewAcceptedResult { +function createAcceptedResult(result: GraphicalViewRenderResult, svg: string, version: number): GraphicalViewAcceptedResult { const targets = Object.freeze(result.targets.map(target => Object.freeze({...target}))); const warnings = Object.freeze([...result.warnings]); const targetById = new ImmutableTargetMap(targets.map(target => [target.id, target])); - return Object.freeze({uri: result.uri, svg, targets, warnings, targetById}); + return Object.freeze({version, uri: result.uri, svg, targets, warnings, targetById}); } class ImmutableTargetMap implements ReadonlyMap { @@ -576,11 +731,129 @@ function isPanelMessage(value: unknown): value is GraphicalViewPanelMessage { if (!isRecord(value)) { return false; } - const keys = Object.keys(value); + const keys = Object.keys(value).sort(); if ((value.type === 'ready' || value.type === 'refresh') && keys.length === 1) { return true; } - return value.type === 'navigate' && keys.length === 2 && typeof value.targetId === 'string'; + if (value.type === 'rendered') { + return keys.length === 2 && keys[0] === 'type' && keys[1] === 'version' && isDisplayedVersion(value.version); + } + if (value.type === 'renderError') { + return keys.length === 3 + && keys[0] === 'reason' + && keys[1] === 'type' + && keys[2] === 'version' + && value.reason === 'sanitizationFailed' + && isDisplayedVersion(value.version); + } + return value.type === 'navigate' + && keys.length === 3 + && keys[0] === 'targetId' + && keys[1] === 'type' + && keys[2] === 'version' + && isDisplayedVersion(value.version) + && typeof value.targetId === 'string' + && GRAPHICAL_VIEW_MARKER_PATTERN.test(value.targetId); +} + +function isDisplayedVersion(value: unknown): value is number { + return Number.isSafeInteger(value) && (value as number) > 0; +} + +type ValidatedResolveTarget = + | Readonly<{kind: 'location'; uri: vscode.Uri; start: vscode.Position; end: vscode.Position}> + | Readonly<{kind: 'warning'; warning: GraphicalViewResolveWarning}> + | Readonly<{kind: 'invalid'}>; + +type GraphicalViewResolveWarning = 'notFound' | 'ambiguous' | 'unsupportedUri' | 'temporarilyUnresolvable'; + +const RESOLVE_WARNINGS: ReadonlySet = new Set([ + 'notFound', + 'ambiguous', + 'unsupportedUri', + 'temporarilyUnresolvable', +]); + +function validateResolveTargetResult(value: unknown): ValidatedResolveTarget { + if (!isRecord(value) || !hasOnlyKeys(value, ['location', 'warning'])) { + return {kind: 'invalid'}; + } + + const warning = value.warning; + if (warning !== undefined && warning !== null && (typeof warning !== 'string' || !RESOLVE_WARNINGS.has(warning))) { + return {kind: 'invalid'}; + } + const location = value.location; + if (location === undefined || location === null) { + return typeof warning === 'string' + ? {kind: 'warning', warning: warning as GraphicalViewResolveWarning} + : {kind: 'invalid'}; + } + if (warning !== undefined && warning !== null) { + return {kind: 'invalid'}; + } + if (!isRecord(location) || !hasExactKeys(location, ['range', 'uri']) || typeof location.uri !== 'string') { + return {kind: 'invalid'}; + } + const range = location.range; + if (!isRecord(range) || !hasExactKeys(range, ['end', 'start'])) { + return {kind: 'invalid'}; + } + const start = validatePosition(range.start); + const end = validatePosition(range.end); + if (!start || !end || start.isAfter(end)) { + return {kind: 'invalid'}; + } + + try { + const uri = vscode.Uri.parse(location.uri, true); + if (uri.scheme !== 'file' + || uri.authority !== '' + || !uri.path.startsWith('/') + || uri.query !== '' + || uri.fragment !== '' + || uri.fsPath.includes('\0')) { + return {kind: 'warning', warning: 'unsupportedUri'}; + } + return {kind: 'location', uri, start, end}; + } catch (_error) { + return {kind: 'invalid'}; + } +} + +function validatePosition(value: unknown): vscode.Position | undefined { + if (!isRecord(value) + || !hasExactKeys(value, ['character', 'line']) + || !Number.isSafeInteger(value.line) + || !Number.isSafeInteger(value.character) + || (value.line as number) < 0 + || (value.character as number) < 0) { + return undefined; + } + return new vscode.Position(value.line as number, value.character as number); +} + +function hasOnlyKeys(value: Record, allowedKeys: readonly string[]): boolean { + const allowed = new Set(allowedKeys); + return Object.keys(value).every(key => allowed.has(key)); +} + +function hasExactKeys(value: Record, expectedKeys: readonly string[]): boolean { + const actualKeys = Object.keys(value).sort(); + return actualKeys.length === expectedKeys.length && actualKeys.every((key, index) => key === expectedKeys[index]); +} + +function resolveWarningMessage(warning: GraphicalViewResolveWarning): string { + switch (warning) { + case 'notFound': + return 'The graphical target no longer exists in the current model.'; + case 'ambiguous': + return 'The graphical target is ambiguous in the current model.'; + case 'unsupportedUri': + return 'The graphical target is not a local file and cannot be opened.'; + case 'temporarilyUnresolvable': + return 'The graphical target is temporarily unavailable. Fix any model syntax errors and try again.'; + } } function isMethodNotFound(error: unknown): boolean { diff --git a/src/graphicalViewPanel.ts b/src/graphicalViewPanel.ts index 0bbda48..e04b411 100644 --- a/src/graphicalViewPanel.ts +++ b/src/graphicalViewPanel.ts @@ -16,24 +16,31 @@ import * as vscode from 'vscode'; import {GraphicalViewDelivery, GraphicalViewPanelAdapter, GraphicalViewPanelFactory} from './graphicalView'; const VIEW_TYPE = 'turtle.graphicalView'; +export const WEBVIEW_ASSET_DIRECTORY = Object.freeze(['out', 'webview'] as const); +export const WEBVIEW_SCRIPT_ORDER = Object.freeze(['purify.min.js', 'sanitizer-contract.js', 'webview.js'] as const); export class VscodeGraphicalViewPanelFactory implements GraphicalViewPanelFactory { + constructor(private readonly extensionUri: vscode.Uri) {} + create(sourceUri: string): GraphicalViewPanelAdapter { const uri = vscode.Uri.parse(sourceUri, true); const name = uri.path.split('/').filter(Boolean).at(-1) ?? 'Aspect Model'; - const panel = vscode.window.createWebviewPanel(VIEW_TYPE, `Graphical View: ${name}`, vscode.ViewColumn.Beside, { - enableScripts: true, - enableForms: false, - enableCommandUris: false, - localResourceRoots: [], - }); - return new VscodeGraphicalViewPanel(panel); + const panel = vscode.window.createWebviewPanel( + VIEW_TYPE, + `Graphical View: ${name}`, + vscode.ViewColumn.Beside, + createGraphicalViewPanelOptions(this.extensionUri), + ); + return new VscodeGraphicalViewPanel(panel, this.extensionUri); } } class VscodeGraphicalViewPanel implements GraphicalViewPanelAdapter { - constructor(private readonly panel: vscode.WebviewPanel) { - panel.webview.html = createShellHtml(panel.webview); + constructor( + private readonly panel: vscode.WebviewPanel, + extensionUri: vscode.Uri, + ) { + panel.webview.html = createShellHtml(panel.webview, extensionUri); } get visible(): boolean { @@ -65,37 +72,65 @@ class VscodeGraphicalViewPanel implements GraphicalViewPanelAdapter { } } -function createShellHtml(webview: vscode.Webview): string { - const nonce = randomBytes(16).toString('hex'); +export function webviewAssetDirectory(extensionUri: vscode.Uri): vscode.Uri { + return vscode.Uri.joinPath(extensionUri, ...WEBVIEW_ASSET_DIRECTORY); +} + +export function createGraphicalViewPanelOptions(extensionUri: vscode.Uri): vscode.WebviewPanelOptions & vscode.WebviewOptions { + return { + enableScripts: true, + enableForms: false, + enableCommandUris: false, + localResourceRoots: [webviewAssetDirectory(extensionUri)], + }; +} + +export function createShellHtml( + webview: Pick, + extensionUri: vscode.Uri, + nonce = randomBytes(18).toString('base64'), + testMode = false, +): string { + const assetDirectory = webviewAssetDirectory(extensionUri); + const stylesheetUri = webview.asWebviewUri(vscode.Uri.joinPath(assetDirectory, 'webview.css')); + const scriptUris = WEBVIEW_SCRIPT_ORDER.map(asset => webview.asWebviewUri(vscode.Uri.joinPath(assetDirectory, asset))); + const csp = [ + "default-src 'none'", + `script-src 'nonce-${nonce}'`, + `style-src ${webview.cspSource}`, + `font-src ${webview.cspSource}`, + "img-src 'none'", + "connect-src 'none'", + "object-src 'none'", + "base-uri 'none'", + "form-action 'none'", + ].join('; '); + return ` - + - + + Graphical View - -

Preparing graphical view...

-

Diagram rendering is prepared. Secure SVG display is completed in Task 4.

- + +
+
+
+ + + `; } diff --git a/src/test/graphicalViewController.test.ts b/src/test/graphicalViewController.test.ts index 7604c56..9e72414 100644 --- a/src/test/graphicalViewController.test.ts +++ b/src/test/graphicalViewController.test.ts @@ -14,6 +14,7 @@ import * as assert from 'node:assert/strict'; import {readFileSync} from 'node:fs'; import {join} from 'node:path'; +import * as vscode from 'vscode'; import {OPEN_GRAPHICAL_VIEW_COMMAND} from '../graphicalView'; import {GraphicalViewRenderResult} from '../graphicalViewProtocol'; import { @@ -267,6 +268,215 @@ suite('GraphicalViewController', () => { harness.controller.dispose(); }); + test('commits a sidecar only after secure render confirmation and retains last-good state on render rejection', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/secure-render.ttl'); + await harness.controller.openGraphicalView(document); + const first = successfulResult(document, 'aaaaaaaaaaaaaaaa'); + harness.client.requests[0].deferred.resolve(first); + await flushPromises(); + const retained = harness.controller.getPanelState(document.uri.toString())?.lastSuccess; + assert.equal(retained?.version, 1); + + const panel = harness.panels.panels[0]; + panel.renderOutcome = 'failure'; + panel.emitMessage({type: 'refresh'}); + const rejected = successfulResult(document, 'bbbbbbbbbbbbbbbb'); + harness.client.requests[1].deferred.resolve(rejected); + await flushPromises(); + + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.status.kind, 'stale'); + assert.equal(state?.lastSuccess, retained); + assert.equal(state?.lastSuccess?.targetById.has(first.targets[0].id), true); + assert.equal(state?.lastSuccess?.targetById.has(rejected.targets[0].id), false); + harness.controller.dispose(); + }); + + test('late acknowledgement for delivered A establishes its sidecar without overwriting loading state for B', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/delayed-render-ack-loading.ttl'); + await harness.controller.openGraphicalView(document); + const panel = harness.panels.panels[0]; + panel.renderOutcome = 'none'; + + harness.client.requests[0].deferred.resolve(successfulResult(document, 'aaaaaaaaaaaaaaaa')); + await flushPromises(); + panel.emitMessage({type: 'refresh'}); + const loadingB = harness.controller.getPanelState(document.uri.toString())?.status; + + panel.emitMessage({type: 'rendered', version: 1}); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.lastSuccess?.version, 1); + assert.equal(state?.status, loadingB); + assert.equal(state?.status.kind, 'loading'); + harness.controller.dispose(); + }); + + test('late acknowledgement for delivered A establishes its sidecar without overwriting failed state for B', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/delayed-render-ack-stale.ttl'); + await harness.controller.openGraphicalView(document); + const panel = harness.panels.panels[0]; + panel.renderOutcome = 'none'; + + harness.client.requests[0].deferred.resolve(successfulResult(document, 'aaaaaaaaaaaaaaaa')); + await flushPromises(); + panel.emitMessage({type: 'refresh'}); + harness.client.requests[1].deferred.reject(new Error('render B failed')); + await flushPromises(); + const failedB = harness.controller.getPanelState(document.uri.toString())?.status; + + panel.emitMessage({type: 'rendered', version: 1}); + const state = harness.controller.getPanelState(document.uri.toString()); + assert.equal(state?.lastSuccess?.version, 1); + assert.equal(state?.status, failedB); + assert.equal(state?.status.kind, 'stale'); + harness.controller.dispose(); + }); + + test('rehydrates the current version through ready without an LSP render or shell reset', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/rehydrate.ttl'); + await harness.controller.openGraphicalView(document); + harness.client.requests[0].deferred.resolve(successfulResult(document)); + await flushPromises(); + const panel = harness.panels.panels[0]; + const renderCount = panel.deliveries.filter(delivery => delivery.type === 'render').length; + + panel.emitMessage({type: 'ready'}); + assert.equal(harness.client.requests.length, 1); + assert.equal(panel.deliveries.filter(delivery => delivery.type === 'render').length, renderCount + 1); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.version, 1); + + panel.renderOutcome = 'failure'; + panel.emitMessage({type: 'refresh'}); + harness.client.requests[1].deferred.resolve(successfulResult(document, 'bbbbbbbbbbbbbbbb')); + await flushPromises(); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'stale'); + + panel.renderOutcome = 'success'; + panel.emitMessage({type: 'ready'}); + assert.equal(harness.client.requests.length, 2); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.lastSuccess?.version, 1); + assert.equal(harness.controller.getPanelState(document.uri.toString())?.status.kind, 'stale'); + harness.controller.dispose(); + }); + + test('resolves a current sidecar-backed marker and opens, selects, and reveals only its local file location', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/navigation-source.ttl'); + await harness.controller.openGraphicalView(document); + const result = successfulResult(document); + harness.client.requests[0].deferred.resolve(result); + await flushPromises(); + const targetUri = vscode.Uri.file('/tmp/navigation-target.ttl'); + harness.client.resolveResult = { + location: { + uri: targetUri.toString(), + range: {start: {line: 3, character: 4}, end: {line: 5, character: 6}}, + }, + }; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: result.targets[0].id}); + await flushPromises(); + await flushPromises(); + + assert.deepEqual(harness.client.resolveRequests[0].params, { + sourceUri: document.uri.toString(), + elementUrn: result.targets[0].elementUrn, + }); + assert.equal(harness.client.resolveRequests[0].token?.isCancellationRequested, false); + assert.equal(harness.window.openedEditors[0].uri.toString(), targetUri.toString()); + assert.equal(harness.window.openedEditors[0].options?.preview, false); + assert.deepEqual(harness.window.openedEditors[0].editor.selection.start, new vscode.Position(3, 4)); + assert.deepEqual(harness.window.openedEditors[0].editor.selection.end, new vscode.Position(5, 6)); + assert.deepEqual(harness.window.openedEditors[0].revealedRanges[0], new vscode.Range(3, 4, 5, 6)); + harness.controller.dispose(); + }); + + test('rejects malformed, stale, fake, extra-field, and non-sidecar navigation before LSP resolution', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/rejected-navigation.ttl'); + await harness.controller.openGraphicalView(document); + const first = successfulResult(document, 'aaaaaaaaaaaaaaaa'); + harness.client.requests[0].deferred.resolve(first); + await flushPromises(); + + const rejectedMessages: unknown[] = [ + {type: 'navigate', targetId: first.targets[0].id}, + {type: 'navigate', version: 1, targetId: first.targets[0].id, uri: 'file:///tmp/evil.ttl'}, + {type: 'navigate', version: 1, targetId: 'gv-header-bbbbbbbbbbbbbbbb'}, + {type: 'navigate', version: 1, targetId: 'bad-marker'}, + {type: 'navigate', version: 1.5, targetId: first.targets[0].id}, + {type: 'navigate', version: '1', targetId: first.targets[0].id}, + {type: 'navigate', version: 1, targetId: first.targets[0].id, elementUrn: first.targets[0].elementUrn}, + ]; + for (const message of rejectedMessages) { + harness.panels.panels[0].emitMessage(message); + } + + harness.panels.panels[0].emitMessage({type: 'refresh'}); + const second = successfulResult(document, 'cccccccccccccccc'); + harness.client.requests[1].deferred.resolve(second); + await flushPromises(); + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: first.targets[0].id}); + assert.equal(harness.client.resolveRequests.length, 0); + assert.equal(harness.window.openedEditors.length, 0); + harness.controller.dispose(); + }); + + test('blocks warning, malformed, invalid-range, non-file, resolver-failure, and editor-open navigation paths', async () => { + const harness = createGraphicalViewHarness(); + const document = track(harness, '/tmp/navigation-failures.ttl'); + await harness.controller.openGraphicalView(document); + const result = successfulResult(document); + harness.client.requests[0].deferred.resolve(result); + await flushPromises(); + const navigate = async () => { + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: result.targets[0].id}); + await flushPromises(); + await flushPromises(); + }; + + for (const warning of ['notFound', 'ambiguous', 'unsupportedUri', 'temporarilyUnresolvable'] as const) { + harness.client.resolveResult = {location: null, warning}; + await navigate(); + } + harness.client.resolveResult = { + location: { + uri: 'https://example.invalid/model.ttl', + range: {start: {line: 0, character: 0}, end: {line: 0, character: 1}}, + }, + }; + await navigate(); + harness.client.resolveResult = { + location: { + uri: vscode.Uri.file('/tmp/invalid-range.ttl').toString(), + range: {start: {line: 2, character: 0}, end: {line: 1, character: 0}}, + }, + }; + await navigate(); + harness.client.resolveResult = {location: null, warning: null}; + await navigate(); + harness.client.resolveFailure = new Error('hostile resolver detail'); + await navigate(); + harness.client.resolveFailure = undefined; + harness.client.resolveResult = { + location: { + uri: vscode.Uri.file('/tmp/open-failure.ttl').toString(), + range: {start: {line: 0, character: 0}, end: {line: 0, character: 1}}, + }, + }; + harness.window.showTextDocumentFailure = new Error('hostile editor detail'); + await navigate(); + + assert.equal(harness.window.openedEditors.length, 0); + assert.equal(harness.window.warnings.length, 9); + assert.ok(harness.window.warnings.every(message => !message.includes('hostile'))); + harness.controller.dispose(); + }); + test('detects MethodNotFound by code and recovers compatibility state after client replacement', async () => { const harness = createGraphicalViewHarness(); const document = track(harness, '/tmp/unsupported.ttl'); diff --git a/src/test/graphicalViewPanel.test.ts b/src/test/graphicalViewPanel.test.ts new file mode 100644 index 0000000..f2c8f98 --- /dev/null +++ b/src/test/graphicalViewPanel.test.ts @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * + * See the AUTHORS file(s) distributed with this work for additional + * information regarding authorship. + * + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at https://mozilla.org/MPL/2.0/. + * + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import {createHash} from 'node:crypto'; +import {readdirSync, readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import * as vscode from 'vscode'; +import {WEBVIEW_SCRIPT_ORDER, createGraphicalViewPanelOptions, createShellHtml, webviewAssetDirectory} from '../graphicalViewPanel'; + +suite('GraphicalView secure panel contract', () => { + test('uses exact script/forms/command options and one out/webview resource root without retention', () => { + const extensionUri = vscode.Uri.file('/tmp/graphical-view-extension'); + const options = createGraphicalViewPanelOptions(extensionUri); + + assert.equal(options.enableScripts, true); + assert.equal(options.enableForms, false); + assert.equal(options.enableCommandUris, false); + assert.equal(options.localResourceRoots?.length, 1); + assert.equal(options.localResourceRoots?.[0].toString(), webviewAssetDirectory(extensionUri).toString()); + assert.equal('retainContextWhenHidden' in options, false); + }); + + test('creates the exact CSP, fresh nonce, local resource URIs, and fixed external script order', () => { + const extensionUri = vscode.Uri.file('/tmp/graphical-view-extension'); + const webview = { + cspSource: 'vscode-webview-resource:', + asWebviewUri: (uri: vscode.Uri) => uri.with({scheme: 'vscode-webview-resource'}), + }; + const first = createShellHtml(webview, extensionUri, 'first-nonce'); + const second = createShellHtml(webview, extensionUri, 'second-nonce'); + + const expectedCsp = + "default-src 'none'; script-src 'nonce-first-nonce'; style-src vscode-webview-resource:; " + + "font-src vscode-webview-resource:; img-src 'none'; connect-src 'none'; object-src 'none'; " + + "base-uri 'none'; form-action 'none'"; + assert.ok(first.includes(`content="${expectedCsp}"`)); + assert.equal(first.includes('second-nonce'), false); + assert.equal(second.includes('second-nonce'), true); + assert.equal((first.match(/'}); + await waitFor(() => hasMessage(messages, 'renderError', 3), 'fail-closed hostile render'); } finally { subscription.dispose(); panel.dispose(); @@ -151,11 +77,11 @@ function graphperSvg(marker: string): string { `; } -function wrappedSeeSvg(firstMarker: string, continuationMarker: string): string { +function multilingualSvg(germanMarker: string, englishMarker: string): string { return ` -see: https://example.test/reference/with/a/long/path, -urn:irdi:0173:1:02:AAO677:003 +description [de]: Beschreibung +description [en]: Description `; } @@ -171,21 +97,6 @@ async function waitFor(probe: () => boolean, description: string, timeoutMs = 20 throw new Error(`Timed out waiting for ${description}`); } -async function waitForMessage( - messages: readonly unknown[], - predicate: (message: unknown) => message is T, - description: string, - skip = 0, -): Promise { - let result: T | undefined; - await waitFor(() => { - const matches = messages.filter(predicate); - result = matches.at(skip); - return result !== undefined; - }, description); - return result as T; -} - function countMessages(messages: readonly unknown[], type: string): number { return messages.filter(message => isRecord(message) && message.type === type).length; } @@ -194,38 +105,6 @@ function hasMessage(messages: readonly unknown[], type: string, version: number) return messages.some(message => isRecord(message) && message.type === type && message.version === version); } -function navigationMessages(messages: readonly unknown[]): unknown[] { - return messages.filter(message => isRecord(message) && message.type === 'navigate'); -} - -function isTestState( - message: unknown, -): message is {type: 'testState'; state: {schemaVersion: number; zoom: number; scrollLeft: number; scrollTop: number}} { - return ( - isRecord(message) && - message.type === 'testState' && - isRecord(message.state) && - typeof message.state.schemaVersion === 'number' && - typeof message.state.zoom === 'number' && - typeof message.state.scrollLeft === 'number' && - typeof message.state.scrollTop === 'number' - ); -} - -function isViewportState( - message: unknown, - zoom: number, - scrollLeft: number, - scrollTop: number, -): message is {type: 'testState'; state: {schemaVersion: number; zoom: number; scrollLeft: number; scrollTop: number}} { - return ( - isTestState(message) && - message.state.zoom === zoom && - message.state.scrollLeft === scrollLeft && - message.state.scrollTop === scrollTop - ); -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null; } diff --git a/src/test/languageClientGraphicalView.test.ts b/src/test/languageClientGraphicalView.test.ts index d9b042c..4b9273c 100644 --- a/src/test/languageClientGraphicalView.test.ts +++ b/src/test/languageClientGraphicalView.test.ts @@ -1,151 +1,108 @@ /* * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH - * - * See the AUTHORS file(s) distributed with this work for additional - * information regarding authorship. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * * SPDX-License-Identifier: MPL-2.0 */ import * as assert from 'node:assert/strict'; import * as vscode from 'vscode'; -import {Trace} from 'vscode-jsonrpc'; -import {State, StateChangeEvent} from 'vscode-languageclient/node'; +import {LspGraphicalViewClient, GraphicalViewRequestTransport} from '../graphicalViewClient'; import { GRAPHICAL_VIEW_RENDER_REQUEST, GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST, GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, GraphicalViewRenderResult, GraphicalViewResolveTargetResult, - isGraphicalViewRenderResult, } from '../graphicalViewProtocol'; -import {LanguageClientAdapter, TurtleLanguageClient} from '../languageClient'; -import type {ExtensionLogger} from '../outputChannel'; -suite('TurtleLanguageClient graphical-view integration', () => { - test('forwards exact typed methods and render cancellation token', async () => { - const adapter = new FakeLanguageClientAdapter(); - const client = new TurtleLanguageClient(new FakeLogger(), 1846, 'off', () => adapter); - await client.connect(); +suite('Graphical View typed LSP client', () => { + test('forwards exact render and resolve methods, parameters, and cancellation token', async () => { + const transport = new FakeTransport(); + const client = new LspGraphicalViewClient(transport); const cancellation = new vscode.CancellationTokenSource(); - const render = await client.renderGraphicalView({uri: 'file:///model.ttl'}, cancellation.token); - const resolve = await client.resolveGraphicalViewTarget({ - sourceUri: 'file:///model.ttl', - elementUrn: 'urn:samm:example:1.0.0#Aspect', - }); - const resolveAttribute = await client.resolveGraphicalViewAttributeTarget({ - sourceUri: 'file:///model.ttl', - ownerUrn: 'urn:samm:example:1.0.0#Aspect', - predicateUrn: 'urn:samm:org.eclipse.esmf.samm:meta-model:2.2.0#description', - selection: 'singleOccurrence', - language: 'en', - }); - - assert.equal(render.uri, 'file:///model.ttl'); - assert.equal(resolve.warning, 'notFound'); - assert.equal(resolveAttribute.warning, 'notFound'); - assert.equal(adapter.requests[0].method, GRAPHICAL_VIEW_RENDER_REQUEST); - assert.equal(adapter.requests[0].token, cancellation.token); - assert.equal(adapter.requests[1].method, GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST); - assert.equal(adapter.requests[2].method, GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST); + await client.render({uri: 'file:///model.ttl', includeAttributeRows: true}, cancellation.token); + await client.resolveElement( + {sourceUri: 'file:///model.ttl', elementUrn: 'urn:samm:example:1.0.0#Aspect'}, + cancellation.token, + ); + await client.resolveAttribute( + { + sourceUri: 'file:///model.ttl', + ownerUrn: 'urn:samm:example:1.0.0#Aspect', + predicateUrn: 'urn:samm:org.eclipse.esmf.samm:meta-model:2.2.0#description', + selection: 'singleOccurrence', + language: 'en', + }, + cancellation.token, + ); + + assert.deepEqual(transport.requests.map(request => request.method), [ + GRAPHICAL_VIEW_RENDER_REQUEST, + GRAPHICAL_VIEW_RESOLVE_TARGET_REQUEST, + GRAPHICAL_VIEW_RESOLVE_ATTRIBUTE_TARGET_REQUEST, + ]); + assert.ok(transport.requests.every(request => request.token === cancellation.token)); + assert.deepEqual(transport.requests[0].params, {uri: 'file:///model.ttl', includeAttributeRows: true}); cancellation.dispose(); }); - test('exposes disconnect and reconnect transitions once per availability change', async () => { - const adapter = new FakeLanguageClientAdapter(); - const client = new TurtleLanguageClient(new FakeLogger(), 1846, 'off', () => adapter); + test('exposes transport availability without a second lifecycle state', () => { + const transport = new FakeTransport(); + const client = new LspGraphicalViewClient(transport); const events: boolean[] = []; - const subscription = client.onDidChangeGraphicalViewAvailability(available => events.push(available)); + const subscription = client.onDidChangeAvailability(available => events.push(available)); - await client.connect(); - adapter.transition(State.Starting); - adapter.transition(State.Stopped); - adapter.transition(State.Running); - assert.deepEqual(events, [true, false, true]); + transport.setAvailable(false); + transport.setAvailable(true); + assert.equal(client.isAvailable(), true); + assert.deepEqual(events, [false, true]); subscription.dispose(); }); - test('accepts Task 2 render warning JSON with omitted or explicit-null svg', () => { - const omittedSvg = JSON.parse( - '{"uri":"file:///model.ttl","targets":[],"warnings":["timeout"]}', - ) as unknown; - const nullSvg = JSON.parse( - '{"uri":"file:///model.ttl","svg":null,"targets":[],"warnings":["modelTooLarge"]}', - ) as unknown; - const missingSuccessfulSvg = JSON.parse( - '{"uri":"file:///model.ttl","targets":[],"warnings":[]}', - ) as unknown; - - assert.equal(isGraphicalViewRenderResult(omittedSvg), true); - assert.equal(isGraphicalViewRenderResult(nullSvg), true); - assert.equal(isGraphicalViewRenderResult(missingSuccessfulSvg), false); - }); - - test('models omitted and explicit-null resolve result fields from Task 2 JSON', () => { - const omittedLocation = JSON.parse('{"warning":"notFound"}') as GraphicalViewResolveTargetResult; - const omittedWarning = JSON.parse( - '{"location":{"uri":"file:///model.ttl","range":{"start":{"line":1,"character":2},"end":{"line":1,"character":3}}}}', - ) as GraphicalViewResolveTargetResult; - const explicitNulls = JSON.parse('{"location":null,"warning":null}') as GraphicalViewResolveTargetResult; - - assert.equal(omittedLocation.location, undefined); - assert.equal(omittedLocation.warning, 'notFound'); - assert.equal(omittedWarning.warning, undefined); - assert.equal(omittedWarning.location?.uri, 'file:///model.ttl'); - assert.equal(explicitNulls.location, null); - assert.equal(explicitNulls.warning, null); + test('preserves omitted and explicit-null JSON wire fields', async () => { + const transport = new FakeTransport(); + const client = new LspGraphicalViewClient(transport); + const cancellation = new vscode.CancellationTokenSource(); + transport.renderResult = JSON.parse('{"uri":"file:///model.ttl","svg":null,"targets":[],"warnings":["timeout"]}'); + const render = await client.render({uri: 'file:///model.ttl'}, cancellation.token); + transport.resolveResult = JSON.parse('{"location":null,"warning":null}'); + const resolve = await client.resolveElement( + {sourceUri: 'file:///model.ttl', elementUrn: 'urn:samm:example:1.0.0#Aspect'}, + cancellation.token, + ); + + assert.equal(render.svg, null); + assert.equal(resolve.location, null); + assert.equal(resolve.warning, null); + cancellation.dispose(); }); }); -class FakeLanguageClientAdapter implements LanguageClientAdapter { - state = State.Stopped; +class FakeTransport implements GraphicalViewRequestTransport { readonly requests: Array<{method: string; params: unknown; token: vscode.CancellationToken | undefined}> = []; - private readonly listeners = new Set<(event: StateChangeEvent) => void>(); + renderResult: GraphicalViewRenderResult = {uri: 'file:///model.ttl', svg: '', targets: [], warnings: []}; + resolveResult: GraphicalViewResolveTargetResult = {location: null, warning: 'notFound'}; + private available = true; + private readonly listeners = new Set<(available: boolean) => void>(); - setTrace(_value: Trace): void {} - - start(): Promise { - this.transition(State.Running); - return Promise.resolve(); - } - - stop(): Promise { - this.transition(State.Stopped); - return Promise.resolve(); + isAvailable(): boolean { + return this.available; } - onDidChangeState(listener: (event: StateChangeEvent) => void): vscode.Disposable { + onDidChangeAvailability(listener: (available: boolean) => void): vscode.Disposable { this.listeners.add(listener); return new vscode.Disposable(() => this.listeners.delete(listener)); } sendRequest(method: string, params?: unknown, token?: vscode.CancellationToken): Promise { this.requests.push({method, params, token}); - if (method === GRAPHICAL_VIEW_RENDER_REQUEST) { - const result: GraphicalViewRenderResult = {uri: 'file:///model.ttl', svg: '', targets: [], warnings: []}; - return Promise.resolve(result as R); - } - const result: GraphicalViewResolveTargetResult = {location: null, warning: 'notFound'}; - return Promise.resolve(result as R); + return Promise.resolve((method === GRAPHICAL_VIEW_RENDER_REQUEST ? this.renderResult : this.resolveResult) as R); } - transition(newState: State): void { - const oldState = this.state; - this.state = newState; + setAvailable(available: boolean): void { + this.available = available; for (const listener of [...this.listeners]) { - listener({oldState, newState}); + listener(available); } } } - -class FakeLogger implements ExtensionLogger { - trace(_message: string): void {} - info(_message: string): void {} - warn(_message: string): void {} - error(_message: string | Error): void {} -} diff --git a/src/webview/webview.js b/src/webview/webview.js index cf7d599..8005907 100644 --- a/src/webview/webview.js +++ b/src/webview/webview.js @@ -22,7 +22,6 @@ const diagram = document.querySelector('#diagram'); const status = document.querySelector('#status'); const zoomValue = document.querySelector('#zoom-value'); - const testMode = document.documentElement.dataset.testMode === 'true'; let currentVersion = null; let currentSvg = null; let baseWidth = 0; @@ -101,9 +100,6 @@ if (notifyRendered) { vscode.postMessage({type: 'rendered', version}); } - if (testMode) { - vscode.postMessage({type: 'testState', state}); - } }); } @@ -173,12 +169,6 @@ } catch (error) { status.textContent = 'The new diagram could not be displayed safely. The last valid diagram is retained.'; vscode.postMessage({type: 'renderError', version: message.version, reason: 'sanitizationFailed'}); - if (testMode) { - vscode.postMessage({ - type: 'testRenderDiagnostic', - message: error instanceof Error ? error.message : 'Unknown render failure', - }); - } } } @@ -237,22 +227,6 @@ status.textContent = message.status.message; return; } - if (testMode && isExactMessage(message, ['scrollLeft', 'scrollTop', 'type', 'zoom']) && message.type === 'testSetViewport') { - state = normalizeState({schemaVersion: 1, zoom: message.zoom, scrollLeft: message.scrollLeft, scrollTop: message.scrollTop}); - applyZoom(); - restoreViewport(currentVersion, false); - return; - } - if (testMode && isExactMessage(message, ['targetId', 'type']) && message.type === 'testClickMarker') { - const marker = diagram.querySelector(`g[id="${CSS.escape(message.targetId)}"]`); - const hitTarget = marker?.querySelector('polygon') ?? marker; - hitTarget?.dispatchEvent(new MouseEvent('click', {bubbles: true})); - return; - } - if (testMode && isExactMessage(message, ['key', 'targetId', 'type']) && message.type === 'testKeyMarker') { - const marker = diagram.querySelector(`g[id="${CSS.escape(message.targetId)}"]`); - marker?.dispatchEvent(new KeyboardEvent('keydown', {key: message.key, bubbles: true})); - } }); updateZoomLabel(); From 9555968026afded5f39fc1fdf7dac5e5d9303352 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Wed, 2 Sep 2026 20:02:31 +0300 Subject: [PATCH 06/15] refactoring --- .vscodeignore | 1 + README.md | 10 +++++++ scripts/copy-webview-assets.mjs | 26 +++++++++++++++++++ scripts/verify-webview-assets.mjs | 34 ++++++++++++++++++++++++ scripts/webview-assets.mjs | 43 +++++++++++++++++++++++++++++++ 5 files changed, 114 insertions(+) create mode 100644 scripts/copy-webview-assets.mjs create mode 100644 scripts/verify-webview-assets.mjs create mode 100644 scripts/webview-assets.mjs diff --git a/.vscodeignore b/.vscodeignore index 3d26b57..b015481 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -1,5 +1,6 @@ .vscode/** .vscode-test/** +scripts/ src/** out/test/** .gitignore diff --git a/README.md b/README.md index 5d8fb00..65389b3 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,16 @@ Use the command `Turtle: Select SAMM CLI Executable` to choose either: - Manual validation command: - `Turtle: Validate document now` +## Graphical View + +Use `Turtle: Open Graphical View` (`turtle.openGraphicalView`) from the Turtle editor-title button, the editor context menu, or the Command Palette to open a read-only SVG snapshot in a separate panel. One panel is reused per Turtle document. + +The view renders on initial open, manual Refresh, and Save while the main Turtle document is visible. It includes unsaved text from the main document but uses the persisted versions of imported files. Typing, import saves, hidden-document saves, and revealing an existing panel do not trigger a render. + +Element headers can navigate to definitions in local files. Eligible attribute rows whose owner is a named model element navigate as a whole row; language-qualified and wrapped rows retain their source mapping, and aggregated rows navigate to the start of the corresponding predicate. Rows with anonymous or otherwise non-deterministic owners remain inert. Navigation to remote URIs and arbitrary referenced values is not supported. + +If rendering fails, the last successful diagram remains visible with an error or warning. Rendering is limited to 1,000 boxes and a 30-second request timeout. The graphical view is not an editor, does not update live while typing, and does not claim visual parity with the Aspect Model Editor. + ## Run The Server And Extension Together 1. In this extension project, install dependencies with `npm install`. diff --git a/scripts/copy-webview-assets.mjs b/scripts/copy-webview-assets.mjs new file mode 100644 index 0000000..8be932c --- /dev/null +++ b/scripts/copy-webview-assets.mjs @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {copyFileSync, mkdirSync, readdirSync, rmSync} from 'node:fs'; +import {join} from 'node:path'; +import {outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; + +for (const asset of webviewAssets) { + if (asset.expectedSha256 && sha256(asset.source) !== asset.expectedSha256) { + throw new Error(`Pinned webview asset hash mismatch: ${asset.destination}`); + } +} + +rmSync(outputDirectory, {recursive: true, force: true}); +mkdirSync(outputDirectory, {recursive: true}); +for (const asset of webviewAssets) { + copyFileSync(asset.source, join(outputDirectory, asset.destination)); +} + +const actual = readdirSync(outputDirectory).sort(); +const expected = webviewAssets.map(asset => asset.destination).sort(); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error('Webview asset copy produced an unexpected inventory.'); +} diff --git a/scripts/verify-webview-assets.mjs b/scripts/verify-webview-assets.mjs new file mode 100644 index 0000000..db1355c --- /dev/null +++ b/scripts/verify-webview-assets.mjs @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {readFileSync, readdirSync, statSync} from 'node:fs'; +import {join} from 'node:path'; +import {extensionRoot, outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; + +const packageJson = JSON.parse(readFileSync(join(extensionRoot, 'package.json'), 'utf8')); +if (packageJson.dependencies?.dompurify !== '3.4.13') { + throw new Error('DOMPurify must remain pinned exactly to 3.4.13.'); +} + +const actual = readdirSync(outputDirectory).sort(); +const expected = webviewAssets.map(asset => asset.destination).sort(); +if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`Unexpected out/webview inventory: ${actual.join(', ')}`); +} + +for (const asset of webviewAssets) { + const output = join(outputDirectory, asset.destination); + if (!statSync(output).isFile() || statSync(output).size === 0) { + throw new Error(`Missing or empty webview asset: ${asset.destination}`); + } + if (sha256(asset.source) !== sha256(output)) { + throw new Error(`Copied webview asset differs from its source: ${asset.destination}`); + } + if (asset.expectedSha256 && sha256(output) !== asset.expectedSha256) { + throw new Error(`Pinned webview asset hash mismatch: ${asset.destination}`); + } +} + +process.stdout.write(`Verified ${webviewAssets.length} deterministic webview assets.\n`); diff --git a/scripts/webview-assets.mjs b/scripts/webview-assets.mjs new file mode 100644 index 0000000..f2b8b7a --- /dev/null +++ b/scripts/webview-assets.mjs @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import {createHash} from 'node:crypto'; +import {readFileSync} from 'node:fs'; +import {dirname, resolve} from 'node:path'; +import {fileURLToPath} from 'node:url'; + +export const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..'); +export const outputDirectory = resolve(extensionRoot, 'out', 'webview'); + +export const webviewAssets = Object.freeze([ + asset('node_modules/dompurify/LICENSE', 'DOMPurify-LICENSE-Apache-2.0.txt'), + asset('node_modules/dompurify/LICENSE-MPL', 'DOMPurify-LICENSE-MPL-2.0.txt'), + asset( + 'node_modules/dompurify/dist/purify.min.js', + 'purify.min.js', + '9ab3d44d73c3e3947f9ab72e0f0bc15c7f1931d60b365ba261fc85fe59013c56', + ), + asset('src/webview/RobotoCondensed-NOTICE.txt', 'RobotoCondensed-NOTICE.txt'), + asset( + 'src/webview/RobotoCondensed-Regular.ttf', + 'RobotoCondensed-Regular.ttf', + '4a7c36df4318fee50a8159c3a0ebde4572abab65447ae4a651c2fe87212302b5', + ), + asset('src/webview/sanitizer-contract.js', 'sanitizer-contract.js'), + asset('src/webview/webview.css', 'webview.css'), + asset('src/webview/webview.js', 'webview.js'), +]); + +export function sha256(file) { + return createHash('sha256').update(readFileSync(file)).digest('hex'); +} + +function asset(source, destination, expectedSha256) { + return Object.freeze({ + source: resolve(extensionRoot, source), + destination, + ...(expectedSha256 ? {expectedSha256} : {}), + }); +} From 0d3691daf6ae1c328f1b60dac17e2a20b844afc7 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Thu, 3 Sep 2026 14:35:55 +0300 Subject: [PATCH 07/15] resolve merge conflicts --- README.md | 2 +- package.json | 2 +- src/graphicalView.ts | 2 +- src/graphicalViewPanel.ts | 2 +- src/test/graphicalViewManifest.test.ts | 2 +- src/test/graphicalViewTestHarness.ts | 2 +- src/test/graphicalViewWebviewLifecycle.test.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index a6d1a0f..f0b1757 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Use the command `Semantic Models: Select SAMM CLI Executable` to choose either: ## Graphical View -Use `Turtle: Open Graphical View` (`turtle.openGraphicalView`) from the Turtle editor-title button, the editor context menu, or the Command Palette to open a read-only SVG snapshot in a separate panel. One panel is reused per Turtle document. +Use `Semantic Models: Open Graphical View` (`semantic-models.openGraphicalView`) from the Turtle editor-title button, the editor context menu, or the Command Palette to open a read-only SVG snapshot in a separate panel. One panel is reused per Turtle document. The view renders on initial open, manual Refresh, and Save while the main Turtle document is visible. It includes unsaved text from the main document but uses the persisted versions of imported files. Typing, import saves, hidden-document saves, and revealing an existing panel do not trigger a render. diff --git a/package.json b/package.json index 12c8870..3465f8c 100644 --- a/package.json +++ b/package.json @@ -189,7 +189,7 @@ { "id": "select-samm-cli", "title": "Select a SAMM CLI Executable", - "description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:turtle.selectSammCliExecutable)", + "description": "Complete the initial setup by downloading or selecting a SAMM CLI executable, which provides the required SAMM Language Server.\n[Download or Select SAMM CLI](command:semantic-models.selectSammCliExecutable)", "media": { "image": "media/walkthrough_select_samm_cli.png", "altText": "Example of selecting a SAMM CLI executable" diff --git a/src/graphicalView.ts b/src/graphicalView.ts index 744b70b..753f8b1 100644 --- a/src/graphicalView.ts +++ b/src/graphicalView.ts @@ -24,7 +24,7 @@ import { import type {GraphicalViewRenderResult, GraphicalViewRenderWarning, GraphicalViewTarget} from './graphicalViewProtocol'; import {AcceptedGraphicalViewResult, acceptGraphicalViewResult, isGraphicalViewRenderResult} from './graphicalViewResult'; -export const OPEN_GRAPHICAL_VIEW_COMMAND = 'turtle.openGraphicalView'; +export const OPEN_GRAPHICAL_VIEW_COMMAND = 'semantic-models.openGraphicalView'; export interface GraphicalViewDocument { readonly languageId: string; diff --git a/src/graphicalViewPanel.ts b/src/graphicalViewPanel.ts index b2d4959..9af26c5 100644 --- a/src/graphicalViewPanel.ts +++ b/src/graphicalViewPanel.ts @@ -15,7 +15,7 @@ import {randomBytes} from 'node:crypto'; import * as vscode from 'vscode'; import {GRAPHICAL_VIEW_MARKER_PATTERN} from './graphicalViewProtocol'; -const VIEW_TYPE = 'turtle.graphicalView'; +const VIEW_TYPE = 'semantic-models.graphicalView'; export const WEBVIEW_ASSET_DIRECTORY = Object.freeze(['out', 'webview'] as const); export const WEBVIEW_SCRIPT_ORDER = Object.freeze(['purify.min.js', 'sanitizer-contract.js', 'webview.js'] as const); diff --git a/src/test/graphicalViewManifest.test.ts b/src/test/graphicalViewManifest.test.ts index 1245c58..cc6dc60 100644 --- a/src/test/graphicalViewManifest.test.ts +++ b/src/test/graphicalViewManifest.test.ts @@ -52,7 +52,7 @@ suite('Graphical View editor-title manifest contract', () => { assert.deepEqual(commands[0], { command: OPEN_GRAPHICAL_VIEW_COMMAND, title: 'Open Graphical View', - category: 'Turtle', + category: 'Semantic Models', enablement: 'editorLangId == turtle', icon: {light: ICON_PATH, dark: ICON_PATH}, }); diff --git a/src/test/graphicalViewTestHarness.ts b/src/test/graphicalViewTestHarness.ts index c13157a..1333d04 100644 --- a/src/test/graphicalViewTestHarness.ts +++ b/src/test/graphicalViewTestHarness.ts @@ -224,7 +224,7 @@ export async function openGraphicalView( document: GraphicalViewDocument, ): Promise { harness.window.activeTextEditor = {document}; - await harness.commands.execute('turtle.openGraphicalView'); + await harness.commands.execute('semantic-models.openGraphicalView'); } export function lastStatus(panel: FakeGraphicalViewPanel): GraphicalViewStatus | undefined { diff --git a/src/test/graphicalViewWebviewLifecycle.test.ts b/src/test/graphicalViewWebviewLifecycle.test.ts index d3563ee..5414746 100644 --- a/src/test/graphicalViewWebviewLifecycle.test.ts +++ b/src/test/graphicalViewWebviewLifecycle.test.ts @@ -19,7 +19,7 @@ suite('GraphicalView real webview lifecycle', function () { this.timeout(30_000); const extensionUri = vscode.Uri.file(join(__dirname, '..', '..')); const panel = vscode.window.createWebviewPanel( - 'turtle.graphicalViewLifecycleTest', + 'semantic-models.graphicalViewLifecycleTest', 'Graphical View Lifecycle Test', vscode.ViewColumn.One, createGraphicalViewPanelOptions(extensionUri), From 89af6c17eb52c9061d77366383eabeadf40c9adb Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Thu, 10 Sep 2026 14:57:43 +0300 Subject: [PATCH 08/15] Enhance graphical view with accessible controls and status indicators --- src/graphicalViewPanel.ts | 22 ++- src/test/graphicalViewPanel.test.ts | 37 ++++ .../graphicalViewWebviewController.test.ts | 60 +++++++ src/webview/webview.css | 163 +++++++++++++++++- src/webview/webview.js | 27 ++- 5 files changed, 289 insertions(+), 20 deletions(-) diff --git a/src/graphicalViewPanel.ts b/src/graphicalViewPanel.ts index 9af26c5..cf283e6 100644 --- a/src/graphicalViewPanel.ts +++ b/src/graphicalViewPanel.ts @@ -147,13 +147,21 @@ export function createGraphicalViewShell(
diff --git a/src/test/graphicalViewPanel.test.ts b/src/test/graphicalViewPanel.test.ts index 460198e..572039d 100644 --- a/src/test/graphicalViewPanel.test.ts +++ b/src/test/graphicalViewPanel.test.ts @@ -71,6 +71,43 @@ suite('GraphicalView secure panel contract', () => { assert.ok(first.includes('/out/webview/webview.css')); }); + test('creates accessible compact controls and a live status presentation', () => { + const extensionUri = vscode.Uri.file('/tmp/graphical-view-extension'); + const webview = { + cspSource: 'vscode-webview-resource:', + asWebviewUri: (uri: vscode.Uri) => uri.with({scheme: 'vscode-webview-resource'}), + }; + const shell = createGraphicalViewShell(webview, extensionUri); + + assert.match(shell, /id="zoom-reset"[^>]*aria-label="Reset zoom to 100 percent"[^>]*>Reset<\/button>/); + assert.match(shell, /id="zoom-controls" role="group" aria-label="Zoom controls"/); + assert.match(shell, /id="status" role="status" aria-live="polite" aria-atomic="true" data-kind="loading"/); + assert.match(shell, /id="status-indicator" aria-hidden="true"/); + assert.match(shell, /id="status-text">Preparing graphical view\.\.\.<\/span>/); + }); + + test('styles all status kinds with theme-aware responsive layout and a loading indicator', () => { + const stylesheet = readFileSync(join(__dirname, '..', '..', 'src', 'webview', 'webview.css'), 'utf8'); + + for (const kind of ['ready', 'loading', 'stale', 'unsupported', 'disconnected']) { + assert.ok(stylesheet.includes(`#status[data-kind="${kind}"]`)); + } + for (const themeVariable of [ + '--vscode-testing-iconPassed', + '--vscode-progressBar-background', + '--vscode-notificationsWarningIcon-foreground', + '--vscode-notificationsErrorIcon-foreground', + '--vscode-panel-border', + '--vscode-foreground', + ]) { + assert.ok(stylesheet.includes(themeVariable)); + } + assert.match(stylesheet, /#toolbar\s*{[^}]*flex-wrap: wrap;/s); + assert.match(stylesheet, /#status\[data-kind="loading"\] #status-indicator\s*{[^}]*animation: status-spin/s); + assert.match(stylesheet, /#status\[data-kind="stale"\],[\s\S]*flex: 1 0 100%;/); + assert.match(stylesheet, /@media \(max-width: 520px\)/); + }); + test('accepts only exact production webview messages', () => { assert.deepEqual(parseGraphicalViewPanelMessage({type: 'ready'}), {type: 'ready'}); assert.deepEqual(parseGraphicalViewPanelMessage({type: 'refresh'}), {type: 'refresh'}); diff --git a/src/test/graphicalViewWebviewController.test.ts b/src/test/graphicalViewWebviewController.test.ts index 1042d93..5011141 100644 --- a/src/test/graphicalViewWebviewController.test.ts +++ b/src/test/graphicalViewWebviewController.test.ts @@ -28,6 +28,7 @@ suite('Graphical View webview controller', () => { ['#diagram', diagram], ['#viewport', viewport], ['#status', new FakeElement()], + ['#status-text', new FakeElement()], ['#zoom-value', new FakeElement()], ['#refresh', new FakeElement()], ['#zoom-in', new FakeElement()], @@ -90,6 +91,65 @@ suite('Graphical View webview controller', () => { assert.equal(prevented, 2); assert.deepEqual(JSON.parse(JSON.stringify(state.value)), {schemaVersion: 1, zoom: 1, scrollLeft: 0, scrollTop: 0}); }); + + test('applies only allow-listed status kinds and preserves complete status text safely', () => { + const source = readFileSync(join(__dirname, '..', '..', 'src', 'webview', 'webview.js'), 'utf8'); + const status = new FakeElement(); + const statusText = new FakeElement(); + const elements = new Map([ + ['#diagram', new FakeElement()], + ['#viewport', new FakeElement()], + ['#status', status], + ['#status-text', statusText], + ['#zoom-value', new FakeElement()], + ['#refresh', new FakeElement()], + ['#zoom-in', new FakeElement()], + ['#zoom-out', new FakeElement()], + ['#zoom-reset', new FakeElement()], + ['#zoom-fit', new FakeElement()], + ]); + const window = new FakeElement(); + runInNewContext(source, { + acquireVsCodeApi: () => ({ + getState: () => undefined, + setState: () => undefined, + postMessage: () => undefined, + }), + document: {querySelector: (selector: string) => elements.get(selector)}, + window, + Element: FakeElement, + SanitizerContract: {MARKER_PATTERN: /^$/, sanitizeSvg: () => undefined}, + requestAnimationFrame: () => undefined, + setTimeout: () => 0, + Number, + Object, + Math, + Error, + }); + + for (const kind of ['loading', 'ready', 'stale', 'unsupported', 'disconnected']) { + const message = `${kind} complete text`; + window.fire('message', {data: {type: 'status', status: {kind, message}}}); + assert.equal(status.attributes.get('data-kind'), kind); + assert.equal(status.attributes.get('title'), message); + assert.equal(statusText.textContent, message); + assert.equal(status.textContent, ''); + } + + const acceptedKind = status.attributes.get('data-kind'); + const acceptedText = statusText.textContent; + for (const malformed of [ + {type: 'status', status: {kind: 'unknown', message: 'Unknown'}}, + {type: 'status', status: {kind: 'ready', message: 42}}, + {type: 'status', status: null}, + {type: 'status', status: ['ready']}, + {type: 'status', status: {kind: 'ready', message: 'Injected'}, extra: true}, + ]) { + window.fire('message', {data: malformed}); + assert.equal(status.attributes.get('data-kind'), acceptedKind); + assert.equal(statusText.textContent, acceptedText); + } + }); }); class FakeElement { diff --git a/src/webview/webview.css b/src/webview/webview.css index 92d72c8..5feda8f 100644 --- a/src/webview/webview.css +++ b/src/webview/webview.css @@ -38,25 +38,73 @@ body { #toolbar { display: flex; flex: 0 0 auto; + flex-wrap: wrap; align-items: center; - gap: 0.35rem; - padding: 0.45rem; + gap: 0.35rem 0.75rem; + box-sizing: border-box; + padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--vscode-panel-border); } +#toolbar-controls, +#zoom-controls { + display: flex; + flex-wrap: wrap; + align-items: center; + min-width: 0; +} + +#toolbar-controls { + flex: 0 1 auto; + gap: 0.4rem; +} + +#zoom-controls { + gap: 0.15rem; +} + #toolbar button { - min-width: 2rem; - min-height: 1.75rem; + min-height: 1.65rem; + padding: 0 0.55rem; + font: inherit; + cursor: pointer; + border-radius: 2px; +} + +#toolbar .primary-action { color: var(--vscode-button-foreground); background: var(--vscode-button-background); border: 1px solid transparent; - border-radius: 2px; } -#toolbar button:hover { +#toolbar .primary-action:hover { background: var(--vscode-button-hoverBackground); } +#toolbar .toolbar-action { + color: var(--vscode-foreground); + background: transparent; + border: 1px solid transparent; +} + +#toolbar .toolbar-action:hover { + background: var(--vscode-toolbar-hoverBackground, var(--vscode-list-hoverBackground)); +} + +#toolbar .icon-action { + width: 1.75rem; + min-width: 1.75rem; + padding: 0; + font-size: 1.1rem; +} + +.toolbar-separator { + align-self: stretch; + width: 1px; + min-height: 1.25rem; + background: var(--vscode-panel-border); +} + #toolbar button:focus-visible, #viewport:focus-visible { outline: 1px solid var(--vscode-focusBorder); @@ -64,18 +112,115 @@ body { } #status { - min-width: 12rem; - margin: 0 0 0 0.5rem; + display: flex; + align-items: center; + gap: 0.4rem; + min-width: 0; + max-width: 50%; + margin: 0 0 0 auto; + padding: 0.2rem 0; +} + +#status-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#status-indicator { + flex: 0 0 auto; + box-sizing: border-box; + width: 0.55rem; + height: 0.55rem; + border-radius: 50%; +} + +#status[data-kind="ready"] #status-indicator { + background: var(--vscode-testing-iconPassed, var(--vscode-foreground)); +} + +#status[data-kind="loading"] #status-indicator { + width: 0.8rem; + height: 0.8rem; + border: 2px solid var(--vscode-panel-border); + border-top-color: var(--vscode-progressBar-background, var(--vscode-foreground)); + animation: status-spin 0.8s linear infinite; +} + +#status[data-kind="stale"], +#status[data-kind="unsupported"], +#status[data-kind="disconnected"] { + flex: 1 0 100%; + order: 2; + box-sizing: border-box; + width: 100%; + max-width: none; + margin: 0; + padding: 0.4rem 0.55rem; + border: 1px solid var(--vscode-panel-border); + border-left-width: 3px; +} + +#status[data-kind="stale"] #status-indicator, +#status[data-kind="unsupported"] #status-indicator, +#status[data-kind="disconnected"] #status-indicator { + width: auto; + height: auto; + border-radius: 0; +} + +#status[data-kind="stale"], +#status[data-kind="unsupported"] { + border-left-color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-foreground)); +} + +#status[data-kind="stale"] #status-indicator::before, +#status[data-kind="unsupported"] #status-indicator::before { + color: var(--vscode-notificationsWarningIcon-foreground, var(--vscode-foreground)); + content: "⚠"; +} + +#status[data-kind="disconnected"] { + border-left-color: var(--vscode-notificationsErrorIcon-foreground, var(--vscode-foreground)); +} + +#status[data-kind="disconnected"] #status-indicator::before { + color: var(--vscode-notificationsErrorIcon-foreground, var(--vscode-foreground)); + content: "×"; + font-weight: 700; +} + #zoom-value { - min-width: 3.5rem; + min-width: 3rem; text-align: center; } +@keyframes status-spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + #status[data-kind="loading"] #status-indicator { + animation: none; + } +} + +@media (max-width: 520px) { + #toolbar-controls { + flex: 1 1 100%; + justify-content: space-between; + } + + #status[data-kind="ready"], + #status[data-kind="loading"] { + flex: 1 1 100%; + max-width: none; + margin-left: 0; + } +} + #viewport { box-sizing: border-box; flex: 1 1 auto; diff --git a/src/webview/webview.js b/src/webview/webview.js index 8005907..073346b 100644 --- a/src/webview/webview.js +++ b/src/webview/webview.js @@ -21,7 +21,9 @@ const viewport = document.querySelector('#viewport'); const diagram = document.querySelector('#diagram'); const status = document.querySelector('#status'); + const statusText = document.querySelector('#status-text'); const zoomValue = document.querySelector('#zoom-value'); + const STATUS_KINDS = Object.freeze(['loading', 'ready', 'stale', 'unsupported', 'disconnected']); let currentVersion = null; let currentSvg = null; let baseWidth = 0; @@ -60,6 +62,21 @@ zoomValue.textContent = `${Math.round(state.zoom * 100)}%`; } + function updateStatus(candidate) { + if ( + !candidate || + typeof candidate !== 'object' || + Array.isArray(candidate) || + !STATUS_KINDS.includes(candidate.kind) || + typeof candidate.message !== 'string' + ) { + return; + } + status.setAttribute('data-kind', candidate.kind); + status.setAttribute('title', candidate.message); + statusText.textContent = candidate.message; + } + function captureViewport() { state = {...state, scrollLeft: Math.max(0, viewport.scrollLeft), scrollTop: Math.max(0, viewport.scrollTop)}; } @@ -167,7 +184,10 @@ applyZoom(); restoreViewport(currentVersion, true); } catch (error) { - status.textContent = 'The new diagram could not be displayed safely. The last valid diagram is retained.'; + updateStatus({ + kind: 'stale', + message: 'The new diagram could not be displayed safely. The last valid diagram is retained.', + }); vscode.postMessage({type: 'renderError', version: message.version, reason: 'sanitizationFailed'}); } } @@ -222,10 +242,9 @@ isExactMessage(message, ['status', 'type']) && message.type === 'status' && message.status && - typeof message.status.message === 'string' + typeof message.status === 'object' ) { - status.textContent = message.status.message; - return; + updateStatus(message.status); } }); From f520453f95b1becb730a674a091ab47e5bb4b480 Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Wed, 16 Sep 2026 09:47:50 +0300 Subject: [PATCH 09/15] improve graphical view open tab management --- src/graphicalView.ts | 37 +++- ...raphicalViewEditorReuse.acceptance.test.ts | 175 ++++++++++++++++++ src/test/graphicalViewNavigation.test.ts | 155 ++++++++++++++++ src/test/graphicalViewTestHarness.ts | 28 ++- 4 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 src/test/graphicalViewEditorReuse.acceptance.test.ts diff --git a/src/graphicalView.ts b/src/graphicalView.ts index 753f8b1..4298819 100644 --- a/src/graphicalView.ts +++ b/src/graphicalView.ts @@ -33,10 +33,41 @@ export interface GraphicalViewDocument { export interface GraphicalViewWindow { readonly activeTextEditor: {readonly document: GraphicalViewDocument} | undefined; + readonly tabGroups: { + readonly all: readonly GraphicalViewTabGroup[]; + }; showWarningMessage(message: string): Thenable; showTextDocument(uri: vscode.Uri, options?: vscode.TextDocumentShowOptions): Thenable; } +export interface GraphicalViewTabGroup { + readonly viewColumn: vscode.ViewColumn; + readonly tabs: readonly { + readonly input: unknown; + readonly isActive: boolean; + }[]; +} + +export function findReusableTextEditorViewColumn( + uri: vscode.Uri, + groups: readonly GraphicalViewTabGroup[], +): vscode.ViewColumn | undefined { + const canonicalUri = uri.toString(); + let firstMatchingColumn: vscode.ViewColumn | undefined; + for (const group of groups) { + for (const tab of group.tabs) { + if (!(tab.input instanceof vscode.TabInputText) || tab.input.uri.toString() !== canonicalUri) { + continue; + } + if (tab.isActive) { + return group.viewColumn; + } + firstMatchingColumn ??= group.viewColumn; + } + } + return firstMatchingColumn; +} + export interface GraphicalViewWorkspace { onDidSaveTextDocument(listener: (document: GraphicalViewDocument) => void): vscode.Disposable; onDidChangeDocumentAvailability(listener: (sourceUri: string, available: boolean) => void): vscode.Disposable; @@ -401,7 +432,11 @@ export class GraphicalViewController implements vscode.Disposable { } try { - const editor = await this.window.showTextDocument(resolution.uri, {preview: false}); + const viewColumn = findReusableTextEditorViewColumn(resolution.uri, this.window.tabGroups.all); + const options: vscode.TextDocumentShowOptions = viewColumn === undefined + ? {preview: false} + : {preview: false, viewColumn}; + const editor = await this.window.showTextDocument(resolution.uri, options); if (!this.isCurrentNavigationResult(state, accepted, target)) { return; } diff --git a/src/test/graphicalViewEditorReuse.acceptance.test.ts b/src/test/graphicalViewEditorReuse.acceptance.test.ts new file mode 100644 index 0000000..db2f1af --- /dev/null +++ b/src/test/graphicalViewEditorReuse.acceptance.test.ts @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import * as vscode from 'vscode'; +import type {GraphicalViewRenderResult} from '../graphicalViewProtocol'; +import {createGraphicalViewHarnessWithWindow, FakeGraphicalViewClient, flushPromises} from './graphicalViewTestHarness'; + +const OWNER = 'urn:samm:example.graphical:1.0.0#Aspect'; +const TARGET_ID = 'gv-header-aaaaaaaaaaaaaaaa'; +const createdUris: vscode.Uri[] = []; + +suite('Graphical View editor reuse acceptance', () => { + setup(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + }); + + teardown(async () => { + await vscode.commands.executeCommand('workbench.action.closeAllEditors'); + for (const uri of createdUris.splice(0)) { + try { + await vscode.workspace.fs.delete(uri); + } catch (_error) { + // The test owns these unique temporary files; a missing file is already clean. + } + } + }); + + test('reuses the original visible source tab and does not create a second instance', async () => { + const source = await createTurtleFile('visible-source', 'line 0\nline 1 target\n'); + const distracting = await createTurtleFile('visible-distracting', 'other\n'); + await vscode.window.showTextDocument(source, {viewColumn: vscode.ViewColumn.One, preview: false}); + const harness = await createAcceptedHarness(source); + await vscode.window.showTextDocument(distracting, {viewColumn: vscode.ViewColumn.Two, preview: false}); + + clickTarget(harness, source, 1, 7, 1, 13); + await waitForNavigation(source, new vscode.Selection(1, 7, 1, 13)); + + assert.equal(textTabs(source).length, 1); + assert.equal(activeTextTabGroup(source)?.viewColumn, vscode.ViewColumn.One); + assert.ok(vscode.window.activeTextEditor?.selection.isEqual(new vscode.Selection(1, 7, 1, 13))); + harness.controller.dispose(); + }); + + test('finds and reuses a source tab hidden behind another tab in its group', async () => { + const source = await createTurtleFile('hidden-source', 'line 0\nline 1 target\n'); + const cover = await createTurtleFile('hidden-cover', 'cover\n'); + const distracting = await createTurtleFile('hidden-distracting', 'other\n'); + await vscode.window.showTextDocument(source, {viewColumn: vscode.ViewColumn.One, preview: false}); + const harness = await createAcceptedHarness(source); + await vscode.window.showTextDocument(cover, {viewColumn: vscode.ViewColumn.One, preview: false}); + await vscode.window.showTextDocument(distracting, {viewColumn: vscode.ViewColumn.Two, preview: false}); + assert.equal(textTabs(source)[0]?.isActive, false); + + clickTarget(harness, source, 1, 7, 1, 13); + await waitForNavigation(source, new vscode.Selection(1, 7, 1, 13)); + + assert.equal(textTabs(source).length, 1); + assert.equal(activeTextTabGroup(source)?.viewColumn, vscode.ViewColumn.One); + assert.ok(vscode.window.activeTextEditor?.selection.isEqual(new vscode.Selection(1, 7, 1, 13))); + harness.controller.dispose(); + }); + + test('opens an unopened target once through the existing fallback placement', async () => { + const source = await createTurtleFile('fallback-source', 'source\n'); + const target = await createTurtleFile('fallback-target', 'line 0\nline 1 target\n'); + const distracting = await createTurtleFile('fallback-distracting', 'other\n'); + await vscode.window.showTextDocument(source, {viewColumn: vscode.ViewColumn.One, preview: false}); + const harness = await createAcceptedHarness(source); + await vscode.window.showTextDocument(distracting, {viewColumn: vscode.ViewColumn.Two, preview: false}); + assert.equal(textTabs(target).length, 0); + + clickTarget(harness, target, 1, 7, 1, 13); + await waitForNavigation(target, new vscode.Selection(1, 7, 1, 13)); + + assert.equal(textTabs(target).length, 1); + assert.equal(activeTextTabGroup(target)?.viewColumn, vscode.ViewColumn.Two); + assert.ok(vscode.window.activeTextEditor?.selection.isEqual(new vscode.Selection(1, 7, 1, 13))); + harness.controller.dispose(); + }); + + test('retained target resolving to notFound does not open or focus another tab', async () => { + const source = await createTurtleFile('not-found-source', 'source\n'); + const distracting = await createTurtleFile('not-found-distracting', 'other\n'); + await vscode.window.showTextDocument(source, {viewColumn: vscode.ViewColumn.One, preview: false}); + const harness = await createAcceptedHarness(source); + await vscode.window.showTextDocument(distracting, {viewColumn: vscode.ViewColumn.Two, preview: false}); + const activeBefore = vscode.window.activeTextEditor?.document.uri.toString(); + harness.client.resolveResult = {location: null, warning: 'notFound'}; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: TARGET_ID}); + await flushPromises(); + + assert.equal(vscode.window.activeTextEditor?.document.uri.toString(), activeBefore); + assert.equal(textTabs(source).length, 1); + harness.controller.dispose(); + }); +}); + +async function createAcceptedHarness(source: vscode.Uri) { + const client = new FakeGraphicalViewClient(); + const harness = createGraphicalViewHarnessWithWindow(vscode.window, client); + harness.workspace.available.add(source.toString()); + await harness.commands.execute('semantic-models.openGraphicalView'); + client.requests[0].deferred.resolve(renderResult(source)); + await flushPromises(); + return harness; +} + +function clickTarget( + harness: ReturnType>, + target: vscode.Uri, + startLine: number, + startCharacter: number, + endLine: number, + endCharacter: number, +): void { + harness.client.resolveResult = { + location: { + uri: target.toString(), + range: { + start: {line: startLine, character: startCharacter}, + end: {line: endLine, character: endCharacter}, + }, + }, + }; + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: TARGET_ID}); +} + +function renderResult(source: vscode.Uri): GraphicalViewRenderResult { + return { + uri: source.toString(), + svg: ``, + targets: [{id: TARGET_ID, kind: 'elementHeader', elementUrn: OWNER}], + warnings: [], + }; +} + +function textTabs(uri: vscode.Uri): vscode.Tab[] { + return vscode.window.tabGroups.all + .flatMap(group => group.tabs) + .filter(tab => tab.input instanceof vscode.TabInputText && tab.input.uri.toString() === uri.toString()); +} + +function activeTextTabGroup(uri: vscode.Uri): vscode.TabGroup | undefined { + return vscode.window.tabGroups.all.find(group => + group.tabs.some(tab => tab.isActive && tab.input instanceof vscode.TabInputText && tab.input.uri.toString() === uri.toString()), + ); +} + +async function createTurtleFile(name: string, contents: string): Promise { + const uri = vscode.Uri.file(`/tmp/esmf-task7-${process.pid}-${name}.ttl`); + createdUris.push(uri); + await vscode.workspace.fs.writeFile(uri, Buffer.from(contents)); + return uri; +} + +async function waitFor(predicate: () => boolean): Promise { + const deadline = Date.now() + 5_000; + while (!predicate()) { + if (Date.now() >= deadline) { + assert.fail('Timed out waiting for the VS Code editor state.'); + } + await new Promise(resolve => setTimeout(resolve, 10)); + } +} + +async function waitForNavigation(uri: vscode.Uri, selection: vscode.Selection): Promise { + await waitFor(() => { + const editor = vscode.window.activeTextEditor; + return editor?.document.uri.toString() === uri.toString() && editor.selection.isEqual(selection); + }); +} diff --git a/src/test/graphicalViewNavigation.test.ts b/src/test/graphicalViewNavigation.test.ts index c1227bb..c206655 100644 --- a/src/test/graphicalViewNavigation.test.ts +++ b/src/test/graphicalViewNavigation.test.ts @@ -5,6 +5,7 @@ import * as assert from 'node:assert/strict'; import * as vscode from 'vscode'; +import {findReusableTextEditorViewColumn} from '../graphicalView'; import {resolveGraphicalViewNavigation, validateGraphicalViewResolveResult} from '../graphicalViewNavigation'; import type {GraphicalViewRenderResult} from '../graphicalViewProtocol'; import { @@ -78,6 +79,136 @@ suite('Graphical View source navigation', () => { assert.ok(invalid.every(candidate => validateGraphicalViewResolveResult(candidate).kind === 'warning')); }); + test('finds visible and hidden regular text tabs while ignoring diff and custom inputs', () => { + const target = vscode.Uri.file('/tmp/reused.ttl'); + const groups = [ + tabGroup(vscode.ViewColumn.One, [ + tab(new vscode.TabInputTextDiff(vscode.Uri.file('/tmp/original.ttl'), target), true), + tab(new vscode.TabInputCustom(target, 'test.customEditor'), false), + ]), + tabGroup(vscode.ViewColumn.Two, [ + tab(new vscode.TabInputText(target), false), + tab(new vscode.TabInputText(vscode.Uri.file('/tmp/visible.ttl')), true), + ]), + ]; + + assert.equal(findReusableTextEditorViewColumn(target, groups), vscode.ViewColumn.Two); + }); + + test('uses complete canonical URI identity and rejects similar resource identifiers', () => { + const target = vscode.Uri.parse('file://server/tmp/a%20model.ttl?revision=1#definition'); + const groups = [ + tabGroup(vscode.ViewColumn.One, [ + tab(new vscode.TabInputText(vscode.Uri.parse('untitled://server/tmp/a%20model.ttl?revision=1#definition')), false), + tab(new vscode.TabInputText(vscode.Uri.parse('file://other/tmp/a%20model.ttl?revision=1#definition')), false), + tab(new vscode.TabInputText(vscode.Uri.parse('file://server/tmp/a%20model.ttl?revision=2#definition')), false), + tab(new vscode.TabInputText(vscode.Uri.parse('file://server/tmp/a%20model.ttl?revision=1#other')), false), + tab(new vscode.TabInputText(vscode.Uri.parse('file://server/tmp/a%2520model.ttl?revision=1#definition')), false), + ]), + tabGroup(vscode.ViewColumn.Three, [tab(new vscode.TabInputText(vscode.Uri.parse(target.toString())), false)]), + ]; + + assert.equal(findReusableTextEditorViewColumn(target, groups), vscode.ViewColumn.Three); + }); + + test('prefers an active duplicate match and otherwise keeps stable group order', () => { + const target = vscode.Uri.file('/tmp/duplicate.ttl'); + assert.equal( + findReusableTextEditorViewColumn(target, [ + tabGroup(vscode.ViewColumn.One, [tab(new vscode.TabInputText(target), false)]), + tabGroup(vscode.ViewColumn.Two, [tab(new vscode.TabInputText(target), true)]), + tabGroup(vscode.ViewColumn.Three, [tab(new vscode.TabInputText(target), true)]), + ]), + vscode.ViewColumn.Two, + ); + assert.equal( + findReusableTextEditorViewColumn(target, [ + tabGroup(vscode.ViewColumn.One, [tab(new vscode.TabInputText(target), false)]), + tabGroup(vscode.ViewColumn.Two, [tab(new vscode.TabInputText(target), false)]), + ]), + vscode.ViewColumn.One, + ); + }); + + test('reuses a matching tab group and preserves fallback, selection, and centered reveal', async () => { + const harness = createGraphicalViewHarness(); + const document = createGraphicalViewDocument('/tmp/reuse-controller.ttl'); + harness.workspace.available.add(document.uri.toString()); + await openGraphicalView(harness, document); + const id = successfulHeaderId(); + harness.client.requests[0].deferred.resolve(headerResult(document, id)); + await flushPromises(); + harness.client.resolveResult = { + location: {uri: document.uri.toString(), range: positions(4, 2, 4, 9)}, + }; + harness.window.tabGroups.all = [ + tabGroup(vscode.ViewColumn.Three, [tab(new vscode.TabInputText(document.uri), false)]) as vscode.TabGroup, + ]; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + assert.deepEqual(harness.window.openedEditors[0].options, {preview: false, viewColumn: vscode.ViewColumn.Three}); + assert.ok(harness.window.openedEditors[0].editor.selection.isEqual(new vscode.Selection(4, 2, 4, 9))); + assert.deepEqual(harness.window.openedEditors[0].revealedRanges, [new vscode.Range(4, 2, 4, 9)]); + assert.deepEqual(harness.window.openedEditors[0].revealTypes, [vscode.TextEditorRevealType.InCenterIfOutsideViewport]); + + harness.window.tabGroups.all = []; + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + assert.deepEqual(harness.window.openedEditors[1].options, {preview: false}); + harness.controller.dispose(); + }); + + test('does not select or reveal when navigation becomes stale while editor reveal is pending', async () => { + const harness = createGraphicalViewHarness(); + const document = createGraphicalViewDocument('/tmp/stale-reuse.ttl'); + harness.workspace.available.add(document.uri.toString()); + await openGraphicalView(harness, document); + const id = successfulHeaderId(); + harness.client.requests[0].deferred.resolve(headerResult(document, id)); + await flushPromises(); + harness.client.resolveResult = { + location: {uri: document.uri.toString(), range: positions(1, 2, 1, 7)}, + }; + let resolveEditor!: (editor: vscode.TextEditor) => void; + harness.window.showTextDocumentPromise = new Promise(resolve => { + resolveEditor = resolve; + }); + const revealed: vscode.Range[] = []; + const editor = { + selection: new vscode.Selection(0, 0, 0, 0), + revealRange: (range: vscode.Range) => revealed.push(range), + } as unknown as vscode.TextEditor; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + harness.panels.panels[0].dispose(); + resolveEditor(editor); + await flushPromises(); + + assert.ok(editor.selection.isEqual(new vscode.Selection(0, 0, 0, 0))); + assert.deepEqual(revealed, []); + harness.controller.dispose(); + }); + + test('resolver warnings do not inspect tabs or open an editor', async () => { + const harness = createGraphicalViewHarness(); + const document = createGraphicalViewDocument('/tmp/not-found-before-tabs.ttl'); + harness.workspace.available.add(document.uri.toString()); + await openGraphicalView(harness, document); + const id = successfulHeaderId(); + harness.client.requests[0].deferred.resolve(headerResult(document, id)); + await flushPromises(); + harness.client.resolveResult = {location: null, warning: 'notFound'}; + + harness.panels.panels[0].emitMessage({type: 'navigate', version: 1, targetId: id}); + await flushPromises(); + + assert.deepEqual(harness.window.warnings, ['The graphical target no longer exists in the current model.']); + assert.deepEqual(harness.window.openedEditors, []); + harness.controller.dispose(); + }); + test('opens current multilingual and wrapped attribute targets using only the accepted sidecar', async () => { const harness = createGraphicalViewHarness(); const document = createGraphicalViewDocument('/tmp/navigation.ttl'); @@ -170,3 +301,27 @@ function positions(startLine: number, startCharacter: number, endLine: number, e end: {line: endLine, character: endCharacter}, }; } + +function tab(input: unknown, isActive: boolean): Pick { + return {input, isActive}; +} + +function tabGroup( + viewColumn: vscode.ViewColumn, + tabs: Array>, +): Pick { + return {viewColumn, tabs: tabs as vscode.Tab[]}; +} + +function successfulHeaderId(): string { + return 'gv-header-aaaaaaaaaaaaaaaa'; +} + +function headerResult(document: ReturnType, id: string): GraphicalViewRenderResult { + return { + uri: document.uri.toString(), + svg: ``, + targets: [{id, kind: 'elementHeader', elementUrn: OWNER}], + warnings: [], + }; +} diff --git a/src/test/graphicalViewTestHarness.ts b/src/test/graphicalViewTestHarness.ts index 1333d04..f7fe953 100644 --- a/src/test/graphicalViewTestHarness.ts +++ b/src/test/graphicalViewTestHarness.ts @@ -191,9 +191,19 @@ export class FakeGraphicalViewPanelFactory implements GraphicalViewPanelFactory } export function createGraphicalViewHarness(client = new FakeGraphicalViewClient()) { + return createHarness(client, new FakeWindow()); +} + +export function createGraphicalViewHarnessWithWindow( + window: TWindow, + client = new FakeGraphicalViewClient(), +) { + return createHarness(client, window); +} + +function createHarness(client: FakeGraphicalViewClient, window: TWindow) { const panels = new FakeGraphicalViewPanelFactory(); const commands = new FakeCommands(); - const window = new FakeWindow(); const workspace = new FakeWorkspace(); const outputChannel = new FakeOutputChannel(); const controller = new GraphicalViewController(client, panels, commands, window, workspace, outputChannel); @@ -254,16 +264,19 @@ class FakeCommands implements GraphicalViewCommands { } } -class FakeWindow implements GraphicalViewWindow { +export class FakeWindow implements GraphicalViewWindow { activeTextEditor: {document: GraphicalViewDocument} | undefined; + readonly tabGroups: {all: vscode.TabGroup[]} = {all: []}; readonly warnings: string[] = []; readonly openedEditors: Array<{ uri: vscode.Uri; options: vscode.TextDocumentShowOptions | undefined; editor: vscode.TextEditor; revealedRanges: vscode.Range[]; + revealTypes: Array; }> = []; showTextDocumentFailure: unknown | undefined; + showTextDocumentPromise: Promise | undefined; showWarningMessage(message: string): Promise { this.warnings.push(message); @@ -274,12 +287,19 @@ class FakeWindow implements GraphicalViewWindow { if (this.showTextDocumentFailure !== undefined) { return Promise.reject(this.showTextDocumentFailure); } + if (this.showTextDocumentPromise !== undefined) { + return this.showTextDocumentPromise; + } const revealedRanges: vscode.Range[] = []; + const revealTypes: Array = []; const editor = { selection: new vscode.Selection(0, 0, 0, 0), - revealRange: (range: vscode.Range) => revealedRanges.push(range), + revealRange: (range: vscode.Range, revealType?: vscode.TextEditorRevealType) => { + revealedRanges.push(range); + revealTypes.push(revealType); + }, } as unknown as vscode.TextEditor; - this.openedEditors.push({uri, options, editor, revealedRanges}); + this.openedEditors.push({uri, options, editor, revealedRanges, revealTypes}); return Promise.resolve(editor); } } From 91d79b4f6c145d2946f51ffbd6ddedc201bdc2fa Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Wed, 16 Sep 2026 16:59:28 +0300 Subject: [PATCH 10/15] remove graphical SVG sanitizer --- README.md | 2 + package-lock.json | 17 -- package.json | 1 - scripts/verify-webview-assets.mjs | 9 +- scripts/webview-assets.mjs | 9 +- src/graphicalView.ts | 6 +- src/graphicalViewPanel.ts | 10 +- src/test/graphicalViewController.test.ts | 4 +- src/test/graphicalViewPanel.test.ts | 24 ++- src/test/graphicalViewTestHarness.ts | 2 +- .../graphicalViewWebviewController.test.ts | 53 +++-- .../graphicalViewWebviewLifecycle.test.ts | 29 ++- .../RobotoCondensed-LICENSE-Apache-2.0.txt | 201 ++++++++++++++++++ src/webview/RobotoCondensed-NOTICE.txt | 4 +- src/webview/sanitizer-contract.js | 155 -------------- src/webview/webview.js | 57 +++-- 16 files changed, 336 insertions(+), 247 deletions(-) create mode 100644 src/webview/RobotoCondensed-LICENSE-Apache-2.0.txt delete mode 100644 src/webview/sanitizer-contract.js diff --git a/README.md b/README.md index f0b1757..bf47872 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,8 @@ Element headers can navigate to definitions in local files. Eligible attribute r If rendering fails, the last successful diagram remains visible with an error or warning. Rendering is limited to 1,000 boxes and a 30-second request timeout. The graphical view is not an editor, does not update live while typing, and does not claim visual parity with the Aspect Model Editor. +Graphical View supports SVG only from the shipped/current trusted SAMM language server and generator contract. The extension strict-parses each SVG as XML but does not sanitize or filter its elements and attributes. Its restrictive webview CSP and extension-side message, sidecar, and local-file navigation validation remain enforced. External, substituted, incompatible, or compromised language servers are outside the supported security model; future Graphper or generator changes must preserve the tested style-free passive-output invariant. + ## Running the Server and Extension Together 1. In this extension project, install the dependencies using `npm install`. diff --git a/package-lock.json b/package-lock.json index 3500327..04c2e22 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "0.0.1", "license": "MPL-2.0", "dependencies": { - "dompurify": "3.4.13", "extract-zip": "^2.0.1", "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" @@ -562,13 +561,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/trusted-types": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", - "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", - "license": "MIT", - "optional": true - }, "node_modules/@types/vscode": { "version": "1.120.0", "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.120.0.tgz", @@ -1430,15 +1422,6 @@ "node": ">=0.3.1" } }, - "node_modules/dompurify": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.13.tgz", - "integrity": "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ==", - "license": "(MPL-2.0 OR Apache-2.0)", - "optionalDependencies": { - "@types/trusted-types": "^2.0.7" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", diff --git a/package.json b/package.json index 3465f8c..c4573ab 100644 --- a/package.json +++ b/package.json @@ -233,7 +233,6 @@ "typescript-eslint": "^8.56.1" }, "dependencies": { - "dompurify": "3.4.13", "extract-zip": "^2.0.1", "tar": "^7.5.15", "vscode-languageclient": "^9.0.1" diff --git a/scripts/verify-webview-assets.mjs b/scripts/verify-webview-assets.mjs index db1355c..56d3124 100644 --- a/scripts/verify-webview-assets.mjs +++ b/scripts/verify-webview-assets.mjs @@ -3,14 +3,9 @@ * SPDX-License-Identifier: MPL-2.0 */ -import {readFileSync, readdirSync, statSync} from 'node:fs'; +import {readdirSync, statSync} from 'node:fs'; import {join} from 'node:path'; -import {extensionRoot, outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; - -const packageJson = JSON.parse(readFileSync(join(extensionRoot, 'package.json'), 'utf8')); -if (packageJson.dependencies?.dompurify !== '3.4.13') { - throw new Error('DOMPurify must remain pinned exactly to 3.4.13.'); -} +import {outputDirectory, sha256, webviewAssets} from './webview-assets.mjs'; const actual = readdirSync(outputDirectory).sort(); const expected = webviewAssets.map(asset => asset.destination).sort(); diff --git a/scripts/webview-assets.mjs b/scripts/webview-assets.mjs index f2b8b7a..ca47898 100644 --- a/scripts/webview-assets.mjs +++ b/scripts/webview-assets.mjs @@ -12,12 +12,10 @@ export const extensionRoot = resolve(dirname(fileURLToPath(import.meta.url)), '. export const outputDirectory = resolve(extensionRoot, 'out', 'webview'); export const webviewAssets = Object.freeze([ - asset('node_modules/dompurify/LICENSE', 'DOMPurify-LICENSE-Apache-2.0.txt'), - asset('node_modules/dompurify/LICENSE-MPL', 'DOMPurify-LICENSE-MPL-2.0.txt'), asset( - 'node_modules/dompurify/dist/purify.min.js', - 'purify.min.js', - '9ab3d44d73c3e3947f9ab72e0f0bc15c7f1931d60b365ba261fc85fe59013c56', + 'src/webview/RobotoCondensed-LICENSE-Apache-2.0.txt', + 'RobotoCondensed-LICENSE-Apache-2.0.txt', + '8173d5c29b4f956d532781d2b86e4e30f83e6b7878dce18c919451d6ba707c90', ), asset('src/webview/RobotoCondensed-NOTICE.txt', 'RobotoCondensed-NOTICE.txt'), asset( @@ -25,7 +23,6 @@ export const webviewAssets = Object.freeze([ 'RobotoCondensed-Regular.ttf', '4a7c36df4318fee50a8159c3a0ebde4572abab65447ae4a651c2fe87212302b5', ), - asset('src/webview/sanitizer-contract.js', 'sanitizer-contract.js'), asset('src/webview/webview.css', 'webview.css'), asset('src/webview/webview.js', 'webview.js'), ]); diff --git a/src/graphicalView.ts b/src/graphicalView.ts index 4298819..2bc6ba2 100644 --- a/src/graphicalView.ts +++ b/src/graphicalView.ts @@ -393,11 +393,11 @@ export class GraphicalViewController implements vscode.Disposable { } else if (state.lastSuccess?.version !== version) { return; } - this.outputChannel.warn('Graphical view rejected an SVG payload at the secure rendering boundary.'); + this.outputChannel.warn('Graphical view rejected an SVG payload at the strict XML rendering boundary.'); this.setStale( state, - 'sanitizationFailed', - 'The new diagram could not be displayed safely. The last successful diagram is retained.', + 'xmlParsingFailed', + 'The new diagram is not a usable SVG document. The last successful diagram is retained.', ); } diff --git a/src/graphicalViewPanel.ts b/src/graphicalViewPanel.ts index cf283e6..91c3e14 100644 --- a/src/graphicalViewPanel.ts +++ b/src/graphicalViewPanel.ts @@ -17,7 +17,7 @@ import {GRAPHICAL_VIEW_MARKER_PATTERN} from './graphicalViewProtocol'; const VIEW_TYPE = 'semantic-models.graphicalView'; export const WEBVIEW_ASSET_DIRECTORY = Object.freeze(['out', 'webview'] as const); -export const WEBVIEW_SCRIPT_ORDER = Object.freeze(['purify.min.js', 'sanitizer-contract.js', 'webview.js'] as const); +export const WEBVIEW_SCRIPT_ORDER = Object.freeze(['webview.js'] as const); export type GraphicalViewStatus = | Readonly<{kind: 'loading'; message: string}> @@ -34,7 +34,7 @@ export type GraphicalViewPanelMessage = | Readonly<{type: 'ready'}> | Readonly<{type: 'refresh'}> | Readonly<{type: 'rendered'; version: number}> - | Readonly<{type: 'renderError'; version: number; reason: 'sanitizationFailed'}> + | Readonly<{type: 'renderError'; version: number; reason: 'xmlParsingFailed'}> | Readonly<{type: 'navigate'; version: number; targetId: string}>; export interface GraphicalViewPanel extends vscode.Disposable { @@ -167,8 +167,6 @@ export function createGraphicalViewShell(
- - `; } @@ -188,9 +186,9 @@ export function parseGraphicalViewPanelMessage(value: unknown): GraphicalViewPan } if (value.type === 'renderError' && hasExactKeys(keys, ['reason', 'type', 'version']) - && value.reason === 'sanitizationFailed' + && value.reason === 'xmlParsingFailed' && isDisplayedVersion(value.version)) { - return value as {type: 'renderError'; version: number; reason: 'sanitizationFailed'}; + return value as {type: 'renderError'; version: number; reason: 'xmlParsingFailed'}; } if (value.type === 'navigate' && hasExactKeys(keys, ['targetId', 'type', 'version']) diff --git a/src/test/graphicalViewController.test.ts b/src/test/graphicalViewController.test.ts index 91b5c65..3f3e734 100644 --- a/src/test/graphicalViewController.test.ts +++ b/src/test/graphicalViewController.test.ts @@ -178,7 +178,7 @@ suite('GraphicalViewController lifecycle', () => { harness.controller.dispose(); }); - test('retains the last accepted diagram for warning, transport, and sanitizer failures', async () => { + test('retains the last accepted diagram for warning, transport, and XML parsing failures', async () => { const harness = createGraphicalViewHarness(); const document = track(harness, '/tmp/retention.ttl'); await openGraphicalView(harness, document); @@ -202,7 +202,7 @@ suite('GraphicalViewController lifecycle', () => { assert.equal(renderDeliveries(panel).at(-1)?.svg, retained.svg); assert.equal(lastStatus(panel)?.kind, 'stale'); - assert.ok(harness.outputChannel.lines.some(line => line.includes('secure rendering boundary'))); + assert.ok(harness.outputChannel.lines.some(line => line.includes('strict XML rendering boundary'))); harness.controller.dispose(); }); diff --git a/src/test/graphicalViewPanel.test.ts b/src/test/graphicalViewPanel.test.ts index 572039d..5741578 100644 --- a/src/test/graphicalViewPanel.test.ts +++ b/src/test/graphicalViewPanel.test.ts @@ -56,8 +56,8 @@ suite('GraphicalView secure panel contract', () => { "font-src vscode-webview-resource:; img-src 'none'; connect-src 'none'; object-src 'none'; " + "base-uri 'none'; form-action 'none'"; assert.ok(first.includes(`content="${expectedCsp}"`)); - assert.equal((first.match(/'}); - await waitFor(() => hasMessage(messages, 'renderError', 3), 'fail-closed hostile render'); + await panel.webview.postMessage({ + type: 'render', + version: 3, + svg: '', + }); + await waitFor(() => hasMessage(messages, 'renderError', 3), 'XML-forbidden control rejection'); + await panel.webview.postMessage({type: 'render', version: 4, svg: ''}); + await waitFor(() => hasMessage(messages, 'renderError', 4), 'non-SVG root rejection'); + await panel.webview.postMessage({ + type: 'render', + version: 5, + svg: '', + }); + await waitFor(() => hasMessage(messages, 'renderError', 5), 'invalid dimension rejection'); + await panel.webview.postMessage({type: 'render', version: 6, svg: ''}); + await waitFor(() => hasMessage(messages, 'renderError', 6), 'missing root rejection'); + await panel.webview.postMessage({ + type: 'render', + version: 7, + svg: '', + }); + await waitFor(() => hasMessage(messages, 'renderError', 7), 'multiple root rejection'); + await panel.webview.postMessage({type: 'render', version: 8, svg: ''}); + await waitFor(() => hasMessage(messages, 'renderError', 8), 'wrong namespace rejection'); } finally { subscription.dispose(); panel.dispose(); @@ -65,8 +87,7 @@ suite('GraphicalView real webview lifecycle', function () { }); function graphperSvg(marker: string): string { - return ` - + return ` diff --git a/src/webview/RobotoCondensed-LICENSE-Apache-2.0.txt b/src/webview/RobotoCondensed-LICENSE-Apache-2.0.txt new file mode 100644 index 0000000..11069ed --- /dev/null +++ b/src/webview/RobotoCondensed-LICENSE-Apache-2.0.txt @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/src/webview/RobotoCondensed-NOTICE.txt b/src/webview/RobotoCondensed-NOTICE.txt index fb40b42..8fc3164 100644 --- a/src/webview/RobotoCondensed-NOTICE.txt +++ b/src/webview/RobotoCondensed-NOTICE.txt @@ -15,5 +15,5 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -The complete Apache License 2.0 text is included in this extension as -DOMPurify-LICENSE-Apache-2.0.txt and is also available at the URL above. +The full Apache License 2.0 text is distributed alongside this notice as +RobotoCondensed-LICENSE-Apache-2.0.txt. diff --git a/src/webview/sanitizer-contract.js b/src/webview/sanitizer-contract.js deleted file mode 100644 index 0e4e621..0000000 --- a/src/webview/sanitizer-contract.js +++ /dev/null @@ -1,155 +0,0 @@ -/* - * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH - * - * See the AUTHORS file(s) distributed with this work for additional - * information regarding authorship. - * - * This Source Code Form is subject to the terms of the Mozilla Public - * License, v. 2.0. If a copy of the MPL was not distributed with this - * file, You can obtain one at https://mozilla.org/MPL/2.0/. - * - * SPDX-License-Identifier: MPL-2.0 - */ - -(function () { - 'use strict'; - - const ALLOWED_TAGS = Object.freeze(['svg', 'g', 'path', 'polygon', 'text', 'title']); - const TAG_ATTRIBUTES = Object.freeze({ - svg: new Set(['xmlns', 'height', 'width', 'viewBox']), - g: new Set(['id', 'transform']), - path: new Set(['d', 'fill', 'stroke']), - polygon: new Set(['points', 'fill', 'stroke', 'stroke-width', 'cx', 'cy', 'rx', 'ry']), - text: new Set(['x', 'y', 'fill', 'font-family', 'font-size', 'text-anchor']), - title: new Set(), - }); - const ALLOWED_ATTRIBUTES = Object.freeze( - Array.from(new Set(Object.values(TAG_ATTRIBUTES).flatMap(attributes => Array.from(attributes)))), - ); - const MARKER_PATTERN = /^gv-(?:header|attribute)-[a-z0-9]{16,32}$/; - const COLOR_PATTERN = /^(?:none|#[0-9a-f]{6})$/i; - const FONT_FAMILIES = new Set(['Arial', 'Roboto Condensed']); - const NUMBER = '[+-]?(?:\\d+(?:\\.\\d*)?|\\.\\d+)(?:[eE][+-]?\\d+)?'; - const NUMBER_PATTERN = new RegExp(`^${NUMBER}$`); - const LENGTH_PATTERN = new RegExp(`^${NUMBER}(?:pt)?$`); - const VIEW_BOX_PATTERN = new RegExp(`^${NUMBER}\\s+${NUMBER}\\s+${NUMBER}\\s+${NUMBER}$`); - const TRANSFORM_PATTERN = new RegExp(`^scale\\(${NUMBER}\\s+${NUMBER}\\)\\s+rotate\\(${NUMBER}\\)$`); - const PATH_PATTERN = /^[MmLlHhVvCcSsQqTtAaZzEe0-9.,+\-\s]+$/; - const POINTS_PATTERN = /^[0-9eE.,+\-\s]+$/; - const FORBIDDEN_SCHEME_PATTERN = /^(?:https?|file|command|javascript|data):/i; - - const DOMPURIFY_CONFIG = Object.freeze({ - ALLOWED_TAGS, - ALLOWED_ATTR: ALLOWED_ATTRIBUTES, - ALLOW_ARIA_ATTR: false, - ALLOW_DATA_ATTR: false, - ALLOW_UNKNOWN_PROTOCOLS: false, - FORBID_TAGS: ['a', 'style', 'script', 'foreignObject', 'defs', 'clipPath', 'tspan', 'image', 'use'], - FORBID_ATTR: ['href', 'xlink:href', 'style'], - RETURN_DOM_FRAGMENT: true, - SANITIZE_DOM: true, - SANITIZE_NAMED_PROPS: false, - }); - - function sanitizeSvg(svgText) { - if (typeof svgText !== 'string') { - throw new TypeError('SVG payload must be a string'); - } - - const fragment = DOMPurify.sanitize(svgText, DOMPURIFY_CONFIG); - validatePurifierRemovals(DOMPurify.removed); - const roots = Array.from(fragment.children); - if (roots.length !== 1 || roots[0].localName !== 'svg') { - throw new Error('Sanitized payload must contain exactly one SVG root'); - } - - const svg = roots[0]; - const markerIds = new Set(); - for (const element of [svg, ...svg.querySelectorAll('*')]) { - const permittedAttributes = TAG_ATTRIBUTES[element.localName]; - if (!permittedAttributes) { - throw new Error('Sanitized payload contains an unsupported SVG element'); - } - - if (element.hasAttribute('id')) { - const id = element.getAttribute('id'); - if (element.localName !== 'g' || !MARKER_PATTERN.test(id)) { - element.removeAttribute('id'); - } else if (markerIds.has(id)) { - throw new Error('Sanitized payload contains a duplicate navigation marker'); - } else { - markerIds.add(id); - } - } - - for (const attribute of Array.from(element.attributes)) { - if (!permittedAttributes.has(attribute.name)) { - throw new Error('Sanitized payload contains an attribute on an unsupported element'); - } - } - - for (const attribute of Array.from(element.attributes)) { - const value = attribute.value.trim(); - if (attribute.name !== 'xmlns' && (/url\s*\(/i.test(value) || FORBIDDEN_SCHEME_PATTERN.test(value))) { - throw new Error('Sanitized payload contains a forbidden resource value'); - } - } - - validateOptionalValue(element, 'fill', COLOR_PATTERN); - validateOptionalValue(element, 'stroke', COLOR_PATTERN); - validateOptionalSetValue(element, 'font-family', FONT_FAMILIES); - validateOptionalExactValue(element, 'xmlns', 'http://www.w3.org/2000/svg'); - validateOptionalExactValue(element, 'text-anchor', 'middle'); - validateOptionalValue(element, 'height', LENGTH_PATTERN); - validateOptionalValue(element, 'width', LENGTH_PATTERN); - validateOptionalValue(element, 'viewBox', VIEW_BOX_PATTERN); - validateOptionalValue(element, 'transform', TRANSFORM_PATTERN); - validateOptionalValue(element, 'd', PATH_PATTERN); - validateOptionalValue(element, 'points', POINTS_PATTERN); - for (const name of ['cx', 'cy', 'rx', 'ry', 'stroke-width', 'font-size', 'x', 'y']) { - validateOptionalValue(element, name, NUMBER_PATTERN); - } - } - - return {fragment, svg, markerIds: Object.freeze(Array.from(markerIds))}; - } - - function validatePurifierRemovals(removals) { - for (const removal of removals) { - if (removal.element?.localName === 'style' || removal.element?.localName === 'body') { - continue; - } - const attributeName = removal.attribute?.name; - if (attributeName === 'class' || attributeName === 'xmlns:xlink') { - continue; - } - const constructKind = removal.element ? 'element' : 'attribute'; - const constructName = boundedConstructName(removal.element?.localName ?? attributeName); - throw new Error(`DOMPurify removed an unsupported SVG ${constructKind}: ${constructName}`); - } - } - - function boundedConstructName(value) { - return typeof value === 'string' && /^[a-z0-9:_-]{1,40}$/i.test(value) ? value : 'unknown'; - } - - function validateOptionalValue(element, attributeName, pattern) { - if (element.hasAttribute(attributeName) && !pattern.test(element.getAttribute(attributeName).trim())) { - throw new Error('Sanitized payload contains an invalid SVG attribute value'); - } - } - - function validateOptionalSetValue(element, attributeName, values) { - if (element.hasAttribute(attributeName) && !values.has(element.getAttribute(attributeName))) { - throw new Error('Sanitized payload contains an unsupported SVG attribute value'); - } - } - - function validateOptionalExactValue(element, attributeName, expected) { - if (element.hasAttribute(attributeName) && element.getAttribute(attributeName) !== expected) { - throw new Error('Sanitized payload contains an unsupported SVG attribute value'); - } - } - - globalThis.SanitizerContract = Object.freeze({MARKER_PATTERN, sanitizeSvg}); -})(); diff --git a/src/webview/webview.js b/src/webview/webview.js index 073346b..dec58de 100644 --- a/src/webview/webview.js +++ b/src/webview/webview.js @@ -17,6 +17,9 @@ const MIN_ZOOM = 0.25; const MAX_ZOOM = 4; const ZOOM_FACTOR = 1.2; + const SVG_NAMESPACE = 'http://www.w3.org/2000/svg'; + const MARKER_PATTERN = /^gv-(?:header|attribute)-[a-z0-9]{16,32}$/; + const POSITIVE_DIMENSION_PATTERN = /^(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?(?:pt)?$/; const vscode = acquireVsCodeApi(); const viewport = document.querySelector('#viewport'); const diagram = document.querySelector('#diagram'); @@ -82,15 +85,37 @@ } function dimensions(svg) { - const width = Number.parseFloat(svg.getAttribute('width')); - const height = Number.parseFloat(svg.getAttribute('height')); - const viewBox = svg.viewBox?.baseVal; - const resolvedWidth = Number.isFinite(width) && width > 0 ? width : viewBox?.width; - const resolvedHeight = Number.isFinite(height) && height > 0 ? height : viewBox?.height; - if (!Number.isFinite(resolvedWidth) || resolvedWidth <= 0 || !Number.isFinite(resolvedHeight) || resolvedHeight <= 0) { + const width = parseDimension(svg.getAttribute('width')); + const height = parseDimension(svg.getAttribute('height')); + if (width === undefined || height === undefined) { throw new Error('SVG dimensions are unavailable'); } - return {width: resolvedWidth, height: resolvedHeight}; + return {width, height}; + } + + function parseDimension(value) { + if (typeof value !== 'string' || !POSITIVE_DIMENSION_PATTERN.test(value)) { + return undefined; + } + const parsed = Number(value.endsWith('pt') ? value.slice(0, -2) : value); + return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined; + } + + function parseTrustedSvg(svgText) { + const parsed = new DOMParser().parseFromString(svgText, 'image/svg+xml'); + if (parsed.getElementsByTagName('parsererror').length > 0) { + throw new Error('SVG is not well-formed XML'); + } + const svg = parsed.documentElement; + if (!svg || svg.localName !== 'svg' || svg.namespaceURI !== SVG_NAMESPACE) { + throw new Error('SVG document root is invalid'); + } + dimensions(svg); + const imported = document.importNode(svg, true); + if (!(imported instanceof Element) || imported.localName !== 'svg' || imported.namespaceURI !== SVG_NAMESPACE) { + throw new Error('SVG document root could not be imported'); + } + return imported; } function applyZoom() { @@ -173,11 +198,11 @@ if (currentSvg) { captureViewport(); } - const sanitized = SanitizerContract.sanitizeSvg(message.svg); - const size = dimensions(sanitized.svg); - makeNavigationMarkersInteractive(sanitized.svg); - diagram.replaceChildren(sanitized.fragment); - currentSvg = sanitized.svg; + const parsedSvg = parseTrustedSvg(message.svg); + const size = dimensions(parsedSvg); + makeNavigationMarkersInteractive(parsedSvg); + diagram.replaceChildren(parsedSvg); + currentSvg = parsedSvg; currentVersion = message.version; baseWidth = size.width; baseHeight = size.height; @@ -186,9 +211,9 @@ } catch (error) { updateStatus({ kind: 'stale', - message: 'The new diagram could not be displayed safely. The last valid diagram is retained.', + message: 'The new diagram is not a usable SVG document. The last valid diagram is retained.', }); - vscode.postMessage({type: 'renderError', version: message.version, reason: 'sanitizationFailed'}); + vscode.postMessage({type: 'renderError', version: message.version, reason: 'xmlParsingFailed'}); } } @@ -207,7 +232,7 @@ function makeNavigationMarkersInteractive(svg) { for (const group of svg.querySelectorAll('g[id]')) { - if (SanitizerContract.MARKER_PATTERN.test(group.id)) { + if (MARKER_PATTERN.test(group.id)) { group.setAttribute('tabindex', '0'); group.setAttribute('role', 'link'); group.setAttribute('aria-label', group.id.startsWith('gv-attribute-') @@ -219,7 +244,7 @@ function activateNavigationTarget(target) { const group = target instanceof Element ? target.closest('g[id]') : null; - if (!group || !diagram.contains(group) || !SanitizerContract.MARKER_PATTERN.test(group.id) || !Number.isInteger(currentVersion)) { + if (!group || !diagram.contains(group) || !MARKER_PATTERN.test(group.id) || !Number.isInteger(currentVersion)) { return false; } vscode.postMessage({type: 'navigate', version: currentVersion, targetId: group.id}); From f48f58df8c1dbea2e3ea1fc90567303322680b6b Mon Sep 17 00:00:00 2001 From: e-filchenko-bosh Date: Thu, 17 Sep 2026 09:43:45 +0300 Subject: [PATCH 11/15] improve graphical viewport controls --- .vscode-test.mjs | 16 +- src/test/graphicalViewPanel.test.ts | 16 +- src/test/graphicalViewViewport.test.ts | 531 ++++++++++++++++++ .../graphicalViewWebviewController.test.ts | 8 +- .../graphicalViewWebviewLifecycle.test.ts | 270 ++++++++- src/webview/webview.css | 64 ++- src/webview/webview.js | 235 ++++++-- 7 files changed, 1058 insertions(+), 82 deletions(-) create mode 100644 src/test/graphicalViewViewport.test.ts diff --git a/.vscode-test.mjs b/.vscode-test.mjs index b36d3f4..f781185 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,12 +1,14 @@ -import { defineConfig } from '@vscode/test-cli'; +import {defineConfig} from '@vscode/test-cli'; const vscodeExecutablePath = process.env.VSCODE_EXECUTABLE_PATH; +const runSuffix = process.env.VSCODE_TEST_RUN_ID?.replace(/[^a-zA-Z0-9_-]/g, '-'); +const testDirectorySuffix = runSuffix ? `-${runSuffix}` : ''; export default defineConfig({ - files: 'out/test/**/*.test.js', - ...(vscodeExecutablePath ? { useInstallation: { fromPath: vscodeExecutablePath } } : {}), - launchArgs: [ - '--user-data-dir=/tmp/extension-vscode-test-user-data', - '--extensions-dir=/tmp/extension-vscode-test-extensions' - ], + files: 'out/test/**/*.test.js', + ...(vscodeExecutablePath ? {useInstallation: {fromPath: vscodeExecutablePath}} : {}), + launchArgs: [ + `--user-data-dir=/tmp/extension-vscode-test-user-data${testDirectorySuffix}`, + `--extensions-dir=/tmp/extension-vscode-test-extensions${testDirectorySuffix}`, + ], }); diff --git a/src/test/graphicalViewPanel.test.ts b/src/test/graphicalViewPanel.test.ts index 5741578..960c380 100644 --- a/src/test/graphicalViewPanel.test.ts +++ b/src/test/graphicalViewPanel.test.ts @@ -90,7 +90,7 @@ suite('GraphicalView secure panel contract', () => { const stylesheet = readFileSync(join(__dirname, '..', '..', 'src', 'webview', 'webview.css'), 'utf8'); for (const kind of ['ready', 'loading', 'stale', 'unsupported', 'disconnected']) { - assert.ok(stylesheet.includes(`#status[data-kind="${kind}"]`)); + assert.match(stylesheet, new RegExp(`#status\\[data-kind=['"]${kind}['"]\\]`)); } for (const themeVariable of [ '--vscode-testing-iconPassed', @@ -103,8 +103,8 @@ suite('GraphicalView secure panel contract', () => { assert.ok(stylesheet.includes(themeVariable)); } assert.match(stylesheet, /#toolbar\s*{[^}]*flex-wrap: wrap;/s); - assert.match(stylesheet, /#status\[data-kind="loading"\] #status-indicator\s*{[^}]*animation: status-spin/s); - assert.match(stylesheet, /#status\[data-kind="stale"\],[\s\S]*flex: 1 0 100%;/); + assert.match(stylesheet, /#status\[data-kind=['"]loading['"]\] #status-indicator\s*{[^}]*animation: status-spin/s); + assert.match(stylesheet, /#status\[data-kind=['"]stale['"]\],[\s\S]*flex: 1 0 100%;/); assert.match(stylesheet, /@media \(max-width: 520px\)/); }); @@ -117,10 +117,11 @@ suite('GraphicalView secure panel contract', () => { version: 1, reason: 'xmlParsingFailed', }); - assert.deepEqual( - parseGraphicalViewPanelMessage({type: 'navigate', version: 2, targetId: 'gv-attribute-aaaaaaaaaaaaaaaa'}), - {type: 'navigate', version: 2, targetId: 'gv-attribute-aaaaaaaaaaaaaaaa'}, - ); + assert.deepEqual(parseGraphicalViewPanelMessage({type: 'navigate', version: 2, targetId: 'gv-attribute-aaaaaaaaaaaaaaaa'}), { + type: 'navigate', + version: 2, + targetId: 'gv-attribute-aaaaaaaaaaaaaaaa', + }); for (const message of [ {type: 'ready', extra: true}, {type: 'navigate', version: 2, targetId: 'bad'}, @@ -160,6 +161,7 @@ suite('GraphicalView secure panel contract', () => { assert.ok(controllerSource.includes('schemaVersion: 1')); assert.ok(controllerSource.includes('scrollLeft')); assert.ok(controllerSource.includes('scrollTop')); + assert.ok(controllerSource.includes('viewportInitialized')); assert.equal(/setState\([^)]*(?:svg|uri|target|command)/i.test(controllerSource), false); assert.ok(controllerSource.includes("event.key !== 'Enter' && event.key !== ' '")); assert.ok(controllerSource.includes("vscode.postMessage({type: 'navigate', version: currentVersion, targetId: group.id})")); diff --git a/src/test/graphicalViewViewport.test.ts b/src/test/graphicalViewViewport.test.ts new file mode 100644 index 0000000..ead9448 --- /dev/null +++ b/src/test/graphicalViewViewport.test.ts @@ -0,0 +1,531 @@ +/* + * Copyright (c) 2026 Robert Bosch Manufacturing Solutions GmbH + * SPDX-License-Identifier: MPL-2.0 + */ + +import * as assert from 'node:assert/strict'; +import {readFileSync} from 'node:fs'; +import {join} from 'node:path'; +import {runInNewContext} from 'node:vm'; + +const source = readFileSync(join(__dirname, '..', '..', 'src', 'webview', 'webview.js'), 'utf8'); + +suite('Graphical View viewport', () => { + test('first valid render fits, centers, persists initialization, and only then acknowledges', () => { + const harness = new WebviewHarness(); + harness.render(1, 2200, 1600); + + assert.equal(harness.hasPosted('rendered', 1), false); + assert.equal(harness.svg?.width, 976); + harness.flushFrame(); + + assert.equal(harness.hasPosted('rendered', 1), true); + assert.deepEqual(harness.savedState, { + schemaVersion: 1, + zoom: 976 / 2200, + scrollLeft: 0, + scrollTop: 0, + viewportInitialized: true, + }); + const bounds = harness.svgBounds(); + assert.equal(bounds.left, 112); + assert.equal(bounds.top, 50 + 12 + (776 - 1600 * (976 / 2200)) / 2); + }); + + test('rejected first SVG leaves Fit pending for the next valid render', () => { + const harness = new WebviewHarness(); + harness.message({type: 'render', version: 1, svg: ' { + const harness = new WebviewHarness({ + schemaVersion: 1, + zoom: 2, + scrollLeft: 240, + scrollTop: 160, + svg: '', + uri: 'file:///secret', + command: 'workbench.action.closeWindow', + pointer: {x: 1}, + }); + harness.render(1, 900, 700); + harness.flushFrame(); + + assert.deepEqual(harness.savedState, { + schemaVersion: 1, + zoom: 2, + scrollLeft: 240, + scrollTop: 160, + viewportInitialized: true, + }); + assert.equal(harness.svg?.width, 1800); + }); + + test('normalizes malformed persisted values without treating them as a valid legacy viewport', () => { + const harness = new WebviewHarness({ + schemaVersion: 1, + zoom: Number.POSITIVE_INFINITY, + scrollLeft: -20, + scrollTop: Number.NaN, + }); + assert.deepEqual(harness.savedState, { + schemaVersion: 1, + zoom: 1, + scrollLeft: 0, + scrollTop: 0, + viewportInitialized: false, + }); + harness.render(1, 100, 100); + harness.flushFrame(); + assert.equal(harness.savedState.zoom, 4); + assert.equal(harness.savedState.viewportInitialized, true); + }); + + test('subsequent renders preserve zoom and clamp only unreachable scroll positions', () => { + const harness = new WebviewHarness(initializedState(2, 1000, 800)); + harness.render(1, 1000, 800); + harness.flushFrame(); + assert.equal(harness.savedState.scrollLeft, 1000); + assert.equal(harness.savedState.scrollTop, 800); + + harness.render(2, 600, 400); + harness.flushFrame(); + assert.deepEqual(harness.savedState, initializedState(2, 224, 24)); + assert.equal(harness.hasPosted('rendered', 2), true); + }); + + test('Fit obeys both bounds, centers free axes, and leaves oversized edges reachable', () => { + const large = new WebviewHarness(initializedState(1, 0, 0)); + large.render(1, 10_000, 1000); + large.flushFrame(); + large.click('#zoom-fit'); + large.flushFrame(); + assert.equal(large.savedState.zoom, 0.25); + assert.equal(large.svgBounds().left, 112); + large.viewport.scrollLeft = large.viewport.maximumLeft; + assert.equal(large.svgBounds().right, 100 + 1000 - 12); + + const small = new WebviewHarness(initializedState(1, 0, 0)); + small.render(1, 100, 100); + small.flushFrame(); + small.click('#zoom-fit'); + small.flushFrame(); + assert.equal(small.savedState.zoom, 4); + const bounds = small.svgBounds(); + assert.equal(bounds.left, 100 + 12 + (976 - 400) / 2); + assert.equal(bounds.top, 50 + 12 + (776 - 400) / 2); + }); + + test('modifier wheel is coalesced and pointer anchored while ordinary input remains native', () => { + const harness = new WebviewHarness(initializedState(1, 500, 300)); + let prevented = 0; + harness.render(1, 2000, 1400); + harness.flushFrame(); + const anchor = {clientX: 420, clientY: 310}; + const before = harness.contentPoint(anchor); + + harness.wheel({...anchor, deltaY: 40, deltaMode: 0, ctrlKey: false, metaKey: false, preventDefault: () => prevented++}); + assert.equal(prevented, 0); + assert.equal(harness.frameCount, 0); + + for (let index = 0; index < 3; index++) { + harness.wheel({...anchor, deltaY: -20, deltaMode: 0, ctrlKey: true, metaKey: false, preventDefault: () => prevented++}); + } + assert.equal(prevented, 3); + assert.equal(harness.frameCount, 1); + harness.flushFrame(); + harness.flushFrame(); + + assert.ok(Number(harness.savedState.zoom) > 1); + const after = harness.contentPoint(anchor); + assert.ok(Math.abs(before.x - after.x) < 0.001); + assert.ok(Math.abs(before.y - after.y) < 0.001); + assert.equal(harness.wheelOptions?.passive, false); + }); + + test('unavailable and bound-blocked modifier wheel avoid layout and state changes', () => { + const empty = new WebviewHarness(); + let emptyPrevented = 0; + empty.wheel(wheelEvent(-100, () => emptyPrevented++)); + assert.equal(emptyPrevented, 0); + assert.equal(empty.frameCount, 0); + + const bounded = new WebviewHarness(initializedState(4, 10, 10)); + bounded.render(1, 1000, 1000); + bounded.flushFrame(); + const callsBefore = bounded.setStateCalls; + let prevented = 0; + bounded.wheel(wheelEvent(-100, () => prevented++)); + assert.equal(prevented, 1); + assert.equal(bounded.frameCount, 0); + assert.equal(bounded.setStateCalls, callsBefore); + assert.deepEqual(bounded.savedState, initializedState(4, 10, 10)); + }); + + test('line and page wheel deltas are bounded and rapid events schedule one frame', () => { + const line = new WebviewHarness(initializedState(1, 300, 200)); + line.render(1, 2000, 1200); + line.flushFrame(); + line.wheel(wheelEvent(-10, () => undefined, 1)); + line.wheel(wheelEvent(-1, () => undefined, 2)); + assert.equal(line.frameCount, 1); + line.flushFrame(); + line.flushFrame(); + assert.ok(Math.abs(Number(line.savedState.zoom) - Math.exp(0.2)) < 0.000001); + }); + + test('toolbar zoom and Reset preserve the viewport-center content point; Fit is distinct', () => { + const harness = new WebviewHarness(initializedState(1, 500, 300)); + harness.render(1, 2000, 1400); + harness.flushFrame(); + const center = {clientX: 600, clientY: 450}; + const before = harness.contentPoint(center); + harness.click('#zoom-in'); + harness.flushFrame(); + assert.equal(harness.savedState.zoom, 1.2); + assertContentPointClose(before, harness.contentPoint(center)); + + harness.click('#zoom-reset'); + harness.flushFrame(); + assert.equal(harness.savedState.zoom, 1); + assertContentPointClose(before, harness.contentPoint(center)); + + harness.click('#zoom-fit'); + harness.flushFrame(); + assert.equal(harness.savedState.zoom, 976 / 2000); + assert.equal(harness.savedState.scrollLeft, 0); + assert.equal(harness.savedState.scrollTop, 0); + }); + + test('a stale queued layout callback cannot reposition or acknowledge a newer render', () => { + const harness = new WebviewHarness(); + harness.render(1, 2200, 1600); + harness.render(2, 1000, 500); + harness.flushFrame(); + + assert.equal(harness.hasPosted('rendered', 1), false); + assert.equal(harness.hasPosted('rendered', 2), true); + assert.equal(harness.savedState.zoom, 0.976); + assert.equal(harness.savedState.viewportInitialized, true); + }); + + test('a queued wheel frame cannot zoom a newer render', () => { + const harness = new WebviewHarness(initializedState(1, 100, 100)); + harness.render(1, 2000, 1200); + harness.flushFrame(); + harness.wheel(wheelEvent(-100, () => undefined)); + assert.equal(harness.frameCount, 1); + + harness.render(2, 1000, 600); + harness.flushFrame(); + + assert.equal(harness.savedState.zoom, 1); + assert.equal(harness.hasPosted('rendered', 2), true); + }); + + test('toolbar layout supersedes a pending restore without losing initialization or acknowledgement', () => { + const harness = new WebviewHarness(); + harness.render(1, 2200, 1600); + harness.click('#zoom-in'); + harness.flushFrame(); + + assert.equal(harness.savedState.viewportInitialized, true); + assert.equal(harness.savedState.zoom, (976 / 2200) * 1.2); + assert.equal(harness.hasPosted('rendered', 1), true); + }); + + test('a render arriving during anchor correction preserves the corrected viewport', () => { + const harness = new WebviewHarness(initializedState(1, 500, 300)); + harness.render(1, 2000, 1400); + harness.flushFrame(); + harness.click('#zoom-in'); + + harness.render(2, 2000, 1400); + harness.flushFrame(); + + assert.equal(harness.savedState.zoom, 1.2); + assert.ok(Math.abs(Number(harness.savedState.scrollLeft) - 697.6) < 0.001); + assert.ok(Math.abs(Number(harness.savedState.scrollTop) - 437.6) < 0.001); + assert.equal(harness.hasPosted('rendered', 2), true); + }); +}); + +function initializedState(zoom: number, scrollLeft: number, scrollTop: number): Record { + return {schemaVersion: 1, zoom, scrollLeft, scrollTop, viewportInitialized: true}; +} + +function wheelEvent(deltaY: number, preventDefault: () => void, deltaMode = 0): Record { + return {clientX: 420, clientY: 310, deltaY, deltaMode, ctrlKey: true, metaKey: false, preventDefault}; +} + +function assertContentPointClose(expected: Point, actual: Point): void { + assert.ok(Math.abs(expected.x - actual.x) < 0.001); + assert.ok(Math.abs(expected.y - actual.y) < 0.001); +} + +interface Point { + x: number; + y: number; +} + +class WebviewHarness { + readonly viewport = new FakeViewport(); + readonly diagram = new FakeElement(); + readonly window = new FakeElement(); + readonly posted: unknown[] = []; + readonly frames: Array<() => void> = []; + savedState: Record = {}; + setStateCalls = 0; + svg?: FakeSvg; + wheelOptions?: {passive?: boolean}; + private readonly elements: Map; + + constructor(savedState?: unknown) { + this.viewport.onWheelOptions = options => { + this.wheelOptions = options; + }; + this.diagram.replaceChildren = child => { + this.svg = child as FakeSvg; + this.svg.viewport = this.viewport; + }; + this.viewport.currentSvg = () => this.svg; + this.elements = new Map([ + ['#diagram', this.diagram], + ['#viewport', this.viewport], + ['#status', new FakeElement()], + ['#status-text', new FakeElement()], + ['#zoom-value', new FakeElement()], + ['#refresh', new FakeElement()], + ['#zoom-in', new FakeElement()], + ['#zoom-out', new FakeElement()], + ['#zoom-reset', new FakeElement()], + ['#zoom-fit', new FakeElement()], + ]); + runInNewContext(source, { + acquireVsCodeApi: () => ({ + getState: () => savedState, + setState: (value: Record) => { + this.savedState = JSON.parse(JSON.stringify(value)) as Record; + this.setStateCalls++; + }, + postMessage: (message: unknown) => this.posted.push(message), + }), + document: { + querySelector: (selector: string) => this.elements.get(selector), + importNode: (node: FakeSvg) => node, + }, + window: this.window, + Element: FakeElement, + DOMParser: FakeDomParser, + requestAnimationFrame: (callback: () => void) => { + this.frames.push(callback); + return this.frames.length; + }, + setTimeout: () => 0, + clearTimeout: () => undefined, + Number, + Object, + Math, + Error, + }); + } + + get frameCount(): number { + return this.frames.length; + } + + render(version: number, width: number, height: number): void { + this.message({ + type: 'render', + version, + svg: ``, + }); + } + + message(data: unknown): void { + this.window.fire('message', {data}); + } + + click(selector: string): void { + this.elements.get(selector)?.fire('click', {}); + } + + wheel(event: Record): void { + this.viewport.fire('wheel', event); + } + + flushFrame(): void { + const callbacks = this.frames.splice(0); + for (const callback of callbacks) { + callback(); + } + } + + hasPosted(type: string, version: number): boolean { + return this.posted.some(message => isRecord(message) && message.type === type && message.version === version); + } + + svgBounds(): Bounds { + assert.ok(this.svg); + return this.svg.getBoundingClientRect(); + } + + contentPoint(anchor: {clientX: number; clientY: number}): Point { + const bounds = this.svgBounds(); + return {x: (anchor.clientX - bounds.left) / (this.svg?.zoom ?? 1), y: (anchor.clientY - bounds.top) / (this.svg?.zoom ?? 1)}; + } +} + +class FakeElement { + readonly attributes = new Map(); + readonly listeners = new Map void>(); + textContent = ''; + replaceChildren: (child: unknown) => void = () => undefined; + + addEventListener(type: string, listener: (event: never) => void, options?: {passive?: boolean}): void { + this.listeners.set(type, listener); + this.onWheelOptions?.(options); + } + + onWheelOptions?: (options?: {passive?: boolean}) => void; + + fire(type: string, event: unknown): void { + this.listeners.get(type)?.(event as never); + } + + setAttribute(name: string, value: string): void { + this.attributes.set(name, value); + } +} + +class FakeViewport extends FakeElement { + readonly clientWidth = 1000; + readonly clientHeight = 800; + currentSvg: () => FakeSvg | undefined = () => undefined; + private left = 0; + private top = 0; + + get maximumLeft(): number { + return Math.max(0, this.scrollWidth - this.clientWidth); + } + + get maximumTop(): number { + return Math.max(0, this.scrollHeight - this.clientHeight); + } + + get scrollWidth(): number { + return Math.max(this.clientWidth, (this.currentSvg()?.width ?? 0) + 24); + } + + get scrollHeight(): number { + return Math.max(this.clientHeight, (this.currentSvg()?.height ?? 0) + 24); + } + + get scrollLeft(): number { + return this.left; + } + + set scrollLeft(value: number) { + this.left = Math.min(this.maximumLeft, Math.max(0, value)); + } + + get scrollTop(): number { + return this.top; + } + + set scrollTop(value: number) { + this.top = Math.min(this.maximumTop, Math.max(0, value)); + } + + getBoundingClientRect(): Bounds { + return {left: 100, top: 50, width: this.clientWidth, height: this.clientHeight, right: 1100, bottom: 850}; + } +} + +class FakeSvg extends FakeElement { + viewport?: FakeViewport; + width: number; + height: number; + readonly baseWidth: number; + readonly baseHeight: number; + readonly localName = 'svg'; + readonly namespaceURI = 'http://www.w3.org/2000/svg'; + + constructor(width: number, height: number) { + super(); + this.width = this.baseWidth = width; + this.height = this.baseHeight = height; + } + + get zoom(): number { + return this.width / this.baseWidth; + } + + getAttribute(name: string): string | null { + return name === 'width' ? String(this.width) : name === 'height' ? String(this.height) : null; + } + + override setAttribute(name: string, value: string): void { + super.setAttribute(name, value); + if (name === 'width') { + this.width = Number(value); + } else if (name === 'height') { + this.height = Number(value); + } + } + + querySelectorAll(): FakeElement[] { + return []; + } + + getBoundingClientRect(): Bounds { + assert.ok(this.viewport); + const viewportBounds = this.viewport.getBoundingClientRect(); + const availableWidth = this.viewport.clientWidth - 24; + const availableHeight = this.viewport.clientHeight - 24; + const left = viewportBounds.left + 12 + Math.max(0, (availableWidth - this.width) / 2) - this.viewport.scrollLeft; + const top = viewportBounds.top + 12 + Math.max(0, (availableHeight - this.height) / 2) - this.viewport.scrollTop; + return {left, top, width: this.width, height: this.height, right: left + this.width, bottom: top + this.height}; + } +} + +class FakeDomParser { + parseFromString(svgText: string, mediaType: string): FakeXmlDocument { + assert.equal(mediaType, 'image/svg+xml'); + const width = Number(svgText.match(/width="([^"]+)"/)?.[1] ?? 0); + const height = Number(svgText.match(/height="([^"]+)"/)?.[1] ?? 0); + return new FakeXmlDocument(new FakeSvg(width, height), svgText.includes(' { + return typeof value === 'object' && value !== null; +} diff --git a/src/test/graphicalViewWebviewController.test.ts b/src/test/graphicalViewWebviewController.test.ts index 074027e..74b1c3a 100644 --- a/src/test/graphicalViewWebviewController.test.ts +++ b/src/test/graphicalViewWebviewController.test.ts @@ -83,7 +83,13 @@ suite('Graphical View webview controller', () => { {type: 'navigate', version: 4, targetId: markerId}, ]); assert.equal(prevented, 2); - assert.deepEqual(JSON.parse(JSON.stringify(state.value)), {schemaVersion: 1, zoom: 1, scrollLeft: 0, scrollTop: 0}); + assert.deepEqual(JSON.parse(JSON.stringify(state.value)), { + schemaVersion: 1, + zoom: 0.44363636363636366, + scrollLeft: 0, + scrollTop: 0, + viewportInitialized: true, + }); }); test('applies only allow-listed status kinds and preserves complete status text safely', () => { diff --git a/src/test/graphicalViewWebviewLifecycle.test.ts b/src/test/graphicalViewWebviewLifecycle.test.ts index 002901d..94d8897 100644 --- a/src/test/graphicalViewWebviewLifecycle.test.ts +++ b/src/test/graphicalViewWebviewLifecycle.test.ts @@ -26,7 +26,9 @@ suite('GraphicalView real webview lifecycle', function () { ); const messages: unknown[] = []; const subscription = panel.webview.onDidReceiveMessage(message => messages.push(message)); - const shell = createGraphicalViewShell(panel.webview, extensionUri); + const shell = instrumentShell(createGraphicalViewShell(panel.webview, extensionUri)); + const themeConfiguration = vscode.workspace.getConfiguration('workbench'); + const originalTheme = themeConfiguration.get('colorTheme'); try { panel.webview.html = shell; @@ -35,6 +37,102 @@ suite('GraphicalView real webview lifecycle', function () { await waitFor(() => hasMessage(messages, 'rendered', 1), 'initial secure render'); assert.equal(hasMessage(messages, 'renderError', 1), false); + const initial = await probe(panel, messages, 'snapshot'); + assert.equal(initial.state.viewportInitialized, true); + const expectedFit = Math.max( + 0.25, + Math.min(4, Math.min((initial.viewport.clientWidth - 24) / 2200, (initial.viewport.clientHeight - 24) / 1600)), + ); + assert.ok(Math.abs(initial.state.zoom - expectedFit) < 0.000_001); + assertCenteredFreeAxes(initial); + + await panel.webview.postMessage({type: 'render', version: 101, svg: sizedSvg(10_000, 10_000)}); + await waitFor(() => hasMessage(messages, 'rendered', 101), 'oversized render'); + await probe(panel, messages, 'fit'); + const oversizedStart = await probe(panel, messages, 'snapshot'); + assert.equal(oversizedStart.state.zoom, 0.25); + assert.ok(oversizedStart.viewport.maximumLeft > 0); + assert.ok(oversizedStart.viewport.maximumTop > 0); + assert.ok(oversizedStart.svg.left >= oversizedStart.viewport.left); + assert.ok(oversizedStart.svg.top >= oversizedStart.viewport.top); + const oversizedEnd = await probe(panel, messages, 'scrollEnd'); + assert.ok(oversizedEnd.svg.right <= oversizedEnd.viewport.right + 1); + assert.ok(oversizedEnd.svg.bottom <= oversizedEnd.viewport.bottom + 1); + + await panel.webview.postMessage({type: 'render', version: 102, svg: sizedSvg(2200, 1600)}); + await waitFor(() => hasMessage(messages, 'rendered', 102), 'anchor fixture render'); + await probe(panel, messages, 'fit'); + const ordinary = await probe(panel, messages, 'ordinaryWheel'); + assert.equal(ordinary.prevented, false); + assert.equal(ordinary.beforeZoom, ordinary.afterZoom); + + const wheel = await probe(panel, messages, 'modifierWheel'); + assert.equal(wheel.prevented, true); + assert.ok(wheel.afterZoom > wheel.beforeZoom); + assert.ok( + Math.abs(wheel.beforePoint.x - wheel.afterPoint.x) * wheel.afterZoom < 0.5, + `wheel x anchor drift: ${JSON.stringify(wheel)}`, + ); + assert.ok( + Math.abs(wheel.beforePoint.y - wheel.afterPoint.y) * wheel.afterZoom < 0.5, + `wheel y anchor drift: ${JSON.stringify(wheel)}`, + ); + + const metaPinch = await probe(panel, messages, 'metaPinch'); + assert.equal(metaPinch.prevented, true); + assert.ok(metaPinch.afterZoom < metaPinch.beforeZoom); + const rapidWheel = await probe(panel, messages, 'rapidWheel'); + assert.equal(rapidWheel.prevented, true); + assert.ok(rapidWheel.afterZoom > rapidWheel.beforeZoom); + assert.ok(rapidWheel.afterZoom / rapidWheel.beforeZoom <= Math.exp(0.2) + 0.000_001); + + const button = await probe(panel, messages, 'zoomIn'); + assert.ok(button.afterZoom > button.beforeZoom); + assert.ok(Math.abs(button.beforePoint.x - button.afterPoint.x) * button.afterZoom < 0.5); + assert.ok(Math.abs(button.beforePoint.y - button.afterPoint.y) * button.afterZoom < 0.5); + const reset = await probe(panel, messages, 'reset'); + assert.equal(reset.state.zoom, 1); + + const resizedFit = await probe(panel, messages, 'resizeAndFit'); + const resizedExpected = Math.max( + 0.25, + Math.min(4, Math.min((resizedFit.viewport.clientWidth - 24) / 2200, (resizedFit.viewport.clientHeight - 24) / 1600)), + ); + assert.ok(Math.abs(resizedFit.state.zoom - resizedExpected) < 0.000_001); + assertCenteredFreeAxes(resizedFit); + assert.equal(resizedFit.styles.overflow, 'auto'); + assert.notEqual(resizedFit.styles.buttonDisplay, 'none'); + assert.notEqual(resizedFit.styles.diagramDisplay, 'none'); + + await panel.webview.postMessage({type: 'render', version: 103, svg: sizedSvg(200, 1600)}); + await waitFor(() => hasMessage(messages, 'rendered', 103), 'horizontal free-space fixture'); + await probe(panel, messages, 'fit'); + const horizontallyCentered = await probe(panel, messages, 'snapshot'); + assert.ok(horizontallyCentered.viewport.clientWidth - 24 - horizontallyCentered.svg.width > 0.5); + assertCenteredFreeAxes(horizontallyCentered); + + await panel.webview.postMessage({type: 'render', version: 104, svg: sizedSvg(2200, 200)}); + await waitFor(() => hasMessage(messages, 'rendered', 104), 'vertical free-space fixture'); + await probe(panel, messages, 'fit'); + const verticallyCentered = await probe(panel, messages, 'snapshot'); + assert.ok(verticallyCentered.viewport.clientHeight - 24 - verticallyCentered.svg.height > 0.5); + assertCenteredFreeAxes(verticallyCentered); + + for (const theme of ['Default Light Modern', 'Default Dark Modern', 'Default High Contrast']) { + await themeConfiguration.update('colorTheme', theme, vscode.ConfigurationTarget.Global); + const themed = await probe(panel, messages, 'snapshot'); + assert.notEqual(themed.styles.bodyColor, ''); + assert.notEqual(themed.styles.bodyBackground, 'rgba(0, 0, 0, 0)'); + assert.notEqual(themed.styles.buttonDisplay, 'none'); + assert.notEqual(themed.styles.diagramDisplay, 'none'); + } + + await probe(panel, messages, 'clearResize'); + await panel.webview.postMessage({type: 'render', version: 105, svg: graphperSvg(HEADER_MARKER)}); + await waitFor(() => hasMessage(messages, 'rendered', 105), 'restoration fixture'); + await probe(panel, messages, 'reset'); + const restorationState = (await probe(panel, messages, 'zoomIn')).state; + const hiddenDocument = await vscode.workspace.openTextDocument({content: 'Hide graphical view', language: 'plaintext'}); await vscode.window.showTextDocument(hiddenDocument, {viewColumn: vscode.ViewColumn.One, preview: false}); await waitFor(() => !panel.visible, 'webview becoming hidden'); @@ -45,15 +143,34 @@ suite('GraphicalView real webview lifecycle', function () { assert.equal(countMessages(messages, 'rendered'), rendersBeforeReveal, 'reveal must not render by itself'); assert.equal(panel.webview.html, shell, 'stable shell must not be reassigned per result'); - await panel.webview.postMessage({type: 'render', version: 1, svg: graphperSvg(HEADER_MARKER)}); - await waitFor(() => countMessages(messages, 'rendered') >= 2, 'retained result rehydration'); + // The host cannot inspect webview state directly. Reload a fresh test-instrumented shell only after + // proving that production reveal did not assign HTML or render; VS Code must retain getState() data. + const readyBeforeInstrumentedReload = countMessages(messages, 'ready'); + panel.webview.html = instrumentShell(createGraphicalViewShell(panel.webview, extensionUri)); + await waitFor(() => countMessages(messages, 'ready') > readyBeforeInstrumentedReload, 'instrumented state reload'); + const rendersBeforeRedelivery = countMessages(messages, 'rendered'); + const snapshotsBeforeRedelivery = countMessages(messages, 'acceptanceAutoSnapshot'); + await panel.webview.postMessage({type: 'render', version: 105, svg: graphperSvg(HEADER_MARKER)}); + await waitFor(() => countMessages(messages, 'rendered') > rendersBeforeRedelivery, 'retained result rehydration'); + await waitFor( + () => countMessages(messages, 'acceptanceAutoSnapshot') > snapshotsBeforeRedelivery, + 'rehydrated viewport snapshot', + ); + const rehydrated = latestMessage(messages, 'acceptanceAutoSnapshot') as ViewportProbeResult; + assert.equal(rehydrated.state.zoom, restorationState.zoom); + assert.equal(rehydrated.state.scrollLeft, restorationState.scrollLeft); + assert.equal(rehydrated.state.scrollTop, restorationState.scrollTop); + assert.equal(rehydrated.state.viewportInitialized, true); await panel.webview.postMessage({ type: 'render', version: 2, svg: multilingualSvg(GERMAN_MARKER, ENGLISH_MARKER), }); await waitFor(() => hasMessage(messages, 'rendered', 2), 'multilingual replacement render'); - assert.equal(messages.some(message => isRecord(message) && message.type === 'renderError'), false); + assert.equal( + messages.some(message => isRecord(message) && message.type === 'renderError'), + false, + ); await panel.webview.postMessage({ type: 'render', @@ -82,6 +199,7 @@ suite('GraphicalView real webview lifecycle', function () { } finally { subscription.dispose(); panel.dispose(); + await themeConfiguration.update('colorTheme', originalTheme, vscode.ConfigurationTarget.Global); } }); }); @@ -107,6 +225,146 @@ function multilingualSvg(germanMarker: string, englishMarker: string): string { `; } +function sizedSvg(width: number, height: number): string { + return ``; +} + +function instrumentShell(shell: string): string { + const scriptMatch = shell.match(/(