const assert = require("node:assert/strict"); const { readFileSync } = require("node:fs"); const { createRequire } = require("node:module"); const path = require("node:path"); const { setImmediate } = require("node:timers/promises"); const { promisify } = require("node:util"); const vm = require("node:vm"); const { test } = require("node:test"); const root = path.resolve("/workspace/firmware"); const source = readFileSync(path.join(__dirname, "extension.js"), "utf8"); const usb = { Ok: { app: "app.toml", task: "usb", hash: "usb", buildOverrideCommand: ["cargo", "check", "-pusb", "--message-format=json"] } }; const jefe = { Ok: { app: "app.toml", task: "jefe", hash: "jefe", buildOverrideCommand: ["cargo", "check", "-pjefe", "--message-format=json"] } }; function editor(resolve, trusted = true) { const settings = {}; const calls = []; const errors = []; const files = new Map(); const timers = new Map(); const subscriptions = []; const status = { show() {}, dispose() {} }; let change; let onUpdate; const vscode = { ConfigurationTarget: { Workspace: 2 }, StatusBarAlignment: { Left: 1 }, workspace: { isTrusted: trusted, workspaceFolders: [{ uri: { fsPath: root } }], getConfiguration(section) { if (section === "hubris") return { get: () => "app.toml" }; const snapshot = structuredClone(settings); return { get: (key) => snapshot[key], async update(key, value) { await onUpdate?.(key, value); settings[key] = value; }, }; }, onDidSaveTextDocument: () => ({ dispose() {} }), onDidChangeConfiguration: () => ({ dispose() {} }), }, window: { createOutputChannel: () => ({ appendLine() {}, dispose() {} }), createStatusBarItem: () => status, showErrorMessage: (error) => errors.push(error), onDidChangeActiveTextEditor: (callback) => { change = callback; return { dispose() {} }; }, }, commands: { executeCommand: async (command) => { calls.push(command); }, registerCommand: () => ({ dispose() {} }), }, }; const execFile = () => {}; execFile[promisify.custom] = async (command, args, options) => { assert.equal(command, "cargo"); assert.deepEqual(Array.from(args.slice(0, 2)), ["xtask", "lsp"]); assert.equal(options.env.HUBRIS_APP, "app.toml"); assert.equal(options.env.HUBRIS_TASK, undefined); return { stdout: JSON.stringify(await resolve(args)) }; }; const module = { exports: {} }; const require = createRequire(__filename); vm.runInNewContext(source, { module, process: { env: { HUBRIS_TASK: "stale-task" } }, require(name) { if (name === "vscode") return vscode; if (name === "node:child_process") return { execFile }; if (name === "node:fs/promises") return { mkdir: async () => {}, writeFile: async (file, content) => { files.set(file, content); } }; return require(name); }, setTimeout: (callback) => { const id = Symbol(); timers.set(id, callback); return id; }, clearTimeout: (id) => timers.delete(id), }); module.exports.activate({ subscriptions, storageUri: { fsPath: "/storage/hubris" }, asAbsolutePath: (file) => path.join(__dirname, file) }); return { settings, calls, errors, files, status, open(file) { vscode.window.activeTextEditor = { document: { languageId: "rust", uri: { scheme: "file", fsPath: path.resolve(root, file) } } }; change?.(); }, async flush() { for (const [id, callback] of timers) { timers.delete(id); callback(); } await setImmediate(); }, onUpdate(callback) { onUpdate = callback; }, dispose() { for (const subscription of subscriptions) subscription.dispose(); }, }; } test("task changes update the launcher and build scripts without changing server environment", async (t) => { const app = editor((args) => args.at(-1).includes("/usb/") ? usb : jefe); t.after(() => app.dispose()); app.open("tasks/usb/src/main.rs"); await app.flush(); const env = JSON.stringify(app.settings["server.extraEnv"]); app.open("tasks/jefe/src/main.rs"); await app.flush(); assert.equal(app.files.get("/storage/hubris/target"), "app.toml:jefe"); assert.deepEqual(Array.from(app.settings["cargo.buildScripts.overrideCommand"]), jefe.Ok.buildOverrideCommand); assert.equal(JSON.stringify(app.settings["server.extraEnv"]), env); assert.equal(app.calls.length, 4); assert.deepEqual(app.errors, []); }); test("shared files pass the current client and do not restart an unchanged context", async (t) => { const lookups = []; const app = editor((args) => { lookups.push(args); return usb; }); t.after(() => app.dispose()); app.open("tasks/usb/src/main.rs"); await app.flush(); app.open("lib/shared/src/lib.rs"); await app.flush(); assert.deepEqual(JSON.parse(lookups[1][3]), { toml: "app.toml", task: "usb" }); assert.equal(app.calls.length, 2); }); test("unresolved files retain the previous context and report the upstream error", async (t) => { const app = editor((args) => args.at(-1).includes("/kernel/") ? { Err: "kernel is not used" } : usb); t.after(() => app.dispose()); app.open("tasks/usb/src/main.rs"); await app.flush(); app.open("kernel/src/main.rs"); await app.flush(); assert.equal(app.files.get("/storage/hubris/target"), "app.toml:usb"); assert.match(app.status.text, /no task/); assert.equal(app.status.tooltip, "kernel is not used"); assert.equal(app.calls.length, 2); app.open("tasks/usb/src/main.rs"); await app.flush(); assert.equal(app.status.text, "Hubris: app.toml:usb"); }); test("a slow lookup cannot replace the newer active file's context", async (t) => { let release; const pending = new Promise((resolve) => { release = resolve; }); const app = editor((args) => args.at(-1).includes("/usb/") ? pending : jefe); t.after(() => app.dispose()); app.open("tasks/usb/src/main.rs"); await app.flush(); app.open("tasks/jefe/src/main.rs"); release(usb); await app.flush(); assert.equal(app.files.get("/storage/hubris/target"), "app.toml:jefe"); assert.equal(app.calls.length, 2); }); test("a file change during configuration application is not lost", async (t) => { const app = editor((args) => args.at(-1).includes("/usb/") ? usb : jefe); t.after(() => app.dispose()); app.onUpdate(() => { app.onUpdate(undefined); app.open("tasks/jefe/src/main.rs"); }); app.open("tasks/usb/src/main.rs"); await app.flush(); assert.equal(app.files.get("/storage/hubris/target"), "app.toml:jefe"); assert.equal(app.calls.length, 4); }); test("a failed settings write does not leave rust-analyzer stopped", async (t) => { const app = editor(() => usb); t.after(() => app.dispose()); app.onUpdate(() => { throw new Error("settings are read-only"); }); app.open("tasks/usb/src/main.rs"); await app.flush(); assert.deepEqual(app.calls, ["rust-analyzer.stopServer", "rust-analyzer.startServer"]); assert.match(app.errors[0], /read-only/); }); test("untrusted workspaces and external files never execute Cargo", async (t) => { for (const trusted of [false, true]) { const app = editor(() => { assert.fail("unexpected cargo invocation"); }, trusted); t.after(() => app.dispose()); app.open(trusted ? "../../../external.rs" : "tasks/usb/src/main.rs"); await app.flush(); assert.deepEqual(app.calls, []); } });