From 020294fca9a7065d4b9cf4e677f606ebaaa29b00 Mon Sep 17 00:00:00 2001 From: Laurenz Date: Sat, 13 Apr 2024 10:39:45 +0200 Subject: Better test runner (#3922) --- tools/test-helper/src/extension.ts | 492 +++++++++++++++++++++++++++++++++++++ 1 file changed, 492 insertions(+) create mode 100644 tools/test-helper/src/extension.ts (limited to 'tools/test-helper/src') diff --git a/tools/test-helper/src/extension.ts b/tools/test-helper/src/extension.ts new file mode 100644 index 00000000..6c44f75e --- /dev/null +++ b/tools/test-helper/src/extension.ts @@ -0,0 +1,492 @@ +import * as vscode from "vscode"; +import * as cp from "child_process"; +import { clearInterval } from "timers"; + +// Called when an activation event is triggered. Our activation event is the +// presence of "tests/suite/playground.typ". +export function activate(context: vscode.ExtensionContext) { + new TestHelper(context); +} + +export function deactivate() {} + +class TestHelper { + // The currently active view for a test or `null` if none. + active?: { + // The tests's name. + name: string; + // The WebView panel that displays the test images and output. + panel: vscode.WebviewPanel; + } | null; + + // The current zoom scale. + scale = 1.0; + + // The extention's status bar item. + statusItem: vscode.StatusBarItem; + + // The active message of the status item. + statusMessage?: string; + + // Whether the status item is currently in spinning state. + statusSpinning = false; + + // Sets the extension up. + constructor(private readonly context: vscode.ExtensionContext) { + this.active = null; + + // Code lens that displays commands inline with the tests. + this.context.subscriptions.push( + vscode.languages.registerCodeLensProvider( + { pattern: "**/*.typ" }, + { provideCodeLenses: (document) => this.lens(document) } + ) + ); + + // Triggered when clicking "View" in the lens. + this.registerCommand("typst-test-helper.viewFromLens", (name) => + this.viewFromLens(name) + ); + + // Triggered when clicking "Run" in the lens. + this.registerCommand("typst-test-helper.runFromLens", (name) => + this.runFromLens(name) + ); + + // Triggered when clicking "Save" in the lens. + this.registerCommand("typst-test-helper.saveFromLens", (name) => + this.saveFromLens(name) + ); + + // Triggered when clicking "Terminal" in the lens. + this.registerCommand("typst-test-helper.runInTerminal", (name) => + this.runInTerminal(name) + ); + + // Triggered when clicking the "Refresh" button in the WebView toolbar. + this.registerCommand("typst-test-helper.refreshFromPreview", () => + this.refreshFromPreview() + ); + + // Triggered when clicking the "Run" button in the WebView toolbar. + this.registerCommand("typst-test-helper.runFromPreview", () => + this.runFromPreview() + ); + + // Triggered when clicking the "Save" button in the WebView toolbar. + this.registerCommand("typst-test-helper.saveFromPreview", () => + this.saveFromPreview() + ); + + // Triggered when clicking the "Increase Resolution" button in the WebView + // toolbar. + this.registerCommand("typst-test-helper.increaseResolution", () => + this.adjustResolution(2.0) + ); + + // Triggered when clicking the "Decrease Resolution" button in the WebView + // toolbar. + this.registerCommand("typst-test-helper.decreaseResolution", () => + this.adjustResolution(0.5) + ); + + // Triggered when performing a right-click on an image in the WebView. + this.registerCommand( + "typst-test-helper.copyImageFilePathFromPreviewContext", + (e) => this.copyImageFilePathFromPreviewContext(e.webviewSection) + ); + + // Set's up the status bar item that shows a spinner while running a test. + this.statusItem = this.createStatusItem(); + this.context.subscriptions.push(this.statusItem); + + // Triggered when clicking on the status item. + this.registerCommand("typst-test-helper.showTestProgress", () => + this.showTestProgress() + ); + + this.setRunButtonEnabled(true); + } + + // Register a command with VS Code. + private registerCommand(id: string, callback: (...args: any[]) => any) { + this.context.subscriptions.push( + vscode.commands.registerCommand(id, callback) + ); + } + + // The test lens that provides "View | Run | Save | Terminal" commands inline + // with the test sources. + private lens(document: vscode.TextDocument) { + const lenses = []; + for (let nr = 0; nr < document.lineCount; nr++) { + const line = document.lineAt(nr); + const re = /^--- ([\d\w-]+) ---$/; + const m = line.text.match(re); + if (!m) { + continue; + } + + const name = m[1]; + 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], + }), + 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], + }), + new vscode.CodeLens(line.range, { + title: "Save", + tooltip: "Run and view the test and save the reference image", + command: "typst-test-helper.saveFromLens", + arguments: [name], + }), + new vscode.CodeLens(line.range, { + title: "Terminal", + tooltip: "Run the test in the integrated terminal", + command: "typst-test-helper.runInTerminal", + arguments: [name], + }) + ); + } + return lenses; + } + + // Triggered when clicking "View" in the lens. + private viewFromLens(name: string) { + if (this.active) { + if (this.active.name == name) { + this.active.panel.reveal(); + return; + } + + this.active.name = name; + this.active.panel.title = name; + } else { + const panel = vscode.window.createWebviewPanel( + "typst-test-helper.preview", + name, + vscode.ViewColumn.Beside, + { enableFindWidget: true } + ); + + panel.onDidDispose(() => (this.active = null)); + + this.active = { name, panel }; + } + + this.refreshWebView(); + } + + // Triggered when clicking "Run" in the lens. + private runFromLens(name: string) { + this.viewFromLens(name); + this.runFromPreview(); + } + + // Triggered when clicking "Run" in the lens. + private saveFromLens(name: string) { + this.viewFromLens(name); + this.saveFromPreview(); + } + + // Triggered when clicking "Terminal" in the lens. + private runInTerminal(name: string) { + const TESTIT = "cargo test --workspace --test tests --"; + + if (vscode.window.terminals.length === 0) { + vscode.window.createTerminal(); + } + + const terminal = vscode.window.terminals[0]; + terminal.show(true); + terminal.sendText(`${TESTIT} --exact ${name}`, true); + } + + // Triggered when clicking the "Refresh" button in the WebView toolbar. + private refreshFromPreview() { + if (this.active) { + this.active.panel.reveal(); + this.refreshWebView(); + } + } + + // Triggered when clicking the "Run" button in the WebView toolbar. + private runFromPreview() { + if (this.active) { + this.runCargoTest(this.active.name); + } + } + + // Triggered when clicking the "Save" button in the WebView toolbar. + private saveFromPreview() { + if (this.active) { + this.runCargoTest(this.active.name, true); + } + } + + // Triggered when clicking the one of the resolution buttons in the WebView + // toolbar. + private adjustResolution(factor: number) { + this.scale = Math.round(Math.min(Math.max(this.scale * factor, 1.0), 8.0)); + this.runFromPreview(); + } + + // Runs a single test with cargo, optionally with `--update`. + private runCargoTest(name: string, update?: boolean) { + this.setRunButtonEnabled(false); + this.statusItem.show(); + + const dir = getWorkspaceRoot().path; + const proc = cp.spawn("cargo", [ + "test", + "--manifest-path", + `${dir}/Cargo.toml`, + "--workspace", + "--test", + "tests", + "--", + ...(this.scale != 1.0 ? ["--scale", `${this.scale}`] : []), + "--exact", + "--verbose", + name, + ...(update ? ["--update"] : []), + ]); + let outs = { stdout: "", stderr: "" }; + if (!proc.stdout || !proc.stderr) { + throw new Error("child process was not spawned successfully."); + } + proc.stdout.setEncoding("utf8"); + proc.stdout.on("data", (data) => { + outs.stdout += data.toString(); + }); + proc.stderr.setEncoding("utf8"); + proc.stderr.on("data", (data) => { + let s = data.toString(); + outs.stderr += s; + s = s.replace(/\(.+?\)/, ""); + this.statusMessage = s.length > 50 ? s.slice(0, 50) + "..." : s; + }); + proc.on("close", (exitCode) => { + this.setRunButtonEnabled(true); + this.statusItem.hide(); + this.statusMessage = undefined; + console.log(`Ran test ${name}, exit = ${exitCode}`); + setTimeout(() => { + this.refreshWebView(outs); + }, 50); + }); + } + + // Triggered when performing a right-click on an image in the WebView. + private copyImageFilePathFromPreviewContext(webviewSection: string) { + if (!this.active) return; + const { name } = this.active; + 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; + } + } + + // Reloads the web view. + private refreshWebView(output?: { stdout: string; stderr: string }) { + if (!this.active) return; + + const { name, panel } = this.active; + const { png, ref } = getImageUris(name); + + if (panel && panel.visible) { + console.log(`Refreshing WebView for ${name}`); + const webViewSrcs = { + png: panel.webview.asWebviewUri(png), + ref: panel.webview.asWebviewUri(ref), + }; + panel.webview.html = ""; + + // Make refresh notable. + setTimeout(() => { + if (!panel) { + throw new Error("panel to refresh is falsy after waiting"); + } + panel.webview.html = getWebviewContent(webViewSrcs, output); + }, 50); + } + } + + // Creates an item for the bottom status bar. + private createStatusItem(): vscode.StatusBarItem { + const item = vscode.window.createStatusBarItem( + vscode.StatusBarAlignment.Right + ); + item.text = "$(loading~spin) Running"; + item.backgroundColor = new vscode.ThemeColor( + "statusBarItem.warningBackground" + ); + item.tooltip = + "test-helper rebuilds crates if necessary, so it may take some time."; + item.command = "typst-test-helper.showTestProgress"; + return item; + } + + // Triggered when clicking on the status bar item. + private showTestProgress() { + if (this.statusMessage === undefined || this.statusSpinning) { + return; + } + this.statusSpinning = true; + vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: "test-helper", + }, + (progress) => + new Promise((resolve) => { + // This progress bar intends to relieve the developer's doubt during + // the possibly long waiting because of the rebuilding phase before + // actually running the test. Therefore, a naive polling (updates + // every few millisec) should be sufficient. + const timerId = setInterval(() => { + if (this.statusMessage === undefined) { + this.statusSpinning = false; + clearInterval(timerId); + resolve(); + } + progress.report({ message: this.statusMessage }); + }, 100); + }) + ); + } + + // Confgiures whether the run and save buttons are enabled. + private setRunButtonEnabled(enabled: boolean) { + vscode.commands.executeCommand( + "setContext", + "typst-test-helper.runButtonEnabled", + enabled + ); + } +} + +// Returns the root folder of the workspace. +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 }; +} + +// Produces the content of the WebView. +function getWebviewContent( + webViewSrcs: { png: vscode.Uri; ref: vscode.Uri }, + output?: { + stdout: string; + stderr: string; + } +): string { + const escape = (text: string) => + text.replace(//g, ">"); + return ` + + + + + + Test output + + + +
+
+

Output

+ Placeholder +
+ +
+

Reference

+ Placeholder +
+
+ ${ + output?.stdout + ? `

Standard output

${escape(output.stdout)}
` + : "" + } + ${ + output?.stderr + ? `

Standard error

${escape(output.stderr)}
` + : "" + } + + `; +} -- cgit v1.2.3