@openvcs/sdk 0.4.0 → 0.4.1-beta.128

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.
@@ -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
+ });
@@ -53,6 +53,34 @@ class DerivedBranchDelegates extends SharedBranchDelegates {
53
53
  }
54
54
  }
55
55
 
56
+ class BinaryAwareDelegates extends VcsDelegateBase {
57
+ getStatusPayload() {
58
+ return {
59
+ files: [
60
+ {
61
+ path: 'img.png',
62
+ old_path: null,
63
+ status: 'M',
64
+ staged: false,
65
+ resolved_conflict: false,
66
+ hunks: [],
67
+ binary: true,
68
+ },
69
+ ],
70
+ ahead: 0,
71
+ behind: 0,
72
+ branch_on_remote: false,
73
+ };
74
+ }
75
+
76
+ diffFile() {
77
+ return {
78
+ lines: ['Binary files a/img.png and b/img.png differ'],
79
+ binary: true,
80
+ };
81
+ }
82
+ }
83
+
56
84
  test('VcsDelegateBase maps overridden camelCase methods to rpc delegates', async () => {
57
85
  const delegate = new ExampleVcsDelegates({ prefix: 'commit', calls: [] });
58
86
  const delegates = delegate.toDelegates();
@@ -110,6 +138,44 @@ test('VcsDelegateBase keeps inherited overrides when building delegates', async
110
138
  );
111
139
  });
112
140
 
141
+ test('VcsDelegateBase preserves binary metadata in status and diff payloads', async () => {
142
+ const delegates = new BinaryAwareDelegates({}).toDelegates();
143
+
144
+ assert.deepEqual(
145
+ await delegates['vcs.get_status_payload'](
146
+ { session_id: 'session-4' },
147
+ { host: {}, method: 'vcs.get_status_payload', requestId: 11 },
148
+ ),
149
+ {
150
+ files: [
151
+ {
152
+ path: 'img.png',
153
+ old_path: null,
154
+ status: 'M',
155
+ staged: false,
156
+ resolved_conflict: false,
157
+ hunks: [],
158
+ binary: true,
159
+ },
160
+ ],
161
+ ahead: 0,
162
+ behind: 0,
163
+ branch_on_remote: false,
164
+ },
165
+ );
166
+
167
+ assert.deepEqual(
168
+ await delegates['vcs.diff_file'](
169
+ { session_id: 'session-4', path: 'img.png' },
170
+ { host: {}, method: 'vcs.diff_file', requestId: 12 },
171
+ ),
172
+ {
173
+ lines: ['Binary files a/img.png and b/img.png differ'],
174
+ binary: true,
175
+ },
176
+ );
177
+ });
178
+
113
179
  test('VcsDelegateBase returns a fresh delegate map on each call', async () => {
114
180
  const delegate = new ExampleVcsDelegates({ prefix: 'repeat', calls: [] });
115
181
  const firstDelegates = delegate.toDelegates();
@@ -167,3 +233,24 @@ test('VcsDelegateBase accepts an empty deps object without errors', () => {
167
233
  const delegates = new SharedBranchDelegates({}).toDelegates();
168
234
  assert.deepEqual(Object.keys(delegates), ['vcs.get_current_branch']);
169
235
  });
236
+
237
+ test('VcsDelegateBase base implementation excludes all stubs from delegate map', () => {
238
+ class EmptyDelegates extends VcsDelegateBase {}
239
+
240
+ assert.deepEqual(new EmptyDelegates({}).toDelegates(), {});
241
+ });
242
+
243
+ test('all VcsDelegateBase stubs throw method-specific errors', () => {
244
+ class EmptyDelegates extends VcsDelegateBase {}
245
+ const delegate = new EmptyDelegates({});
246
+ const stubNames = Object.getOwnPropertyNames(VcsDelegateBase.prototype)
247
+ .filter((name) => !['constructor', 'toDelegates', 'assignDelegate', 'unimplemented'].includes(name));
248
+
249
+ assert.ok(stubNames.length > 30);
250
+ for (const name of stubNames) {
251
+ assert.throws(
252
+ () => delegate[name]({}, {}),
253
+ new RegExp(`VCS delegate method '${name}' must be overridden before registration`),
254
+ );
255
+ }
256
+ });