From c4bcfb18c13c8b6fff26667def1c855162eeb02d Mon Sep 17 00:00:00 2001 From: Laurenz Date: Mon, 30 Jun 2025 10:27:02 +0200 Subject: [PATCH] Support HTML tests in test-helper extension (#6504) --- tools/test-helper/package.json | 11 +- tools/test-helper/src/extension.ts | 313 +++++++++++++++++++++-------- tools/test-helper/tsconfig.json | 5 +- 3 files changed, 239 insertions(+), 90 deletions(-) diff --git a/tools/test-helper/package.json b/tools/test-helper/package.json index 08a60fa31..42c77390d 100644 --- a/tools/test-helper/package.json +++ b/tools/test-helper/package.json @@ -94,9 +94,12 @@ "watch": "tsc -watch -p ./" }, "devDependencies": { - "@types/node": "18.x", - "@types/vscode": "^1.88.0", - "typescript": "^5.3.3" + "@types/node": "^24.0.4", + "@types/vscode": "^1.101.0", + "typescript": "^5.8.3" + }, + "dependencies": { + "shiki": "^3.7.0" }, "engines": { "vscode": "^1.88.0" @@ -104,4 +107,4 @@ "__metadata": { "size": 35098973 } -} \ No newline at end of file +} diff --git a/tools/test-helper/src/extension.ts b/tools/test-helper/src/extension.ts index b98b4bad4..f86527b5a 100644 --- a/tools/test-helper/src/extension.ts +++ b/tools/test-helper/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode"; import * as cp from "child_process"; import { clearInterval } from "timers"; +const shiki = import("shiki"); // Normal import causes TypeScript problems. // Called when an activation event is triggered. Our activation event is the // presence of "tests/suite/playground.typ". @@ -17,6 +18,8 @@ class TestHelper { opened?: { // The tests's name. name: string; + // The test's attributes. + attrs: string[]; // The WebView panel that displays the test images and output. panel: vscode.WebviewPanel; }; @@ -44,18 +47,18 @@ class TestHelper { ); // Triggered when clicking "View" in the lens. - this.registerCommand("typst-test-helper.viewFromLens", (name) => - this.viewFromLens(name) + this.registerCommand("typst-test-helper.viewFromLens", (name, attrs) => + this.viewFromLens(name, attrs) ); // Triggered when clicking "Run" in the lens. - this.registerCommand("typst-test-helper.runFromLens", (name) => - this.runFromLens(name) + this.registerCommand("typst-test-helper.runFromLens", (name, attrs) => + this.runFromLens(name, attrs) ); // Triggered when clicking "Save" in the lens. - this.registerCommand("typst-test-helper.saveFromLens", (name) => - this.saveFromLens(name) + this.registerCommand("typst-test-helper.saveFromLens", (name, attrs) => + this.saveFromLens(name, attrs) ); // Triggered when clicking "Terminal" in the lens. @@ -121,31 +124,32 @@ class TestHelper { const lenses = []; for (let nr = 0; nr < document.lineCount; nr++) { const line = document.lineAt(nr); - const re = /^--- ([\d\w-]+)( [\d\w-]+)* ---$/; + const re = /^--- ([\d\w-]+)(( [\d\w-]+)*) ---$/; const m = line.text.match(re); if (!m) { continue; } const name = m[1]; + const attrs = m[2].trim().split(" "); lenses.push( new vscode.CodeLens(line.range, { title: "View", tooltip: "View the test output and reference in a new tab", command: "typst-test-helper.viewFromLens", - arguments: [name], + arguments: [name, attrs], }), new vscode.CodeLens(line.range, { title: "Run", tooltip: "Run the test and view the results in a new tab", command: "typst-test-helper.runFromLens", - arguments: [name], + arguments: [name, attrs], }), new vscode.CodeLens(line.range, { title: "Save", tooltip: "Run and view the test and save the reference output", command: "typst-test-helper.saveFromLens", - arguments: [name], + arguments: [name, attrs], }), new vscode.CodeLens(line.range, { title: "Terminal", @@ -159,40 +163,49 @@ class TestHelper { } // Triggered when clicking "View" in the lens. - private viewFromLens(name: string) { - if (this.opened?.name == name) { + private viewFromLens(name: string, attrs: string[]) { + if ( + this.opened?.name == name && + this.opened.attrs.join(" ") == attrs.join(" ") + ) { this.opened.panel.reveal(); return; } if (this.opened) { this.opened.name = name; + this.opened.attrs = attrs; this.opened.panel.title = name; } else { const panel = vscode.window.createWebviewPanel( "typst-test-helper.preview", name, vscode.ViewColumn.Beside, - { enableFindWidget: true } + { enableFindWidget: true, enableScripts: true } ); panel.onDidDispose(() => (this.opened = undefined)); + panel.webview.onDidReceiveMessage((message) => { + if (message.command === "openFile") { + vscode.env.openExternal(vscode.Uri.parse(message.uri)); + } + }); - this.opened = { name, panel }; + this.opened = { name, attrs, panel }; } this.refreshWebView(); } // Triggered when clicking "Run" in the lens. - private runFromLens(name: string) { - this.viewFromLens(name); + private runFromLens(name: string, attrs: string[]) { + this.viewFromLens(name, attrs); this.runFromPreview(); } // Triggered when clicking "Run" in the lens. - private saveFromLens(name: string) { - this.viewFromLens(name); + private saveFromLens(name: string, attrs: string[]) { + this.viewFromLens(name, attrs); this.saveFromPreview(); } @@ -288,41 +301,37 @@ class TestHelper { private copyImageFilePathFromPreviewContext(webviewSection: string) { if (!this.opened) return; const { name } = this.opened; - const { png, ref } = getImageUris(name); - switch (webviewSection) { - case "png": - vscode.env.clipboard.writeText(png.fsPath); - break; - case "ref": - vscode.env.clipboard.writeText(ref.fsPath); - break; - default: - break; - } + const [bucket, format] = webviewSection.split("/"); + vscode.env.clipboard.writeText( + getUri(name, bucket as Bucket, format as Format).fsPath + ); } // Reloads the web view. private refreshWebView(output?: { stdout: string; stderr: string }) { if (!this.opened) return; - const { name, panel } = this.opened; - const { png, ref } = getImageUris(name); + const { name, attrs, panel } = this.opened; if (panel) { console.log( - `Refreshing WebView for ${name}` + (panel.visible ? " in background" : "")); - const webViewSrcs = { - png: panel.webview.asWebviewUri(png), - ref: panel.webview.asWebviewUri(ref), - }; + `Refreshing WebView for ${name}` + + (panel.visible ? " in background" : "") + ); + panel.webview.html = ""; // Make refresh notable. - setTimeout(() => { + setTimeout(async () => { if (!panel) { throw new Error("panel to refresh is falsy after waiting"); } - panel.webview.html = getWebviewContent(webViewSrcs, output); + panel.webview.html = await getWebviewContent( + panel, + name, + attrs, + output + ); }, 50); } } @@ -386,30 +395,43 @@ function getWorkspaceRoot() { return vscode.workspace.workspaceFolders![0].uri; } -// Returns the URIs for a test's images. -function getImageUris(name: string) { - const root = getWorkspaceRoot(); - const png = vscode.Uri.joinPath(root, `tests/store/render/${name}.png`); - const ref = vscode.Uri.joinPath(root, `tests/ref/${name}.png`); - return { png, ref }; +const EXTENSION = { html: "html", render: "png" }; + +type Bucket = "store" | "ref"; +type Format = "html" | "render"; + +function getUri(name: string, bucket: Bucket, format: Format) { + let path; + if (bucket === "ref" && format === "render") { + path = `tests/ref/${name}.png`; + } else { + path = `tests/${bucket}/${format}/${name}.${EXTENSION[format]}`; + } + return vscode.Uri.joinPath(getWorkspaceRoot(), path); } // Produces the content of the WebView. -function getWebviewContent( - webViewSrcs: { png: vscode.Uri; ref: vscode.Uri }, +async function getWebviewContent( + panel: vscode.WebviewPanel, + name: string, + attrs: string[], output?: { stdout: string; stderr: string; } -): string { - const escape = (text: string) => - text.replace(//g, ">"); +): Promise { + const showHtml = attrs.includes("html"); + const showRender = !showHtml || attrs.includes("render"); - const stdoutHtml = output?.stdout - ? `

Standard output

${escape(output.stdout)}
` + const stdout = output?.stdout + ? `

Standard output

${escape(
+        output.stdout
+      )}
` : ""; - const stderrHtml = output?.stderr - ? `

Standard error

${escape(output.stderr)}
` + const stderr = output?.stderr + ? `

Standard error

${escape(
+        output.stderr
+      )}
` : ""; return ` @@ -449,46 +471,169 @@ function getWebviewContent( color: #bebebe; content: "Not present"; } - pre { - display: inline-block; - font-family: var(--vscode-editor-font-family); - text-align: left; - width: 80%; + h2 { + margin-bottom: 12px; + } + h2 a { + color: var(--vscode-editor-foreground); + text-decoration: underline; + } + h2 a:hover { + cursor: pointer; } .flex { display: flex; flex-wrap: wrap; justify-content: center; } + .vertical { + flex-direction: column; + } + .top-bottom { + display: flex; + flex-direction: column; + padding-inline: 32px; + width: calc(100vw - 64px); + } + pre { + font-family: var(--vscode-editor-font-family); + text-align: left; + white-space: pre-wrap; + } + pre.output { + display: inline-block; + width: 80%; + margin-block-start: 0; + } + pre.shiki { + background-color: transparent !important; + padding: 12px; + margin-block-start: 0; + } + pre.shiki code { + --vscode-textPreformat-background: transparent; + } + iframe, pre.shiki { + border: 1px solid rgb(189, 191, 204); + border-radius: 6px; + } + iframe { + background: white; + } + .vscode-dark iframe { + filter: invert(1) hue-rotate(180deg); + } + -
-
-

Output

- Placeholder -
- -
-

Reference

- Placeholder -
-
- ${stdoutHtml} - ${stderrHtml} + ${showRender ? renderSection(panel, name) : ""} + ${showHtml ? await htmlSection(name) : ""} + ${stdout} + ${stderr} `; } + +function renderSection(panel: vscode.WebviewPanel, name: string) { + const outputUri = getUri(name, "store", "render"); + const refUri = getUri(name, "ref", "render"); + return `
+
+ ${linkedTitle("Output", outputUri)} + Placeholder +
+ +
+ ${linkedTitle("Reference", refUri)} + Placeholder +
+
`; +} + +async function htmlSection(name: string) { + const storeHtml = await htmlSnippet( + "HTML Output", + getUri(name, "store", "html") + ); + const refHtml = await htmlSnippet( + "HTML Reference", + getUri(name, "ref", "html") + ); + return `
+ ${storeHtml} + ${refHtml} +
`; +} + +async function htmlSnippet(title: string, uri: vscode.Uri): Promise { + try { + const data = await vscode.workspace.fs.readFile(uri); + const code = new TextDecoder("utf-8").decode(data); + return `
+ ${linkedTitle(title, uri)} +
+ ${await highlight(code)} + +
+
`; + } catch { + return `

${title}

Not present
`; + } +} + +function linkedTitle(title: string, uri: vscode.Uri) { + return `

${title}

`; +} + +async function highlight(code: string): Promise { + return (await shiki).codeToHtml(code, { + lang: "html", + theme: selectTheme(), + }); +} + +function selectTheme() { + switch (vscode.window.activeColorTheme.kind) { + case vscode.ColorThemeKind.Light: + case vscode.ColorThemeKind.HighContrastLight: + return "github-light"; + case vscode.ColorThemeKind.Dark: + case vscode.ColorThemeKind.HighContrast: + return "github-dark"; + } +} + +function escape(text: string) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} diff --git a/tools/test-helper/tsconfig.json b/tools/test-helper/tsconfig.json index 45e374553..952c40426 100644 --- a/tools/test-helper/tsconfig.json +++ b/tools/test-helper/tsconfig.json @@ -1,9 +1,10 @@ { "compilerOptions": { - "module": "Node16", + "module": "nodenext", + "lib": ["ES2022", "DOM"], "target": "ES2022", + "moduleResolution": "nodenext", "outDir": "dist", - "lib": ["ES2022"], "sourceMap": true, "rootDir": "src", "strict": true