@openvcs/sdk 0.4.0 → 0.4.1-beta.102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,57 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { runCli } = require("../lib/cli");
5
+
6
+ async function captureCli(args) {
7
+ const stdout = [];
8
+ const stderr = [];
9
+ const originalStdoutWrite = process.stdout.write;
10
+ const originalStderrWrite = process.stderr.write;
11
+ const originalExitCode = process.exitCode;
12
+ process.exitCode = undefined;
13
+ process.stdout.write = (chunk) => { stdout.push(String(chunk)); return true; };
14
+ process.stderr.write = (chunk) => { stderr.push(String(chunk)); return true; };
15
+ try {
16
+ await runCli(args);
17
+ return { stdout: stdout.join(""), stderr: stderr.join(""), exitCode: process.exitCode };
18
+ } finally {
19
+ process.stdout.write = originalStdoutWrite;
20
+ process.stderr.write = originalStderrWrite;
21
+ process.exitCode = originalExitCode;
22
+ }
23
+ }
24
+
25
+ test("runCli writes usage to stderr for empty args", async () => {
26
+ const result = await captureCli([]);
27
+
28
+ assert.match(result.stderr, /Usage: openvcs/);
29
+ assert.equal(result.exitCode, 1);
30
+ });
31
+
32
+ test("runCli writes root help to stdout", async () => {
33
+ const result = await captureCli(["help"]);
34
+
35
+ assert.match(result.stdout, /Commands:/);
36
+ assert.equal(result.exitCode, undefined);
37
+ });
38
+
39
+ test("runCli passes through build parser errors", async () => {
40
+ await assert.rejects(() => runCli(["build", "--plugin-dir"]), /missing value for --plugin-dir/);
41
+ });
42
+
43
+ test("runCli rejects invalid init arguments", async () => {
44
+ await assert.rejects(() => runCli(["init", "--bad"]), /unknown argument for init/);
45
+ });
46
+
47
+ test("runCli direct help branches write subcommand usage", async () => {
48
+ const buildHelp = await captureCli(["build", "--help"]);
49
+ const initHelp = await captureCli(["init", "--help"]);
50
+
51
+ assert.match(buildHelp.stdout, /openvcs build \[args\]/);
52
+ assert.match(initHelp.stdout, /Usage: openvcs init/);
53
+ });
54
+
55
+ test("runCli direct unknown command branch rejects", async () => {
56
+ await assert.rejects(() => runCli(["wat"]), /unknown command: wat/);
57
+ });
@@ -0,0 +1,104 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { createDefaultPluginDelegates, createHost, createRuntimeDispatcher, isPluginFailure, pluginError } = require("../lib/runtime");
5
+ const { PROTOCOL_VERSION } = require("../lib/types");
6
+
7
+ function writer() {
8
+ const messages = [];
9
+ return {
10
+ messages,
11
+ sendResult(id, result) {
12
+ messages.push({ id, result });
13
+ },
14
+ sendError(id, code, message, data) {
15
+ messages.push({ id, error: { code, message, data } });
16
+ },
17
+ };
18
+ }
19
+
20
+ test("default plugin delegates return protocol-safe defaults", async () => {
21
+ const delegates = createDefaultPluginDelegates();
22
+
23
+ assert.equal(await delegates["plugin.init"]({}, {}), null);
24
+ assert.equal(await delegates["plugin.deinit"]({}, {}), null);
25
+ assert.deepEqual(await delegates["plugin.get_menus"]({}, {}), []);
26
+ assert.equal(await delegates["plugin.handle_action"]({}, {}), null);
27
+ assert.deepEqual(await delegates["plugin.settings.defaults"]({}, {}), []);
28
+ assert.deepEqual(await delegates["plugin.settings.on_load"]({ values: [1] }, {}), [1]);
29
+ assert.deepEqual(await delegates["plugin.settings.on_load"]({}, {}), []);
30
+ assert.equal(await delegates["plugin.settings.on_apply"]({}, {}), null);
31
+ assert.deepEqual(await delegates["plugin.settings.on_save"]({ values: [2] }, {}), [2]);
32
+ assert.deepEqual(await delegates["plugin.settings.on_save"]({}, {}), []);
33
+ assert.equal(await delegates["plugin.settings.on_reset"]({}, {}), null);
34
+ });
35
+
36
+ test("dispatcher rejects protocol version mismatch", async () => {
37
+ const out = writer();
38
+ const dispatcher = createRuntimeDispatcher({}, createHost(() => {}), out);
39
+
40
+ await dispatcher(1, "plugin.initialize", { expected_protocol_version: PROTOCOL_VERSION + 1 });
41
+
42
+ assert.equal(out.messages[0].id, 1);
43
+ assert.equal(out.messages[0].error.data.code, "protocol-version-mismatch");
44
+ });
45
+
46
+ test("dispatcher merges initialize override with inferred implements", async () => {
47
+ const out = writer();
48
+ const dispatcher = createRuntimeDispatcher(
49
+ {
50
+ implements: { plugin: { menus: true } },
51
+ vcs: { "vcs.status": async () => ({ files: [] }) },
52
+ plugin: {
53
+ "plugin.initialize": async () => ({ implements: { custom: true } }),
54
+ },
55
+ },
56
+ createHost(() => {}),
57
+ out,
58
+ );
59
+
60
+ await dispatcher("init", "plugin.initialize", { expected_protocol_version: PROTOCOL_VERSION });
61
+
62
+ assert.equal(out.messages[0].result.protocol_version, PROTOCOL_VERSION);
63
+ assert.equal(out.messages[0].result.implements.custom, true);
64
+ assert.equal(out.messages[0].result.implements.vcs, true);
65
+ });
66
+
67
+ test("dispatcher reports plugin failures separately from generic failures", async () => {
68
+ const pluginOut = writer();
69
+ await createRuntimeDispatcher(
70
+ { plugin: { "plugin.init": async () => { throw pluginError("bad-input", "Bad input", { field: "x" }); } } },
71
+ createHost(() => {}),
72
+ pluginOut,
73
+ )(2, "plugin.init", {});
74
+
75
+ const genericOut = writer();
76
+ await createRuntimeDispatcher(
77
+ { plugin: { "plugin.init": async () => { throw new Error("Boom"); } } },
78
+ createHost(() => {}),
79
+ genericOut,
80
+ )(3, "plugin.init", {});
81
+
82
+ assert.equal(pluginOut.messages[0].error.data.code, "bad-input");
83
+ assert.equal(genericOut.messages[0].error.data.code, "plugin-internal-error");
84
+ });
85
+
86
+ test("isPluginFailure rejects non-objects", () => {
87
+ assert.equal(isPluginFailure(null), false);
88
+ assert.equal(isPluginFailure("bad"), false);
89
+ assert.equal(isPluginFailure({ code: "x", message: "y" }), false);
90
+ });
91
+
92
+ test("dispatcher times out slow handlers", async () => {
93
+ const out = writer();
94
+ const dispatcher = createRuntimeDispatcher(
95
+ { timeout: 1, plugin: { "plugin.init": async () => new Promise(() => {}) } },
96
+ createHost(() => {}),
97
+ out,
98
+ );
99
+
100
+ await dispatcher(4, "plugin.init", {});
101
+
102
+ assert.equal(out.messages[0].error.data.code, "request-timeout");
103
+ assert.match(out.messages[0].error.message, /timed out/);
104
+ });
@@ -55,6 +55,20 @@ test("copyFileStrict rejects non-files", () => {
55
55
  cleanupTempDir(root);
56
56
  });
