@openvcs/sdk 0.4.0 → 0.4.1-edge.20260530.101

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,74 @@
1
+ const assert = require("node:assert/strict");
2
+ const test = require("node:test");
3
+
4
+ const { ModalBuilder } = require("../lib/runtime");
5
+
6
+ test("ModalBuilder serializes all supported content item types", async () => {
7
+ const modal = new ModalBuilder(" Settings ")
8
+ .text("Hello", { title: "Greeting", align: "center" })
9
+ .separator()
10
+ .button(" save ", "Save", { title: "Persist", variant: "primary", align: "end", payload: { ok: true } })
11
+ .input(" name ", " Name ", { kind: "text", value: "OpenVCS", placeholder: "Name", required: true, align: "start" })
12
+ .select("theme", "Theme", { value: "dark", align: "center", options: [{ value: "dark", label: "Dark" }] })
13
+ .list("files", { label: "Files", emptyText: "None", align: "start", items: [{ id: "a", label: "A" }] })
14
+ .build();
15
+
16
+ assert.equal(modal.title, "Settings");
17
+ assert.deepEqual(modal.content.map((item) => item.type), [
18
+ "text",
19
+ "separator",
20
+ "button",
21
+ "input",
22
+ "select",
23
+ "list",
24
+ ]);
25
+ assert.deepEqual(modal.content[2].payload, { ok: true });
26
+ assert.equal(modal.content[3].required, true);
27
+ });
28
+
29
+ test("ModalBuilder omits optional fields when not provided", () => {
30
+ const modal = new ModalBuilder(" ")
31
+ .text("Plain")
32
+ .button(" save ", "Save")
33
+ .input(" name ", " Name ")
34
+ .select("theme", "Theme", { options: [] })
35
+ .list("files", { items: [] })
36
+ .horizontalBox([{ type: "text", content: "child" }])
37
+ .verticalBox([{ type: "text", content: "child" }])
38
+ .grid([{ type: "text", content: "child" }], { columns: " 1fr " })
39
+ .build();
40
+
41
+ assert.equal(modal.title, "");
42
+ assert.equal(modal.content[0].title, undefined);
43
+ assert.equal(modal.content[1].variant, undefined);
44
+ assert.equal(modal.content[2].kind, undefined);
45
+ assert.equal(modal.content[3].value, undefined);
46
+ assert.equal(modal.content[4].label, undefined);
47
+ assert.equal(modal.content[5].gap, undefined);
48
+ assert.equal(modal.content[6].gap, undefined);
49
+ assert.equal(modal.content[7].gap, undefined);
50
+ assert.equal(modal.content[7].columns, "1fr");
51
+ });
52
+
53
+ test("ModalBuilder clones nested container content before returning definitions", () => {
54
+ const nested = [{ type: "text", content: "inside" }];
55
+ const builder = new ModalBuilder("Nested")
56
+ .horizontalBox(nested, { gap: "4px", align: "center", wrap: true })
57
+ .verticalBox(nested, { gap: "8px" })
58
+ .grid(nested, { columns: "1fr 1fr", gap: "2px" });
59
+
60
+ const first = builder.build();
61
+ first.content[0].content[0].content = "mutated";
62
+ nested[0].content = "source mutated";
63
+ const second = builder.build();
64
+
65
+ assert.equal(second.content[0].content[0].content, "inside");
66
+ assert.equal(second.content[1].content[0].content, "inside");
67
+ assert.equal(second.content[2].columns, "1fr 1fr");
68
+ });
69
+
70
+ test("ModalBuilder.open returns the same payload shape as build", async () => {
71
+ const builder = new ModalBuilder("Open").text("Body");
72
+
73
+ assert.deepEqual(await builder.open(), builder.build());
74
+ });
@@ -0,0 +1,89 @@
1
+ const assert = require("node:assert/strict");
2
+ const path = require("node:path");
3
+ const test = require("node:test");
4
+
5
+ const { npmArgsPrefix, npmCommand, npmExecutable, resolveNpmCli } = require("../lib/npm-runner");
6
+
7
+ test("npmCommand uses npm directly on non-Windows platforms", () => {
8
+ const command = npmCommand("linux", "/usr/bin/node");
9
+
10
+ assert.deepEqual(command, { program: "npm", argsPrefix: [] });
11
+ });
12
+
13
+ test("npmExecutable and npmArgsPrefix expose the default npm command", () => {
14
+ if (process.platform === "win32") {
15
+ assert.equal(npmExecutable(), process.execPath);
16
+ assert.ok(npmArgsPrefix().length > 0);
17
+ } else {
18
+ assert.equal(npmExecutable(), "npm");
19
+ assert.deepEqual(npmArgsPrefix(), []);
20
+ }
21
+ });
22
+
23
+ test("npmCommand runs npm through node.exe on Windows without cmd.exe", () => {
24
+ const execPath = "C:\\Program Files\\nodejs\\node.exe";
25
+ const localCli = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js");
26
+
27
+ const command = npmCommand("win32", execPath, (candidate) => candidate === localCli);
28
+
29
+ assert.equal(command.program, execPath);
30
+ assert.deepEqual(command.argsPrefix, [localCli]);
31
+ assert.notEqual(command.program.toLowerCase(), "cmd.exe");
32
+ assert.notEqual(path.basename(command.argsPrefix[0]).toLowerCase(), "npm.cmd");
33
+ });
34
+
35
+ test("resolveNpmCli falls back to require.resolve when local npm cli is unavailable", () => {
36
+ const resolved = resolveNpmCli(
37
+ "C:\\Program Files\\nodejs\\node.exe",
38
+ () => false,
39
+ (specifier) => `resolved:${specifier}`,
40
+ );
41
+
42
+ assert.equal(resolved, "resolved:npm/bin/npm-cli.js");
43
+ });
44
+
45
+ test("resolveNpmCli prefers npm cli beside node.exe before require.resolve", () => {
46
+ const execPath = "C:\\Tools With Spaces\\nodejs\\node.exe";
47
+ const localCli = path.join(path.dirname(execPath), "node_modules", "npm", "bin", "npm-cli.js");
48
+ let fallbackCalled = false;
49
+
50
+ const resolved = resolveNpmCli(
51
+ execPath,
52
+ (candidate) => candidate === localCli,
53
+ () => {
54
+ fallbackCalled = true;
55
+ return "fallback";
56
+ },
57
+ );
58
+
59
+ assert.equal(resolved, localCli);
60
+ assert.equal(fallbackCalled, false);
61
+ });
62
+
63
+ test("npmCommand keeps Windows paths unquoted because spawn receives argv array", () => {
64
+ const execPath = "C:\\Program Files\\nodejs\\node.exe";
65
+ const cliPath = "C:\\Program Files\\nodejs\\node_modules\\npm\\bin\\npm-cli.js";
66
+ const command = npmCommand("win32", execPath, () => false, () => cliPath);
67
+
68
+ assert.equal(command.program, execPath);
69
+ assert.equal(command.argsPrefix[0], cliPath);
70
+ assert.equal(command.program.startsWith('"'), false);
71
+ assert.equal(command.argsPrefix[0].startsWith('"'), false);
72
+ });
73
+
74
+ test("npmCommand returns a fresh argsPrefix array per Windows call", () => {
75
+ const execPath = "C:\\nodejs\\node.exe";
76
+ const cliPath = "C:\\nodejs\\node_modules\\npm\\bin\\npm-cli.js";
77
+ const first = npmCommand("win32", execPath, () => false, () => cliPath);
78
+ const second = npmCommand("win32", execPath, () => false, () => cliPath);
79
+
80
+ first.argsPrefix.push("mutated");
81
+
82
+ assert.deepEqual(second.argsPrefix, [cliPath]);
83
+ });
84
+
85
+ test("SDK build module no longer exposes shell-wrapper helper", () => {
86
+ const buildModule = require("../lib/build");
87
+
88
+ assert.equal(Object.hasOwn(buildModule, "shouldUseWindowsShell"), false);
89
+ });
@@ -5,6 +5,8 @@ const test = require("node:test");
5
5
  const {
6
6
  bootstrapPluginModule,
7
7
  createRegisteredPluginRuntime,
8
+ createPluginRuntime,
9
+ startPluginRuntime,
8
10
  resetMenuRegistry,
9
11
  } = require("../lib/runtime");
