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/README.md | 38 +-- tools/test-helper/extension.js | 363 --------------------------- tools/test-helper/package.json | 115 ++++----- tools/test-helper/src/extension.ts | 492 +++++++++++++++++++++++++++++++++++++ tools/test-helper/tsconfig.json | 11 + 5 files changed, 572 insertions(+), 447 deletions(-) delete mode 100644 tools/test-helper/extension.js create mode 100644 tools/test-helper/src/extension.ts create mode 100644 tools/test-helper/tsconfig.json (limited to 'tools/test-helper') diff --git a/tools/test-helper/README.md b/tools/test-helper/README.md index 0b10c454..f95b8d73 100644 --- a/tools/test-helper/README.md +++ b/tools/test-helper/README.md @@ -1,23 +1,29 @@ # Test helper This is a small VS Code extension that helps with managing Typst's test suite. -When installed, three new buttons appear in the menubar for all `.typ` files in -the `tests` folder. +When installed, a new Code Lens appears in all `.typ` files in the `tests` +folder. It provides the following actions: -- Open: Opens the output and reference images of a test to the side. -- Refresh: Refreshes the preview. -- Rerun: Re-runs the test. -- Update: Copies the output into the reference folder and optimizes - it with `oxipng`. +- View: Opens the output and reference image of a test to the side. +- Run: Runs the test and shows the results to the side. +- Terminal: Runs the test in the integrated terminal. -For the test helper to work correctly, you also need to install `oxipng`, for -example with `cargo install oxipng`. Make sure that the version of oxipng you -install is the same as the one in the root `Cargo.toml` so that the results are -the same as when using the test CLI. +In the side panel, there are a few menu actions at the top right: + +- Refresh: Reloads the panel to reflect changes to the images +- Run: Runs the test and shows the results +- Save: Runs the test with `--update` to save the reference image ## Installation -The simplest way to install this extension (and keep it up-to-date) is to use VSCode's UI: -* Go to View > Command Palette, -* In the drop down list, pick command "Developer: Install extension from location", -* Select this `test-helper` directory in the file explorer dialogue box. VSCode will add -the extension's path to `~/.vscode/extensions/extensions.json`. +First, you need to build the extension: +```bash +npm i +npm run build +``` + +Then, you can easily install and (and keep it up-to-date) via VS Code's UI: +- Go to View > Command Palette or press Cmd/Ctrl+P, +- In the drop down list, pick command "Developer: Install extension from + location", +- Select this `test-helper` directory in the file explorer dialogue box. VS Code + will add the extension's path to `~/.vscode/extensions/extensions.json`. diff --git a/tools/test-helper/extension.js b/tools/test-helper/extension.js deleted file mode 100644 index 7c3fa418..00000000 --- a/tools/test-helper/extension.js +++ /dev/null @@ -1,363 +0,0 @@ -const vscode = require('vscode') -const cp = require('child_process') -const {clearInterval} = require('timers') - -class Handler { - constructor() { - /** @type {vscode.Uri?} */ this.sourceUriOfActivePanel = null - /** @type {Map} */ this.panels = new Map() - - /** @type {vscode.StatusBarItem} */ this.testRunningStatusBarItem = - vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right) - this.testRunningStatusBarItem.text = "$(loading~spin) Running" - this.testRunningStatusBarItem.backgroundColor = - new vscode.ThemeColor('statusBarItem.warningBackground') - this.testRunningStatusBarItem.tooltip = - "test-helper rebuilds crates if necessary, so it may take some time." - this.testRunningStatusBarItem.command = - "Typst.test-helper.showTestProgress" - /** @type {string|undefined} */ this.testRunningLatestMessage = undefined - this.testRunningProgressShown = false - - Handler.enableRunTestButton_(true) - } - - /** - * The caller should ensure when this function is called, a text editor is active. - * Note the fake "editor" for the extension's WebView panel is not one. - * @returns {vscode.Uri} - */ - static getActiveDocumentUri() { - const editor = vscode.window.activeTextEditor - if (!editor) { - throw new Error('vscode.window.activeTextEditor is undefined.') - } - return editor.document.uri - } - - /** - * The caller should ensure when this function is called, a WebView panel is active. - * @returns {vscode.Uri} - */ - getSourceUriOfActivePanel() { - // If this function is invoked when user clicks the button from within a WebView - // panel, then the active panel is this panel, and sourceUriOfActivePanel is - // guaranteed to have been updated by that panel's onDidChangeViewState listener. - if (!this.sourceUriOfActivePanel) { - throw new Error('sourceUriOfActivePanel is falsy; is there a focused panel?') - } - return this.sourceUriOfActivePanel - } - - /** @param {vscode.Uri} uri */ - static getImageUris(uri) { - const png = vscode.Uri.file(uri.path - .replace("tests/typ", "tests/png") - .replace(".typ", ".png")) - - const ref = vscode.Uri.file(uri.path - .replace("tests/typ", "tests/ref") - .replace(".typ", ".png")) - - return {png, ref} - } - - /** @param {boolean} enable */ - static enableRunTestButton_(enable) { - // Need to flip the value here, i.e. "disableRunTestButton" rather than - // "enableRunTestButton", because default values of custom context keys - // before extension activation are falsy. The extension isn't activated - // until a button is clicked. - // - // Note: at the time of this writing, VSCode doesn't support activating - // on path patterns. Alternatively one may try activating the extension - // using the activation event "onLanguage:typst", but this idea in fact - // doesn't work perperly as we would like, since (a) we do not want the - // extension to be enabled on every Typst file, e.g. the thesis you are - // working on, and (b) VSCode does not know the language ID "typst" out - // of box. - vscode.commands.executeCommand( - "setContext", "Typst.test-helper.disableRunTestButton", !enable) - } - - /** - * @param {vscode.Uri} uri - * @param {string} stdout - * @param {string} stderr - */ - refreshTestPreviewImpl_(uri, stdout, stderr) { - const {png, ref} = Handler.getImageUris(uri) - - const panel = this.panels.get(uri) - if (panel && panel.visible) { - console.log(`Refreshing WebView for ${uri.fsPath}`) - 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, stdout, stderr) - }, 50) - } - } - - /** @param {vscode.Uri} uri */ - openTestPreview(uri) { - if (this.panels.has(uri)) { - this.panels.get(uri)?.reveal() - return - } - - const newPanel = vscode.window.createWebviewPanel( - 'Typst.test-helper.preview', - uri.path.split('/').pop()?.replace('.typ', '.png') ?? 'Test output', - vscode.ViewColumn.Beside, - {enableFindWidget: true}, - ) - newPanel.onDidChangeViewState(() => { - if (newPanel && newPanel.active && newPanel.visible) { - console.log(`Set sourceUriOfActivePanel to ${uri}`) - this.sourceUriOfActivePanel = uri - } else { - console.log(`Set sourceUriOfActivePanel to null`) - this.sourceUriOfActivePanel = null - } - }) - newPanel.onDidDispose(() => { - console.log(`Delete panel ${uri}`) - this.panels.delete(uri) - if (this.sourceUriOfActivePanel === uri) { - this.sourceUriOfActivePanel = null - } - }) - this.panels.set(uri, newPanel) - - this.refreshTestPreviewImpl_(uri, "", "") - } - - showTestProgress() { - if (this.testRunningLatestMessage === undefined - || this.testRunningProgressShown) { - return - } - this.testRunningProgressShown = true - vscode.window.withProgress({ - location: vscode.ProgressLocation.Notification, - title: "test-helper", - }, (progress) => { - /** @type {!Promise} */ - const closeWhenResolved = 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.testRunningLatestMessage === undefined) { - this.testRunningProgressShown = false - clearInterval(timerId) - resolve() - } - progress.report({message: this.testRunningLatestMessage}) - }, 100) - }) - - return closeWhenResolved - }) - } - - /** @param {vscode.Uri} uri */ - runTest(uri) { - const components = uri.fsPath.split(/tests[\/\\]/) - const [dir, subPath] = components - - Handler.enableRunTestButton_(false) - this.testRunningStatusBarItem.show() - - const proc = cp.spawn( - "cargo", - ["test", "--manifest-path", `${dir}/Cargo.toml`, "--workspace", "--test", "tests", "--", `${subPath}`]) - 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.testRunningLatestMessage = s.length > 50 ? (s.slice(0, 50) + "...") : s - }) - proc.on("close", (exitCode) => { - Handler.enableRunTestButton_(true) - this.testRunningStatusBarItem.hide() - this.testRunningLatestMessage = undefined - console.log(`Ran tests ${uri.fsPath}, exit = ${exitCode}`) - this.refreshTestPreviewImpl_(uri, outs.stdout, outs.stderr) - }) - } - - /** @param {vscode.Uri} uri */ - refreshTestPreview(uri) { - const panel = this.panels.get(uri) - if (panel) { - panel.reveal() - this.refreshTestPreviewImpl_(uri, "", "") - } - } - - /** @param {vscode.Uri} uri */ - updateTestReference(uri) { - const {png, ref} = Handler.getImageUris(uri) - - vscode.workspace.fs.copy(png, ref, {overwrite: true}) - .then(() => { - cp.exec(`oxipng -o max -a ${ref.fsPath}`, (err, stdout, stderr) => { - console.log(`Copied to reference file for ${uri.fsPath}`) - this.refreshTestPreviewImpl_(uri, stdout, stderr) - }) - }) - } - - /** - * @param {vscode.Uri} uri - * @param {string} webviewSection - */ - copyFilePathToClipboard(uri, webviewSection) { - const {png, ref} = Handler.getImageUris(uri) - switch (webviewSection) { - case 'png': - vscode.env.clipboard.writeText(png.fsPath) - break - case 'ref': - vscode.env.clipboard.writeText(ref.fsPath) - break - default: - break - } - } -} - -/** @param {vscode.ExtensionContext} context */ -function activate(context) { - const handler = new Handler() - context.subscriptions.push(handler.testRunningStatusBarItem) - - context.subscriptions.push(vscode.commands.registerCommand( - "Typst.test-helper.showTestProgress", () => { - handler.showTestProgress() - })) - context.subscriptions.push(vscode.commands.registerCommand( - "Typst.test-helper.openFromSource", () => { - handler.openTestPreview(Handler.getActiveDocumentUri()) - })) - context.subscriptions.push(vscode.commands.registerCommand( - "Typst.test-helper.refreshFromSource", () => { - handler.refreshTestPreview(Handler.getActiveDocumentUri()) - })) - context.subscriptions.push(vscode.commands.registerCommand( - "Typst.test-helper.refreshFromPreview", () => { - handler.refreshTestPreview(handler.getSourceUriOfActivePanel()) - })) - context.subscriptions.push(vscode.commands.registerCommand( - "Typst.test-helper.runFromSource", () => { - handler.runTest(Handler.getActiveDocumentUri()) - })) - context.subscriptions.push(vscode.commands.registerCommand( - "Typst.test-helper.runFromPreview", () => { - handler.runTest(handler.getSourceUriOfActivePanel()) - })) - context.subscriptions.push(vscode.commands.registerCommand( - "Typst.test-helper.updateFromSource", () => { - handler.updateTestReference(Handler.getActiveDocumentUri()) - })) - context.subscriptions.push(vscode.commands.registerCommand( - "Typst.test-helper.updateFromPreview", () => { - handler.updateTestReference(handler.getSourceUriOfActivePanel()) - })) - context.subscriptions.push(vscode.commands.registerCommand( - // The context menu (the "right-click menu") in the preview tab. - "Typst.test-helper.copyImageFilePathFromPreviewContext", (e) => { - handler.copyFilePathToClipboard( - handler.getSourceUriOfActivePanel(), e.webviewSection) - })) -} - -/** - * @param {{png: vscode.Uri, ref: vscode.Uri}} webViewSrcs - * @param {string} stdout - * @param {string} stderr - * @returns {string} - */ -function getWebviewContent(webViewSrcs, stdout, stderr) { - const escape = (/**@type{string}*/text) => text.replace(//g, ">") - return ` - - - - - - Test output - - - -
-
-

