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 opened tab for a test or `undefined` if none. A non-empty "opened" // means there is a TestHelper editor tab present, but the content within // that tab (i.e. the WebView panel) might not be visible yet. opened?: { // The tests's name. name: string; // The WebView panel that displays the test images and output. panel: vscode.WebviewPanel; }; // 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) { // 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.opened?.name == name) { this.opened.panel.reveal(); return; } if (this.opened) { this.opened.name = name; this.opened.panel.title = name; } else { const panel = vscode.window.createWebviewPanel( "typst-test-helper.preview", name, vscode.ViewColumn.Beside, { enableFindWidget: true } ); panel.onDidDispose(() => (this.opened = undefined)); this.opened = { 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.opened) { this.opened.panel.reveal(); this.refreshWebView(); } } // Triggered when clicking the "Run" button in the WebView toolbar. private runFromPreview() { if (this.opened) { this.runCargoTest(this.opened.name); } } // Triggered when clicking the "Save" button in the WebView toolbar. private saveFromPreview() { if (this.opened) { this.runCargoTest(this.opened.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.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; } } // 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); if (panel) { console.log( `Refreshing WebView for ${name}` + (panel.visible ? " in background" : "")); 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, ">"); const stdoutHtml = output?.stdout ? `

Standard output

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

Standard error

${escape(output.stderr)}
` : ""; return ` Test output

Output

Placeholder

Reference

Placeholder
${stdoutHtml} ${stderrHtml} `; }