10
12
  const {
@@ -134,6 +136,110 @@ test("createPluginRuntime reports missing methods as plugin failures", async ()
134
136
  ]);
135
137
  });
136
138
 
139
+ test("createPluginRuntime ignores chunks before start and duplicate starts", async () => {
140
+ const stdin = new EventEmitter();
141
+ const chunks = [];
142
+ const stdout = {
143
+ write(chunk) {
144
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
145
+ return true;
146
+ },
147
+ };
148
+ const runtime = createRegisteredPluginRuntime({});
149
+
150
+ runtime.consumeChunk(serializeFramedMessage({ jsonrpc: "2.0", id: 40, method: "plugin.initialize", params: {} }));
151
+ runtime.start({ stdin, stdout });
152
+ runtime.start({ stdin, stdout });
153
+ stdin.emit("data", serializeFramedMessage({ jsonrpc: "2.0", id: 41, method: "plugin.initialize", params: {} }));
154
+ await new Promise((resolve) => setImmediate(resolve));
155
+
156
+ const parsed = parseFramedMessages(Buffer.concat(chunks));
157
+ assert.deepEqual(parsed.messages.map((message) => message.id), [41]);
158
+ });
159
+
160
+ test("createPluginRuntime logs invalid requests instead of responding", async () => {
161
+ const harness = createRuntimeHarness({});
162
+
163
+ const messages = await harness.request({ jsonrpc: "2.0", id: null, method: " ", params: [] });
164
+
165
+ assert.deepEqual(messages, [{
166
+ jsonrpc: "2.0",
167
+ method: "host.log",
168
+ params: {
169
+ level: "error",
170
+ target: "openvcs.plugin",
171
+ message: "invalid request: missing method, invalid id type: object",
172
+ },
173
+ }]);
174
+ });
175
+
176
+ test("createPluginRuntime stop is idempotent and invokes shutdown callback", async () => {
177
+ const stdin = new EventEmitter();
178
+ const stdout = { write() { return true; } };
179
+ let shutdowns = 0;
180
+ const runtime = createRegisteredPluginRuntime({ onShutdown() { shutdowns += 1; } });
181
+
182
+ runtime.stop();
183
+ runtime.start({ stdin, stdout });
184
+ runtime.stop();
185
+ runtime.stop();
186
+ await new Promise((resolve) => setImmediate(resolve));
187
+
188
+ assert.equal(shutdowns, 1);
189
+ });
190
+
191
+ test("startPluginRuntime uses the runtime start method", () => {
192
+ const runtime = createPluginRuntime();
193
+ let started = 0;
194
+ const originalStart = runtime.start.bind(runtime);
195
+ runtime.start = (transport) => {
196
+ started += 1;
197
+ return originalStart(transport);
198
+ };
199
+
200
+ startPluginRuntime(runtime, {
201
+ stdin: new EventEmitter(),
202
+ stdout: { write() { return true; } },
203
+ });
204
+
205
+ assert.equal(started, 1);
206
+ runtime.stop();
207
+ });
208
+
209
+ test("runtime root exports and type constants are live bindings", () => {
210
+ const runtimeRoot = require("../lib/runtime");
211
+ const typesRoot = require("../lib/types");
212
+
213
+ assert.equal(runtimeRoot.createPluginRuntime, createPluginRuntime);
214
+ assert.equal(runtimeRoot.startPluginRuntime, startPluginRuntime);
215
+ assert.equal(typeof runtimeRoot.createDefaultPluginDelegates, "function");
216
+ assert.equal(typeof runtimeRoot.createRuntimeDispatcher, "function");
217
+ assert.equal(typeof runtimeRoot.isPluginFailure, "function");
218
+ assert.equal(typeof runtimeRoot.pluginError, "function");
219
+ assert.equal(typeof runtimeRoot.createHost, "function");
220
+ assert.equal(typeof runtimeRoot.ModalBuilder, "function");
221
+ assert.equal(typeof runtimeRoot.bootstrapPluginModule, "function");
222
+ assert.equal(typeof runtimeRoot.createRegisteredPluginRuntime, "function");
223
+ assert.equal(typeof runtimeRoot.VcsDelegateBase, "function");
224
+ assert.equal(typeof runtimeRoot.getMenu, "function");
225
+ assert.equal(typeof runtimeRoot.getOrCreateMenu, "function");
226
+ assert.equal(typeof runtimeRoot.createMenu, "function");
227
+ assert.equal(typeof runtimeRoot.addMenuItem, "function");
228
+ assert.equal(typeof runtimeRoot.addMenuSeparator, "function");
229
+ assert.equal(typeof runtimeRoot.removeMenu, "function");
230
+ assert.equal(typeof runtimeRoot.hideMenu, "function");
231
+ assert.equal(typeof runtimeRoot.showMenu, "function");
232
+ assert.equal(typeof runtimeRoot.registerAction, "function");
233
+ assert.equal(typeof runtimeRoot.resetMenuRegistry, "function");
234
+ assert.equal(typeof runtimeRoot.invoke, "function");
235
+ assert.equal(typeof runtimeRoot.notify, "function");
236
+
237
+ assert.equal(typesRoot.PROTOCOL_VERSION, 1);
238
+ assert.equal(typesRoot.PLUGIN_FAILURE_CODE, -32001);
239
+ assert.equal(typesRoot.PLUGIN_INTERNAL_ERROR_CODE, -32002);
240
+ assert.equal(typesRoot.PROTOCOL_VERSION_MISMATCH_CODE, -32003);
241
+ });
242
+
137
243
  test("bootstrapPluginModule runs OnPluginStart before starting runtime", async () => {
138
244
  const stdin = new EventEmitter();
139
245
  const chunks = [];
@@ -523,6 +629,43 @@ test("plugin.handle_action logs unhandled actions without explicit handler", asy
523
629
  ]);
524
630
  });