57
57
 
58
+ test("copyFileStrict rejects file symlinks", () => {
59
+ if (process.platform === "win32") {
60
+ return;
61
+ }
62
+ const root = makeTempDir("openvcs-sdk-test");
63
+ const target = path.join(root, "target.txt");
64
+ const link = path.join(root, "link.txt");
65
+ writeText(target, "target");
66
+ fs.symlinkSync(target, link);
67
+
68
+ assert.throws(() => copyFileStrict(link, path.join(root, "out.txt")), /symlink/);
69
+ cleanupTempDir(root);
70
+ });
71
+
58
72
  test("copyDirectoryRecursiveStrict copies nested trees", () => {
59
73
  const root = makeTempDir("openvcs-sdk-test");
60
74
  const src = path.join(root, "src");
@@ -87,6 +101,49 @@ test("copyDirectoryRecursiveStrict errors when source is file", () => {
87
101
  cleanupTempDir(root);
88
102
  });
89
103
 
104
+ test("copyDirectoryRecursiveStrict rejects source directory symlink", () => {
105
+ if (process.platform === "win32") {
106
+ return;
107
+ }
108
+ const root = makeTempDir("openvcs-sdk-test");
109
+ const targetDir = path.join(root, "target");
110
+ const link = path.join(root, "link");
111
+ fs.mkdirSync(targetDir, { recursive: true });
112
+ fs.symlinkSync(targetDir, link);
113
+
114
+ assert.throws(() => copyDirectoryRecursiveStrict(link, path.join(root, "dst")), /symlink/);
115
+ cleanupTempDir(root);
116
+ });
117
+
118
+ test("copyDirectoryRecursiveStrict skips special non-file entries", () => {
119
+ const root = makeTempDir("openvcs-sdk-test");
120
+ const src = path.join(root, "src");
121
+ const dst = path.join(root, "dst");
122
+ fs.mkdirSync(path.join(src, "fifo-like"), { recursive: true });
123
+ writeText(path.join(src, "file.txt"), "ok");
124
+
125
+ copyDirectoryRecursiveStrict(src, dst);
126
+
127
+ assert.equal(fs.readFileSync(path.join(dst, "file.txt"), "utf8"), "ok");
128
+ cleanupTempDir(root);
129
+ });
130
+
131
+ test("copyDirectoryRecursiveStrict rejects symlink entries inside trees", () => {
132
+ if (process.platform === "win32") {
133
+ return;
134
+ }
135
+ const root = makeTempDir("openvcs-sdk-test");
136
+ const src = path.join(root, "src");
137
+ const dst = path.join(root, "dst");
138
+ const target = path.join(root, "target.txt");
139
+ writeText(target, "target");
140
+ fs.mkdirSync(src, { recursive: true });
141
+ fs.symlinkSync(target, path.join(src, "linked.txt"));
142
+
143
+ assert.throws(() => copyDirectoryRecursiveStrict(src, dst), /symlink/);
144
+ cleanupTempDir(root);
145
+ });
146
+
90
147
  test("rejectSymlinksRecursive allows regular trees", () => {
91
148
  const root = makeTempDir("openvcs-sdk-test");
92
149
  writeText(path.join(root, "a.txt"), "a");
@@ -0,0 +1,39 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { createHost } = require("../lib/runtime");
5
+
6
+ test("createHost maps log helpers to host.log notifications", () => {
7
+ const sent = [];
8
+ const host = createHost((method, params) => sent.push({ method, params }), { logTarget: "test.plugin" });
9
+
10
+ host.log("warn", "careful");
11
+ host.info("ready");
12
+ host.error("failed");
13
+
14
+ assert.deepEqual(sent, [
15
+ { method: "host.log", params: { level: "warn", target: "test.plugin", message: "careful" } },
16
+ { method: "host.log", params: { level: "info", target: "test.plugin", message: "ready" } },
17
+ { method: "host.log", params: { level: "error", target: "test.plugin", message: "failed" } },
18
+ ]);
19
+ });
20
+
21
+ test("createHost maps UI/status/event helpers to protocol notifications", () => {
22
+ const sent = [];
23
+ const host = createHost((method, params) => sent.push({ method, params }));
24
+
25
+ host.uiNotify({ level: "info", message: "Saved" });
26
+ host.statusSet({ message: "Working" });
27
+ host.emitEvent({ name: "custom", payload: { value: 1 } });
28
+ host.emitVcsEvent("session-1", 42, { type: "progress", message: "Fetch" });
29
+
30
+ assert.deepEqual(sent, [
31
+ { method: "host.ui_notify", params: { level: "info", message: "Saved" } },
32
+ { method: "host.status_set", params: { message: "Working" } },
33
+ { method: "host.event_emit", params: { name: "custom", payload: { value: 1 } } },
34
+ {
35
+ method: "vcs.event",
36
+ params: { session_id: "session-1", request_id: 42, event: { type: "progress", message: "Fetch" } },
37
+ },
38
+ ]);
39
+ });
package/test/init.test.js CHANGED
@@ -1,11 +1,42 @@
1
1
  const assert = require("node:assert/strict");
2
+ const childProcess = require("node:child_process");
2
3
  const fs = require("node:fs");
4
+ const readline = require("node:readline/promises");
3
5
  const test = require("node:test");
4
6
  const path = require("node:path");
5
7
 
6
- const { __private } = require("../lib/init");
8
+ const { __private, initUsage, isUsageError, runInitCommand } = require("../lib/init");
7
9
  const { cleanupTempDir, makeTempDir } = require("./helpers");
8
10
 
11
+ async function withMockReadline(answers, run) {
12
+ const originalCreateInterface = readline.createInterface;
13
+ const prompts = [];
14
+ readline.createInterface = () => ({
15
+ async question(prompt) {
16
+ prompts.push(prompt);
17
+ return answers.shift() ?? "";
18
+ },
19
+ close() {},
20
+ });
21
+
22
+ try {
23
+ return await run(prompts);
24
+ } finally {
25
+ readline.createInterface = originalCreateInterface;
26
+ }
27
+ }
28
+
29
+ async function withMockSpawnSync(result, run) {
30
+ const originalSpawnSync = childProcess.spawnSync;
31
+ childProcess.spawnSync = () => result;
32
+
33
+ try {
34
+ return await run();
35
+ } finally {
36
+ childProcess.spawnSync = originalSpawnSync;
37
+ }
38
+ }
39
+
9
40
  test("validatePluginId accepts regular ids", () => {
10
41
  assert.equal(__private.validatePluginId("my.plugin"), undefined);
11
42
  assert.equal(__private.validatePluginId("my-plugin_1"), undefined);
@@ -25,6 +56,97 @@ test("validatePluginId rejects path separators", () => {
25
56
  assert.match(__private.validatePluginId("bad\\id"), /path separators/);
26
57
  });
27
58
 
59
+ test("init helpers derive stable defaults", () => {
60
+ assert.equal(__private.sanitizeIdToken(" My Plugin!! "), "my-plugin");
61
+ assert.equal(__private.defaultPluginIdFromDir(path.join("tmp", "My Plugin")), "my-plugin");
62
+ assert.equal(__private.defaultPluginIdFromDir(path.join("tmp", "!!!")), "openvcs.plugin");
63
+ assert.equal(__private.defaultPluginNameFromId("my-plugin.name"), "My Plugin Name");
64
+ assert.equal(__private.defaultPluginNameFromId("---"), "OpenVCS Plugin");
65
+ assert.match(initUsage("sdk"), /sdk init/);
66
+ });
67
+
68
+ test("runInitCommand validates args before prompting", async () => {
69
+ await assert.rejects(() => runInitCommand(["--bad"]), /unknown argument for init/);
70
+ await assert.rejects(() => runInitCommand(["one", "two"]), /at most one target directory/);
71
+
72
+ let usageError;
73
+ try {
74
+ await runInitCommand(["--help"]);
75
+ } catch (error) {
76
+ usageError = error;
77
+ }
78
+ assert.equal(isUsageError(usageError), true);
79
+ });
80
+
81
+ test("runInitCommand rejects when target path is a file", async () => {
82
+ const root = makeTempDir("openvcs-sdk-test");
83
+ const targetPath = path.join(root, "plugin");
84
+ fs.writeFileSync(targetPath, "not a directory", "utf8");
85
+
86
+ await assert.rejects(
87
+ () =>
88
+ withMockReadline([
89
+ "",
90
+ "module",
91
+ "",
92
+ "",
93
+ "",
94
+ "",
95
+ "n",
96
+ ], () => runInitCommand([targetPath])),
97
+ /target path exists but is not a directory/
98
+ );
99
+
100
+ cleanupTempDir(root);
101
+ });
102
+
103
+ test("runInitCommand writes a module template after confirming overwrite", async () => {
104
+ const root = makeTempDir("openvcs-sdk-test");
105
+ const targetDir = path.join(root, "plugin");
106
+ fs.mkdirSync(targetDir, { recursive: true });
107
+ fs.writeFileSync(path.join(targetDir, "README.md"), "keep", "utf8");
108
+
109
+ const created = await withMockReadline([
110
+ "",
111
+ "module",
112
+ "",
113
+ "",
114
+ "",
115
+ "",
116
+ "n",
117
+ "y",
118
+ ], () => runInitCommand([targetDir]));
119
+
120
+ assert.equal(created, targetDir);
121
+ assert.equal(fs.existsSync(path.join(targetDir, "package.json")), true);
122
+ assert.equal(fs.existsSync(path.join(targetDir, "src", "plugin.ts")), true);
123
+ assert.equal(fs.existsSync(path.join(targetDir, ".gitignore")), true);
124
+
125
+ cleanupTempDir(root);
126
+ });
127
+
128
+ test("runInitCommand spawns npm install when requested", async () => {
129
+ const root = makeTempDir("openvcs-sdk-test");
130
+ const targetDir = path.join(root, "fresh-plugin");
131
+
132
+ await withMockSpawnSync({ status: 0 }, async () => {
133
+ const created = await withMockReadline([
134
+ targetDir,
135
+ "module",
136
+ "fresh.plugin",
137
+ "Fresh Plugin",
138
+ "0.1.0",
139
+ "y",
140
+ "y",
141
+ ], () => runInitCommand([]));
142
+
143
+ assert.equal(created, path.resolve(targetDir));
144
+ assert.equal(fs.existsSync(path.join(targetDir, "package.json")), true);
145
+ });
146
+
147
+ cleanupTempDir(root);
148
+ });
149
+
28
150
  test("collectAnswers re-prompts invalid plugin id", async () => {
29
151
  const prompts = [
30
152
  "plugin-dir",
@@ -66,6 +188,67 @@ test("collectAnswers re-prompts invalid plugin id", async () => {
66
188
  assert.equal(messages.some((message) => message.includes("must not contain path separators")), true);
67
189
  });
68
190
 
191
+ test("collectAnswers handles theme mode, invalid kind, blank defaults, and boolean retries", async () => {
192
+ const prompts = [
193
+ "",
194
+ "bad-kind",
195
+ "t",
196
+ "",
197
+ "",
198
+ "",
199
+ ];
200
+ const booleans = [false, true];
201
+ const messages = [];
202
+ const promptDriver = {
203
+ async promptText(_label, defaultValue) {
204
+ const value = prompts.shift();
205
+ return value === "" ? defaultValue : value;
206
+ },
207
+ async promptBoolean() {
208
+ return booleans.shift();
209
+ },
210
+ close() {
211
+ messages.push("closed");
212
+ },
213
+ };
214
+ const output = { write(message) { messages.push(message); } };
215
+
216
+ const answers = await __private.collectAnswers(
217
+ { forceTheme: false, targetHint: "theme-dir" },
218
+ promptDriver,
219
+ output,
220
+ );
221
+
222
+ assert.equal(answers.kind, "theme");
223
+ assert.equal(answers.pluginId, "theme-dir");
224
+ assert.equal(answers.pluginName, "Theme Dir");
225
+ assert.equal(answers.pluginVersion, "0.1.0");
226
+ assert.equal(answers.defaultEnabled, false);
227
+ assert.equal(answers.runNpmInstall, true);
228
+ assert.equal(messages.some((message) => String(message).includes("Please choose")), true);
229
+ assert.equal(messages.includes("closed"), true);
230
+ });
231
+
232
+ test("collectAnswers skips kind prompt when theme is forced", async () => {
233
+ const prompts = ["forced-dir", "forced.theme", "Forced Theme", "1.0.0"];
234
+ const labels = [];
235
+ const promptDriver = {
236
+ async promptText(label) {
237
+ labels.push(label);
238
+ return prompts.shift();
239
+ },
240
+ async promptBoolean() {
241
+ return false;
242
+ },
243
+ close() {},
244
+ };
245
+
246
+ const answers = await __private.collectAnswers({ forceTheme: true }, promptDriver, { write() {} });
247
+
248
+ assert.equal(answers.kind, "theme");
249
+ assert.equal(labels.includes("Template type (module/theme)"), false);
250
+ });
251
+
69
252
  test("writeModuleTemplate scaffolds SDK runtime entrypoint", () => {
70
253
  const root = makeTempDir("openvcs-sdk-test");
71
254
  const targetDir = path.join(root, "plugin");
@@ -92,3 +275,28 @@ test("writeModuleTemplate scaffolds SDK runtime entrypoint", () => {
92
275
 
93
276
  cleanupTempDir(root);
94
277
  });
278
+
279
+ test("writeThemeTemplate scaffolds theme package without module runtime", () => {
280
+ const root = makeTempDir("openvcs-sdk-test");
281
+ const targetDir = path.join(root, "theme-plugin");
282
+
283
+ __private.writeThemeTemplate({
284
+ targetDir,
285
+ kind: "theme",
286
+ pluginId: "example.theme",
287
+ pluginName: "Example Theme",
288
+ pluginVersion: "0.3.0",
289
+ defaultEnabled: false,
290
+ runNpmInstall: false,
291
+ });
292
+
293
+ const packageJson = JSON.parse(fs.readFileSync(path.join(targetDir, "package.json"), "utf8"));
294
+ const themeJson = JSON.parse(fs.readFileSync(path.join(targetDir, "themes", "default", "theme.json"), "utf8"));
295
+
296
+ assert.equal(packageJson.openvcs.module, undefined);
297
+ assert.equal(packageJson.openvcs.default_enabled, false);
298
+ assert.equal(themeJson.name, "Example Theme");
299
+ assert.equal(themeJson.tokens.accent, "#2a7fff");
300
+
301
+ cleanupTempDir(root);
302
+ });
@@ -0,0 +1,147 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const {
5
+ addMenuItem,
6
+ createMenu,
7
+ getMenu,
8
+ hasRegisteredAction,
9
+ hideMenu,
10
+ invoke,
11
+ notify,
12
+ registerAction,
13
+ removeMenu,
14
+ resetMenuRegistry,
15
+ runRegisteredAction,
16
+ showMenu,
17
+ } = require("../lib/runtime/menu");
18
+ const { createRegisteredPluginRuntime } = require("../lib/runtime");
19
+ const { EventEmitter } = require("node:events");
20
+ const { parseFramedMessages, serializeFramedMessage } = require("../lib/runtime/transport");
21
+
22
+ function menuResult() {
23
+ const stdin = new EventEmitter();
24
+ const chunks = [];
25
+ const stdout = {
26
+ write(chunk) {
27
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
28
+ return true;
29
+ },
30
+ };
31
+ createRegisteredPluginRuntime({}).start({ stdin, stdout });
32
+ stdin.emit("data", serializeFramedMessage({ jsonrpc: "2.0", id: 1, method: "plugin.get_menus", params: {} }));
33
+ return new Promise((resolve) => setImmediate(() => resolve(parseFramedMessages(Buffer.concat(chunks)).messages[0].result)));
34
+ }
35
+
36
+ test("menu ordering supports before and after placement", async () => {
37
+ resetMenuRegistry();
38
+ createMenu("middle", "Middle", { surface: "menubar" });
39
+ createMenu("after", "After", { surface: "settings", after: "middle" });
40
+ createMenu("before", "Before", { surface: "menubar", before: "middle" });
41
+
42
+ const menus = await menuResult();
43
+
44
+ assert.deepEqual(menus.map((menu) => menu.id), ["before", "middle", "after"]);
45
+ assert.deepEqual(menus.map((menu) => menu.surface), ["menubar", "menubar", "settings"]);
46
+ });
47
+
48
+ test("menu ordering falls back when requested anchors are missing", async () => {
49
+ resetMenuRegistry();
50
+ createMenu("first", "First", { surface: "menubar", after: "missing" });
51
+ createMenu("second", "Second", { surface: "menubar", before: "missing" });
52
+ createMenu("first", "First Updated", { surface: "settings" });
53
+
54
+ const menus = await menuResult();
55
+
56
+ assert.deepEqual(menus.map((menu) => menu.id), ["second", "first"]);
57
+ assert.equal(menus[1].label, "First Updated");
58
+ assert.equal(menus[1].surface, "settings");
59
+ });
60
+
61
+ test("menu item insertion supports before anchors and ignores invalid items", async () => {
62
+ resetMenuRegistry();
63
+ const menu = createMenu("insert", "Insert", { surface: "menubar" });
64
+ menu.addItem({ label: "Last", action: "last" });
65
+ menu.addItem({ label: "First", action: "first", before: "last" });
66
+ menu.addItem({ label: "", action: "ignored" });
67
+ menu.addItem({ label: "Ignored", action: "" });
68
+
69
+ const menus = await menuResult();
70
+
71
+ assert.deepEqual(menus[0].elements, [
72
+ { type: "button", id: "first", label: "First" },
73
+ { type: "button", id: "last", label: "Last" },
74
+ ]);
75
+ });
76
+
77
+ test("menu handle can remove, hide, and show individual items", async () => {
78
+ resetMenuRegistry();
79
+ const menu = createMenu("tools", "Tools", { surface: "menubar" });
80
+ menu.addItem({ label: "A", action: "a" });
81
+ menu.addItem({ label: "B", action: "b" });
82
+ menu.hideItem("a");
83
+
84
+ let menus = await menuResult();
85
+ assert.deepEqual(menus[0].elements, [{ type: "button", id: "b", label: "B" }]);
86
+
87
+ menu.showItem("a");
88
+ menu.removeItem("b");
89
+ menus = await menuResult();
90
+
91
+ assert.deepEqual(menus[0].elements, [{ type: "button", id: "a", label: "A" }]);
92
+ });
93
+
94
+ test("registered actions report presence and ignore blank ids", async () => {
95
+ resetMenuRegistry();
96
+ registerAction(" save ", () => "saved");
97
+
98
+ assert.equal(hasRegisteredAction("save"), true);
99
+ assert.equal(hasRegisteredAction(""), false);
100
+ assert.equal(await runRegisteredAction("save"), "saved");
101
+ assert.equal(await runRegisteredAction("missing"), null);
102
+ assert.equal(await runRegisteredAction(""), null);
103
+ });
104
+
105
+ test("removeMenu deletes menu and order entry", async () => {
106
+ resetMenuRegistry();
107
+ createMenu("gone", "Gone", { surface: "menubar" });
108
+ createMenu("stay", "Stay", { surface: "menubar" });
109
+ removeMenu("gone");
110
+ hideMenu("missing");
111
+ showMenu("missing");
112
+
113
+ assert.equal(getMenu("gone"), null);
114
+ assert.deepEqual((await menuResult()).map((menu) => menu.id), ["stay"]);
115
+ });
116
+
117
+ test("invoke and notify use OpenVCS host helper when available", async () => {
118
+ const previous = globalThis.OpenVCS;
119
+ const calls = [];
120
+ globalThis.OpenVCS = {
121
+ async invoke(cmd, args) {
122
+ calls.push({ cmd, args });
123
+ return "ok";
124
+ },
125
+ notify(msg) {
126
+ calls.push({ msg });
127
+ },
128
+ };
129
+ try {
130
+ assert.equal(await invoke("repo.open", { path: "/tmp" }), "ok");
131
+ notify("done");
132
+ assert.deepEqual(calls, [{ cmd: "repo.open", args: { path: "/tmp" } }, { msg: "done" }]);
133
+ } finally {
134
+ globalThis.OpenVCS = previous;
135
+ }
136
+ });
137
+
138
+ test("invoke and notify fail when OpenVCS host helper is missing", async () => {
139
+ const previous = globalThis.OpenVCS;
140
+ delete globalThis.OpenVCS;
141
+ try {
142
+ await assert.rejects(() => invoke("missing"), /host is not available/);
143
+ assert.throws(() => notify("missing"), /host is not available/);
144
+ } finally {
145
+ globalThis.OpenVCS = previous;
146
+ }
147
+ });