Support HTML tests in test-helper extension (#6504)

This commit is contained in:
Laurenz 2025-06-30 10:27:02 +02:00 committed by GitHub
parent e8f9877fc5
commit c4bcfb18c1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 239 additions and 90 deletions

View File

@ -94,9 +94,12 @@
"watch": "tsc -watch -p ./" "watch": "tsc -watch -p ./"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "18.x", "@types/node": "^24.0.4",
"@types/vscode": "^1.88.0", "@types/vscode": "^1.101.0",
"typescript": "^5.3.3" "typescript": "^5.8.3"
},
"dependencies": {
"shiki": "^3.7.0"
}, },
"engines": { "engines": {
"vscode": "^1.88.0" "vscode": "^1.88.0"

View File

@ -1,6 +1,7 @@
import * as vscode from "vscode"; import * as vscode from "vscode";
import * as cp from "child_process"; import * as cp from "child_process";
import { clearInterval } from "timers"; 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 // Called when an activation event is triggered. Our activation event is the
// presence of "tests/suite/playground.typ". // presence of "tests/suite/playground.typ".
@ -17,6 +18,8 @@ class TestHelper {
opened?: { opened?: {
// The tests's name. // The tests's name.
name: string; name: string;
// The test's attributes.
attrs: string[];
// The WebView panel that displays the test images and output. // The WebView panel that displays the test images and output.
panel: vscode.WebviewPanel; panel: vscode.WebviewPanel;
}; };
@ -44,18 +47,18 @@ class TestHelper {
); );
// Triggered when clicking "View" in the lens. // Triggered when clicking "View" in the lens.
this.registerCommand("typst-test-helper.viewFromLens", (name) => this.registerCommand("typst-test-helper.viewFromLens", (name, attrs) =>
this.viewFromLens(name) this.viewFromLens(name, attrs)
); );
// Triggered when clicking "Run" in the lens. // Triggered when clicking "Run" in the lens.
this.registerCommand("typst-test-helper.runFromLens", (name) => this.registerCommand("typst-test-helper.runFromLens", (name, attrs) =>
this.runFromLens(name) this.runFromLens(name, attrs)
); );
// Triggered when clicking "Save" in the lens. // Triggered when clicking "Save" in the lens.
this.registerCommand("typst-test-helper.saveFromLens", (name) => this.registerCommand("typst-test-helper.saveFromLens", (name, attrs) =>
this.saveFromLens(name) this.saveFromLens(name, attrs)
); );
// Triggered when clicking "Terminal" in the lens. // Triggered when clicking "Terminal" in the lens.
@ -121,31 +124,32 @@ class TestHelper {
const lenses = []; const lenses = [];
for (let nr = 0; nr < document.lineCount; nr++) { for (let nr = 0; nr < document.lineCount; nr++) {
const line = document.lineAt(nr); const line = document.lineAt(nr);
const re = /^--- ([\d\w-]+)( [\d\w-]+)* ---$/; const re = /^--- ([\d\w-]+)(( [\d\w-]+)*) ---$/;
const m = line.text.match(re); const m = line.text.match(re);
if (!m) { if (!m) {
continue; continue;
} }
const name = m[1]; const name = m[1];
const attrs = m[2].trim().split(" ");
lenses.push( lenses.push(
new vscode.CodeLens(line.range, { new vscode.CodeLens(line.range, {
title: "View", title: "View",
tooltip: "View the test output and reference in a new tab", tooltip: "View the test output and reference in a new tab",
command: "typst-test-helper.viewFromLens", command: "typst-test-helper.viewFromLens",
arguments: [name], arguments: [name, attrs],
}), }),
new vscode.CodeLens(line.range, { new vscode.CodeLens(line.range, {
title: "Run", title: "Run",
tooltip: "Run the test and view the results in a new tab", tooltip: "Run the test and view the results in a new tab",
command: "typst-test-helper.runFromLens", command: "typst-test-helper.runFromLens",
arguments: [name], arguments: [name, attrs],
}), }),
new vscode.CodeLens(line.range, { new vscode.CodeLens(line.range, {
title: "Save", title: "Save",
tooltip: "Run and view the test and save the reference output", tooltip: "Run and view the test and save the reference output",
command: "typst-test-helper.saveFromLens", command: "typst-test-helper.saveFromLens",
arguments: [name], arguments: [name, attrs],
}), }),
new vscode.CodeLens(line.range, { new vscode.CodeLens(line.range, {
title: "Terminal", title: "Terminal",
@ -159,40 +163,49 @@ class TestHelper {
} }
// Triggered when clicking "View" in the lens. // Triggered when clicking "View" in the lens.
private viewFromLens(name: string) { private viewFromLens(name: string, attrs: string[]) {
if (this.opened?.name == name) { if (
this.opened?.name == name &&
this.opened.attrs.join(" ") == attrs.join(" ")
) {
this.opened.panel.reveal(); this.opened.panel.reveal();
return; return;
} }
if (this.opened) { if (this.opened) {
this.opened.name = name; this.opened.name = name;
this.opened.attrs = attrs;
this.opened.panel.title = name; this.opened.panel.title = name;
} else { } else {
const panel = vscode.window.createWebviewPanel( const panel = vscode.window.createWebviewPanel(
"typst-test-helper.preview", "typst-test-helper.preview",
name, name,
vscode.ViewColumn.Beside, vscode.ViewColumn.Beside,
{ enableFindWidget: true } { enableFindWidget: true, enableScripts: true }
); );
panel.onDidDispose(() => (this.opened = undefined)); 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(); this.refreshWebView();
} }
// Triggered when clicking "Run" in the lens. // Triggered when clicking "Run" in the lens.
private runFromLens(name: string) { private runFromLens(name: string, attrs: string[]) {
this.viewFromLens(name); this.viewFromLens(name, attrs);
this.runFromPreview(); this.runFromPreview();
} }
// Triggered when clicking "Run" in the lens. // Triggered when clicking "Run" in the lens.
private saveFromLens(name: string) { private saveFromLens(name: string, attrs: string[]) {
this.viewFromLens(name); this.viewFromLens(name, attrs);
this.saveFromPreview(); this.saveFromPreview();
} }
@ -288,41 +301,37 @@ class TestHelper {
private copyImageFilePathFromPreviewContext(webviewSection: string) { private copyImageFilePathFromPreviewContext(webviewSection: string) {
if (!this.opened) return; if (!this.opened) return;
const { name } = this.opened; const { name } = this.opened;
const { png, ref } = getImageUris(name); const [bucket, format] = webviewSection.split("/");
switch (webviewSection) { vscode.env.clipboard.writeText(
case "png": getUri(name, bucket as Bucket, format as Format).fsPath
vscode.env.clipboard.writeText(png.fsPath); );
break;
case "ref":
vscode.env.clipboard.writeText(ref.fsPath);
break;
default:
break;
}
} }
// Reloads the web view. // Reloads the web view.
private refreshWebView(output?: { stdout: string; stderr: string }) { private refreshWebView(output?: { stdout: string; stderr: string }) {
if (!this.opened) return; if (!this.opened) return;
const { name, panel } = this.opened; const { name, attrs, panel } = this.opened;
const { png, ref } = getImageUris(name);
if (panel) { if (panel) {
console.log( console.log(
`Refreshing WebView for ${name}` + (panel.visible ? " in background" : "")); `Refreshing WebView for ${name}` +
const webViewSrcs = { (panel.visible ? " in background" : "")
png: panel.webview.asWebviewUri(png), );
ref: panel.webview.asWebviewUri(ref),
};
panel.webview.html = ""; panel.webview.html = "";
// Make refresh notable. // Make refresh notable.
setTimeout(() => { setTimeout(async () => {
if (!panel) { if (!panel) {
throw new Error("panel to refresh is falsy after waiting"); 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); }, 50);
} }
} }
@ -386,30 +395,43 @@ function getWorkspaceRoot() {
return vscode.workspace.workspaceFolders![0].uri; return vscode.workspace.workspaceFolders![0].uri;
} }
// Returns the URIs for a test's images. const EXTENSION = { html: "html", render: "png" };
function getImageUris(name: string) {
const root = getWorkspaceRoot(); type Bucket = "store" | "ref";
const png = vscode.Uri.joinPath(root, `tests/store/render/${name}.png`); type Format = "html" | "render";
const ref = vscode.Uri.joinPath(root, `tests/ref/${name}.png`);
return { png, ref }; 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. // Produces the content of the WebView.
function getWebviewContent( async function getWebviewContent(
webViewSrcs: { png: vscode.Uri; ref: vscode.Uri }, panel: vscode.WebviewPanel,
name: string,
attrs: string[],
output?: { output?: {
stdout: string; stdout: string;
stderr: string; stderr: string;
} }
): string { ): Promise<string> {
const escape = (text: string) => const showHtml = attrs.includes("html");
text.replace(/</g, "&lt;").replace(/>/g, "&gt;"); const showRender = !showHtml || attrs.includes("render");
const stdoutHtml = output?.stdout const stdout = output?.stdout
? `<h1>Standard output</h1><pre>${escape(output.stdout)}</pre>` ? `<h2>Standard output</h2><pre class="output">${escape(
output.stdout
)}</pre>`
: ""; : "";
const stderrHtml = output?.stderr const stderr = output?.stderr
? `<h1>Standard error</h1><pre>${escape(output.stderr)}</pre>` ? `<h2>Standard error</h2><pre class="output">${escape(
output.stderr
)}</pre>`
: ""; : "";
return ` return `
@ -449,46 +471,169 @@ function getWebviewContent(
color: #bebebe; color: #bebebe;
content: "Not present"; content: "Not present";
} }
pre { h2 {
display: inline-block; margin-bottom: 12px;
font-family: var(--vscode-editor-font-family); }
text-align: left; h2 a {
width: 80%; color: var(--vscode-editor-foreground);
text-decoration: underline;
}
h2 a:hover {
cursor: pointer;
} }
.flex { .flex {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
justify-content: center; 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);
}
</style> </style>
<script>
const api = acquireVsCodeApi()
function openFile(uri) {
api.postMessage({ command: 'openFile', uri });
}
function sizeIframe(obj){
obj.style.height = 0;
obj.style.height = obj.contentWindow.document.body.scrollHeight + 'px';
}
</script>
</head> </head>
<body> <body>
<div ${showRender ? renderSection(panel, name) : ""}
${showHtml ? await htmlSection(name) : ""}
${stdout}
${stderr}
</body>
</html>`;
}
function renderSection(panel: vscode.WebviewPanel, name: string) {
const outputUri = getUri(name, "store", "render");
const refUri = getUri(name, "ref", "render");
return `<div
class="flex" class="flex"
data-vscode-context='{"preventDefaultContextMenuItems": true}' data-vscode-context='{"preventDefaultContextMenuItems": true}'
> >
<div> <div>
<h1>Output</h1> ${linkedTitle("Output", outputUri)}
<img <img
class="output" class="output"
data-vscode-context='{"webviewSection":"png"}' data-vscode-context='{"bucket":"store", format: "render"}'
src="${webViewSrcs.png}" src="${panel.webview.asWebviewUri(outputUri)}"
alt="Placeholder" alt="Placeholder"
> >
</div> </div>
<div> <div>
<h1>Reference</h1> ${linkedTitle("Reference", refUri)}
<img <img
class="ref" class="ref"
data-vscode-context='{"webviewSection":"ref"}' data-vscode-context='{"bucket":"ref", format: "render"}'
src="${webViewSrcs.ref}" src="${panel.webview.asWebviewUri(refUri)}"
alt="Placeholder" alt="Placeholder"
> >
</div> </div>
</div> </div>`;
${stdoutHtml} }
${stderrHtml}
</body> async function htmlSection(name: string) {
</html>`; const storeHtml = await htmlSnippet(
"HTML Output",
getUri(name, "store", "html")
);
const refHtml = await htmlSnippet(
"HTML Reference",
getUri(name, "ref", "html")
);
return `<div
class="flex vertical"
data-vscode-context='{"preventDefaultContextMenuItems": true}'
>
${storeHtml}
${refHtml}
</div>`;
}
async function htmlSnippet(title: string, uri: vscode.Uri): Promise<string> {
try {
const data = await vscode.workspace.fs.readFile(uri);
const code = new TextDecoder("utf-8").decode(data);
return `<div>
${linkedTitle(title, uri)}
<div class="top-bottom">
${await highlight(code)}
<iframe srcdoc="${escape(code)}"></iframe>
</div>
</div>`;
} catch {
return `<div><h2>${title}</h2>Not present</div>`;
}
}
function linkedTitle(title: string, uri: vscode.Uri) {
return `<h2><a onclick="openFile('${uri.toString()}')">${title}</a></h2>`;
}
async function highlight(code: string): Promise<string> {
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
} }

View File

@ -1,9 +1,10 @@
{ {
"compilerOptions": { "compilerOptions": {
"module": "Node16", "module": "nodenext",
"lib": ["ES2022", "DOM"],
"target": "ES2022", "target": "ES2022",
"moduleResolution": "nodenext",
"outDir": "dist", "outDir": "dist",
"lib": ["ES2022"],
"sourceMap": true, "sourceMap": true,
"rootDir": "src", "rootDir": "src",
"strict": true "strict": true