525
631
 
632
+ test("registered runtime merges explicit and generated menus", async () => {
633
+ resetMenuRegistry();
634
+ createMenu("generated", "Generated", { surface: "menubar" });
635
+ addMenuItem("generated", { label: "Generated Item", action: "generated-action" });
636
+ const harness = createRuntimeHarness({
637
+ plugin: {
638
+ async "plugin.get_menus"() {
639
+ return [{ id: "explicit", label: "Explicit", surface: "settings", order: 1, elements: [] }];
640
+ },
641
+ },
642
+ });
643
+
644
+ const messages = await harness.request({ jsonrpc: "2.0", id: 50, method: "plugin.get_menus", params: {} });
645
+
646
+ assert.deepEqual(messages[0].result.map((menu) => menu.id), ["explicit", "generated"]);
647
+ });
648
+
649
+ test("registered runtime falls back to explicit action handler when action is unregistered", async () => {
650
+ resetMenuRegistry();
651
+ const harness = createRuntimeHarness({
652
+ plugin: {
653
+ async "plugin.handle_action"(params) {
654
+ return `explicit:${params.action_id}`;
655
+ },
656
+ },
657
+ });
658
+
659
+ const messages = await harness.request({
660
+ jsonrpc: "2.0",
661
+ id: 51,
662
+ method: "plugin.handle_action",
663
+ params: { action_id: "not-registered" },
664
+ });
665
+
666
+ assert.deepEqual(messages, [{ jsonrpc: "2.0", id: 51, result: "explicit:not-registered" }]);
667
+ });
668
+
526
669
  test("bootstrapPluginModule resets menu registry before OnPluginStart", async () => {
527
670
  resetMenuRegistry();
528
671
  // Pre-populate state that should be cleared.
@@ -0,0 +1,71 @@
1
+ const assert = require("node:assert/strict");
2
+ const { EventEmitter } = require("node:events");
3
+ const test = require("node:test");
4
+
5
+ const { parseFramedMessages, serializeFramedMessage, writeFramedMessage } = require("../lib/runtime/transport");
6
+
7
+ test("parseFramedMessages keeps incomplete frames as remainder", () => {
8
+ const frame = serializeFramedMessage({ jsonrpc: "2.0", id: 1, method: "plugin.init", params: {} });
9
+ const partial = frame.subarray(0, frame.length - 3);
10
+
11
+ const parsed = parseFramedMessages(partial);
12
+
13
+ assert.deepEqual(parsed.messages, []);
14
+ assert.deepEqual(parsed.remainder, partial);
15
+ });
16
+
17
+ test("parseFramedMessages skips invalid JSON payloads and continues at next frame", () => {
18
+ const invalidPayload = Buffer.from("Content-Length: 4\r\n\r\noops", "utf8");
19
+ const valid = serializeFramedMessage({ jsonrpc: "2.0", id: 2, method: "plugin.deinit", params: {} });
20
+
21
+ const parsed = parseFramedMessages(Buffer.concat([invalidPayload, valid]));
22
+
23
+ assert.equal(parsed.messages.length, 1);
24
+ assert.equal(parsed.messages[0].id, 2);
25
+ assert.equal(parsed.remainder.length, 0);
26
+ });
27
+
28
+ test("parseFramedMessages drops malformed header blocks", () => {
29
+ const malformed = Buffer.from("Bogus: 1\r\n\r\n", "utf8");
30
+ const valid = serializeFramedMessage({ jsonrpc: "2.0", id: 3, method: "plugin.deinit", params: {} });
31
+ const parsed = parseFramedMessages(Buffer.concat([malformed, valid]));
32
+
33
+ assert.equal(parsed.messages.length, 1);
34
+ assert.equal(parsed.messages[0].id, 3);
35
+ assert.equal(parsed.remainder.length, 0);
36
+ });
37
+
38
+ test("parseFramedMessages leaves garbage payload bytes after invalid content lengths", () => {
39
+ const badNegative = Buffer.from("Content-Length: -1\r\n\r\n{}", "utf8");
40
+ const parsed = parseFramedMessages(badNegative);
41
+
42
+ assert.deepEqual(parsed.messages, []);
43
+ assert.deepEqual(parsed.remainder, Buffer.from("{}", "utf8"));
44
+ });
45
+
46
+ test("writeFramedMessage waits for drain when stream backpressures", async () => {
47
+ const writer = new EventEmitter();
48
+ let wrote = false;
49
+ writer.write = (chunk) => {
50
+ wrote = Buffer.isBuffer(chunk);
51
+ setImmediate(() => writer.emit("drain"));
52
+ return false;
53
+ };
54
+
55
+ await writeFramedMessage(writer, { ok: true });
56
+
57
+ assert.equal(wrote, true);
58
+ });
59
+
60
+ test("writeFramedMessage reports writer failures without throwing", async () => {
61
+ const originalError = console.error;
62
+ const messages = [];
63
+ console.error = (message) => messages.push(String(message));
64
+ try {
65
+ await writeFramedMessage({ write() { throw new Error("closed"); } }, { ok: true });
66
+ } finally {
67
+ console.error = originalError;
68
+ }
69
+
70
+ assert.match(messages[0], /failed to write framed message: closed/);
71
+ });
@@ -167,3 +167,24 @@ test('VcsDelegateBase accepts an empty deps object without errors', () => {
167
167
  const delegates = new SharedBranchDelegates({}).toDelegates();
168
168
  assert.deepEqual(Object.keys(delegates), ['vcs.get_current_branch']);
169
169
  });
170
+
171
+ test('VcsDelegateBase base implementation excludes all stubs from delegate map', () => {
172
+ class EmptyDelegates extends VcsDelegateBase {}
173
+
174
+ assert.deepEqual(new EmptyDelegates({}).toDelegates(), {});
175
+ });
176
+
177
+ test('all VcsDelegateBase stubs throw method-specific errors', () => {
178
+ class EmptyDelegates extends VcsDelegateBase {}
179
+ const delegate = new EmptyDelegates({});
180
+ const stubNames = Object.getOwnPropertyNames(VcsDelegateBase.prototype)
181
+ .filter((name) => !['constructor', 'toDelegates', 'assignDelegate', 'unimplemented'].includes(name));
182
+
183
+ assert.ok(stubNames.length > 30);
184
+ for (const name of stubNames) {
185
+ assert.throws(
186
+ () => delegate[name]({}, {}),
187
+ new RegExp(`VCS delegate method '${name}' must be overridden before registration`),
188
+ );
189
+ }
190
+ });