char-slop/vscode-hubris

git clone https://git.t4t.associates/char-slop/vscode-hubris

Charlotte Sominitial commitf4210c2

main
7.3 KiB182 linesraw
1const assert = require("node:assert/strict");
2const { readFileSync } = require("node:fs");
3const { createRequire } = require("node:module");
4const path = require("node:path");
5const { setImmediate } = require("node:timers/promises");
6const { promisify } = require("node:util");
7const vm = require("node:vm");
8const { test } = require("node:test");
9
10const root = path.resolve("/workspace/firmware");
11const source = readFileSync(path.join(__dirname, "extension.js"), "utf8");
12const usb = { Ok: { app: "app.toml", task: "usb", hash: "usb", buildOverrideCommand: ["cargo", "check", "-pusb", "--message-format=json"] } };
13const jefe = { Ok: { app: "app.toml", task: "jefe", hash: "jefe", buildOverrideCommand: ["cargo", "check", "-pjefe", "--message-format=json"] } };
14
15function editor(resolve, trusted = true) {
16  const settings = {};
17  const calls = [];
18  const errors = [];
19  const files = new Map();
20  const timers = new Map();
21  const subscriptions = [];
22  const status = { show() {}, dispose() {} };
23  let change;
24  let onUpdate;
25  const vscode = {
26    ConfigurationTarget: { Workspace: 2 },
27    StatusBarAlignment: { Left: 1 },
28    workspace: {
29      isTrusted: trusted,
30      workspaceFolders: [{ uri: { fsPath: root } }],
31      getConfiguration(section) {
32        if (section === "hubris") return { get: () => "app.toml" };
33        const snapshot = structuredClone(settings);
34        return {
35          get: (key) => snapshot[key],
36          async update(key, value) {
37            await onUpdate?.(key, value);
38            settings[key] = value;
39          },
40        };
41      },
42      onDidSaveTextDocument: () => ({ dispose() {} }),
43      onDidChangeConfiguration: () => ({ dispose() {} }),
44    },
45    window: {
46      createOutputChannel: () => ({ appendLine() {}, dispose() {} }),
47      createStatusBarItem: () => status,
48      showErrorMessage: (error) => errors.push(error),
49      onDidChangeActiveTextEditor: (callback) => { change = callback; return { dispose() {} }; },
50    },
51    commands: {
52      executeCommand: async (command) => { calls.push(command); },
53      registerCommand: () => ({ dispose() {} }),
54    },
55  };
56  const execFile = () => {};
57  execFile[promisify.custom] = async (command, args, options) => {
58    assert.equal(command, "cargo");
59    assert.deepEqual(Array.from(args.slice(0, 2)), ["xtask", "lsp"]);
60    assert.equal(options.env.HUBRIS_APP, "app.toml");
61    assert.equal(options.env.HUBRIS_TASK, undefined);
62    return { stdout: JSON.stringify(await resolve(args)) };
63  };
64  const module = { exports: {} };
65  const require = createRequire(__filename);
66  vm.runInNewContext(source, {
67    module,
68    process: { env: { HUBRIS_TASK: "stale-task" } },
69    require(name) {
70      if (name === "vscode") return vscode;
71      if (name === "node:child_process") return { execFile };
72      if (name === "node:fs/promises") return { mkdir: async () => {}, writeFile: async (file, content) => { files.set(file, content); } };
73      return require(name);
74    },
75    setTimeout: (callback) => { const id = Symbol(); timers.set(id, callback); return id; },
76    clearTimeout: (id) => timers.delete(id),
77  });
78  module.exports.activate({ subscriptions, storageUri: { fsPath: "/storage/hubris" }, asAbsolutePath: (file) => path.join(__dirname, file) });
79  return {
80    settings, calls, errors, files, status,
81    open(file) {
82      vscode.window.activeTextEditor = { document: { languageId: "rust", uri: { scheme: "file", fsPath: path.resolve(root, file) } } };
83      change?.();
84    },
85    async flush() {
86      for (const [id, callback] of timers) { timers.delete(id); callback(); }
87      await setImmediate();
88    },
89    onUpdate(callback) { onUpdate = callback; },
90    dispose() { for (const subscription of subscriptions) subscription.dispose(); },
91  };
92}
93
94test("task changes update the launcher and build scripts without changing server environment", async (t) => {
95  const app = editor((args) => args.at(-1).includes("/usb/") ? usb : jefe);
96  t.after(() => app.dispose());
97  app.open("tasks/usb/src/main.rs");
98  await app.flush();
99  const env = JSON.stringify(app.settings["server.extraEnv"]);
100  app.open("tasks/jefe/src/main.rs");
101  await app.flush();
102  assert.equal(app.files.get("/storage/hubris/target"), "app.toml:jefe");
103  assert.deepEqual(Array.from(app.settings["cargo.buildScripts.overrideCommand"]), jefe.Ok.buildOverrideCommand);
104  assert.equal(JSON.stringify(app.settings["server.extraEnv"]), env);
105  assert.equal(app.calls.length, 4);
106  assert.deepEqual(app.errors, []);
107});
108
109test("shared files pass the current client and do not restart an unchanged context", async (t) => {
110  const lookups = [];
111  const app = editor((args) => { lookups.push(args); return usb; });
112  t.after(() => app.dispose());
113  app.open("tasks/usb/src/main.rs");
114  await app.flush();
115  app.open("lib/shared/src/lib.rs");
116  await app.flush();
117  assert.deepEqual(JSON.parse(lookups[1][3]), { toml: "app.toml", task: "usb" });
118  assert.equal(app.calls.length, 2);
119});
120
121test("unresolved files retain the previous context and report the upstream error", async (t) => {
122  const app = editor((args) => args.at(-1).includes("/kernel/") ? { Err: "kernel is not used" } : usb);
123  t.after(() => app.dispose());
124  app.open("tasks/usb/src/main.rs");
125  await app.flush();
126  app.open("kernel/src/main.rs");
127  await app.flush();
128  assert.equal(app.files.get("/storage/hubris/target"), "app.toml:usb");
129  assert.match(app.status.text, /no task/);
130  assert.equal(app.status.tooltip, "kernel is not used");
131  assert.equal(app.calls.length, 2);
132  app.open("tasks/usb/src/main.rs");
133  await app.flush();
134  assert.equal(app.status.text, "Hubris: app.toml:usb");
135});
136
137test("a slow lookup cannot replace the newer active file's context", async (t) => {
138  let release;
139  const pending = new Promise((resolve) => { release = resolve; });
140  const app = editor((args) => args.at(-1).includes("/usb/") ? pending : jefe);
141  t.after(() => app.dispose());
142  app.open("tasks/usb/src/main.rs");
143  await app.flush();
144  app.open("tasks/jefe/src/main.rs");
145  release(usb);
146  await app.flush();
147  assert.equal(app.files.get("/storage/hubris/target"), "app.toml:jefe");
148  assert.equal(app.calls.length, 2);
149});
150
151test("a file change during configuration application is not lost", async (t) => {
152  const app = editor((args) => args.at(-1).includes("/usb/") ? usb : jefe);
153  t.after(() => app.dispose());
154  app.onUpdate(() => {
155    app.onUpdate(undefined);
156    app.open("tasks/jefe/src/main.rs");
157  });
158  app.open("tasks/usb/src/main.rs");
159  await app.flush();
160  assert.equal(app.files.get("/storage/hubris/target"), "app.toml:jefe");
161  assert.equal(app.calls.length, 4);
162});
163
164test("a failed settings write does not leave rust-analyzer stopped", async (t) => {
165  const app = editor(() => usb);
166  t.after(() => app.dispose());
167  app.onUpdate(() => { throw new Error("settings are read-only"); });
168  app.open("tasks/usb/src/main.rs");
169  await app.flush();
170  assert.deepEqual(app.calls, ["rust-analyzer.stopServer", "rust-analyzer.startServer"]);
171  assert.match(app.errors[0], /read-only/);
172});
173
174test("untrusted workspaces and external files never execute Cargo", async (t) => {
175  for (const trusted of [false, true]) {
176    const app = editor(() => { assert.fail("unexpected cargo invocation"); }, trusted);
177    t.after(() => app.dispose());
178    app.open(trusted ? "../../../external.rs" : "tasks/usb/src/main.rs");
179    await app.flush();
180    assert.deepEqual(app.calls, []);
181  }
182});