Output

- -
- -
-

Reference

- -
-
- -

Standard output

-
${escape(stdout)}
- -

Standard error

-
${escape(stderr)}
- - - ` -} - -function deactivate() {} - -module.exports = {activate, deactivate} diff --git a/tools/test-helper/package.json b/tools/test-helper/package.json index 0f806eb9..5da2fe5a 100644 --- a/tools/test-helper/package.json +++ b/tools/test-helper/package.json @@ -1,5 +1,5 @@ { - "name": "test-helper", + "name": "typst-test-helper", "publisher": "typst", "displayName": "Typst Test Helper", "description": "Helps to run, compare and update Typst tests.", @@ -11,115 +11,94 @@ "Other" ], "activationEvents": [ - "onCommand:Typst.test-helper.openFromSource", - "onCommand:Typst.test-helper.refreshFromSource", - "onCommand:Typst.test-helper.refreshFromPreview", - "onCommand:Typst.test-helper.runFromSource", - "onCommand:Typst.test-helper.runFromPreview", - "onCommand:Typst.test-helper.updateFromSource", - "onCommand:Typst.test-helper.updateFromPreview", - "onCommand:Typst.test-helper.copyImageFilePathFromPreviewContext" + "workspaceContains:tests/suite/playground.typ" ], - "main": "./extension.js", + "main": "./dist/extension.js", "contributes": { "commands": [ { - "command": "Typst.test-helper.openFromSource", - "title": "Open test output", - "category": "Typst.test-helper", - "icon": "$(plus)" - }, - { - "command": "Typst.test-helper.refreshFromSource", + "command": "typst-test-helper.refreshFromPreview", "title": "Refresh preview", - "category": "Typst.test-helper", + "category": "Typst Test Helper", "icon": "$(refresh)" }, { - "command": "Typst.test-helper.refreshFromPreview", - "title": "Refresh preview", - "category": "Typst.test-helper", - "icon": "$(refresh)" - }, - { - "command": "Typst.test-helper.runFromSource", + "command": "typst-test-helper.runFromPreview", "title": "Run test", - "category": "Typst.test-helper", + "category": "Typst Test Helper", "icon": "$(debug-start)", - "enablement": "!Typst.test-helper.disableRunTestButton" + "enablement": "typst-test-helper.runButtonEnabled" }, { - "command": "Typst.test-helper.runFromPreview", - "title": "Run test", - "category": "Typst.test-helper", - "icon": "$(debug-start)", - "enablement": "!Typst.test-helper.disableRunTestButton" + "command": "typst-test-helper.saveFromPreview", + "title": "Run and save reference image", + "category": "Typst Test Helper", + "icon": "$(save)", + "enablement": "typst-test-helper.runButtonEnabled" }, { - "command": "Typst.test-helper.updateFromSource", - "title": "Update reference image", - "category": "Typst.test-helper", - "icon": "$(save)" + "command": "typst-test-helper.copyImageFilePathFromPreviewContext", + "title": "Copy image file path", + "category": "Typst Test Helper" }, { - "command": "Typst.test-helper.updateFromPreview", - "title": "Update reference image", - "category": "Typst.test-helper", - "icon": "$(save)" + "command": "typst-test-helper.increaseResolution", + "title": "Render at higher resolution", + "category": "Typst Test Helper", + "icon": "$(zoom-in)", + "enablement": "typst-test-helper.runButtonEnabled" }, { - "command": "Typst.test-helper.copyImageFilePathFromPreviewContext", - "title": "Copy image file path" + "command": "typst-test-helper.decreaseResolution", + "title": "Render at lower resolution", + "category": "Typst Test Helper", + "icon": "$(zoom-out)", + "enablement": "typst-test-helper.runButtonEnabled" } ], "menus": { "editor/title": [ { - "when": "resourceExtname == .typ && resourcePath =~ /.*tests.*/", - "command": "Typst.test-helper.openFromSource", + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.refreshFromPreview", "group": "navigation@1" }, { - "when": "resourceExtname == .typ && resourcePath =~ /.*tests.*/", - "command": "Typst.test-helper.refreshFromSource", + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.runFromPreview", "group": "navigation@2" }, { - "when": "resourceExtname == .typ && resourcePath =~ /.*tests.*/", - "command": "Typst.test-helper.runFromSource", + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.saveFromPreview", "group": "navigation@3" }, { - "when": "resourceExtname == .typ && resourcePath =~ /.*tests.*/", - "command": "Typst.test-helper.updateFromSource", + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.increaseResolution", "group": "navigation@4" }, { - "when": "activeWebviewPanelId == Typst.test-helper.preview", - "command": "Typst.test-helper.refreshFromPreview", - "group": "navigation@1" - }, - { - "when": "activeWebviewPanelId == Typst.test-helper.preview", - "command": "Typst.test-helper.runFromPreview", - "group": "navigation@2" - }, - { - "when": "activeWebviewPanelId == Typst.test-helper.preview", - "command": "Typst.test-helper.updateFromPreview", - "group": "navigation@3" + "when": "activeWebviewPanelId == typst-test-helper.preview", + "command": "typst-test-helper.decreaseResolution", + "group": "navigation@4" } ], "webview/context": [ { - "command": "Typst.test-helper.copyImageFilePathFromPreviewContext", - "when": "webviewId == Typst.test-helper.preview && (webviewSection == png || webviewSection == ref)" + "command": "typst-test-helper.copyImageFilePathFromPreviewContext", + "when": "webviewId == typst-test-helper.preview && (webviewSection == png || webviewSection == ref)" } ] } }, + "scripts": { + "build": "tsc -p ./", + "watch": "tsc -watch -p ./" + }, "devDependencies": { - "@types/vscode": "^1.53.0", - "@types/node": "^12.11.7" + "@types/vscode": "^1.88.0", + "@types/node": "18.x", + "typescript": "^5.3.3" } -} \ No newline at end of file +} 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)}
` + : "" + } + + `; +} diff --git a/tools/test-helper/tsconfig.json b/tools/test-helper/tsconfig.json new file mode 100644 index 00000000..45e37455 --- /dev/null +++ b/tools/test-helper/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "module": "Node16", + "target": "ES2022", + "outDir": "dist", + "lib": ["ES2022"], + "sourceMap": true, + "rootDir": "src", + "strict": true + } +} -- cgit v1.2.3