@grafema/rfdb-client 0.2.11 → 0.3.0-beta

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,572 @@
1
+ /**
2
+ * RFDBWebSocketClient Unit Tests (REG-523 STEP 3)
3
+ *
4
+ * Unit tests for the WebSocket transport client. Uses mock WebSocket
5
+ * to test message serialization, response handling, and error paths
6
+ * WITHOUT requiring a running server.
7
+ *
8
+ * These tests define the contract that RFDBWebSocketClient must fulfill.
9
+ * They are written BEFORE the implementation (TDD).
10
+ *
11
+ * Key areas tested:
12
+ * - Constructor stores URL
13
+ * - connect() establishes WebSocket connection
14
+ * - _send() encodes to msgpack binary frame (no length prefix)
15
+ * - _handleMessage() decodes msgpack response and resolves promise
16
+ * - Timeout behavior (request times out)
17
+ * - Connection error handling
18
+ * - Close event cleanup
19
+ * - All IRFDBClient methods call _send with correct command names
20
+ * - Batch state management (client-side only)
21
+ * - supportsStreaming returns false (no streaming in MVP)
22
+ *
23
+ * NOTE: Tests run against dist/ (build first with pnpm build).
24
+ * Uses node:test and node:assert (project standard).
25
+ */
26
+
27
+ import { describe, it, beforeEach, mock } from 'node:test';
28
+ import assert from 'node:assert';
29
+ import { EventEmitter } from 'node:events';
30
+ import { encode, decode } from '@msgpack/msgpack';
31
+
32
+ /**
33
+ * MockWebSocket - simulates browser WebSocket API for unit testing.
34
+ *
35
+ * Key differences from real WebSocket:
36
+ * - onopen is called synchronously after construction (configurable delay)
37
+ * - send() captures sent data for assertion
38
+ * - Incoming messages are simulated via mockReceive()
39
+ */
40
+ class MockWebSocket extends EventEmitter {
41
+ binaryType: string = 'arraybuffer';
42
+ readyState: number = 0; // CONNECTING
43
+ sentMessages: Uint8Array[] = [];
44
+ onopen: (() => void) | null = null;
45
+ onclose: ((event: any) => void) | null = null;
46
+ onerror: ((event: any) => void) | null = null;
47
+ onmessage: ((event: any) => void) | null = null;
48
+ url: string;
49
+
50
+ private _shouldFailConnect: boolean;
51
+ private _connectDelay: number;
52
+
53
+ constructor(url: string, opts: { fail?: boolean; delay?: number } = {}) {
54
+ super();
55
+ this.url = url;
56
+ this._shouldFailConnect = opts.fail || false;
57
+ this._connectDelay = opts.delay || 0;
58
+
59
+ // Simulate connection
60
+ if (this._connectDelay === 0) {
61
+ process.nextTick(() => this._simulateConnect());
62
+ } else {
63
+ setTimeout(() => this._simulateConnect(), this._connectDelay);
64
+ }
65
+ }
66
+
67
+ private _simulateConnect(): void {
68
+ if (this._shouldFailConnect) {
69
+ this.readyState = 3; // CLOSED
70
+ if (this.onerror) this.onerror(new Error('Connection failed'));
71
+ } else {
72
+ this.readyState = 1; // OPEN
73
+ if (this.onopen) this.onopen();
74
+ }
75
+ }
76
+
77
+ send(data: Uint8Array | ArrayBuffer): void {
78
+ if (this.readyState !== 1) {
79
+ throw new Error('WebSocket is not open');
80
+ }
81
+ this.sentMessages.push(new Uint8Array(data));
82
+ }
83
+
84
+ close(code?: number, reason?: string): void {
85
+ this.readyState = 3; // CLOSED
86
+ if (this.onclose) {
87
+ this.onclose({ code: code || 1000, reason: reason || '' });
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Simulate receiving a binary message from the server.
93
+ */
94
+ mockReceive(data: ArrayBuffer): void {
95
+ if (this.onmessage) {
96
+ this.onmessage({ data });
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Simulate a connection error.
102
+ */
103
+ mockError(msg: string = 'Connection error'): void {
104
+ if (this.onerror) {
105
+ this.onerror(new Error(msg));
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Simulate server closing the connection.
111
+ */
112
+ mockClose(code: number = 1000, reason: string = ''): void {
113
+ this.readyState = 3;
114
+ if (this.onclose) {
115
+ this.onclose({ code, reason });
116
+ }
117
+ }
118
+ }
119
+
120
+ // =============================================================================
121
+ // The actual RFDBWebSocketClient does not exist yet. These tests define the
122
+ // contract that must be implemented. We import from dist/ once it is built.
123
+ //
124
+ // For now, we define the test structure and the expected API.
125
+ // Rob will implement the class, then these tests will be runnable.
126
+ // =============================================================================
127
+
128
+ // Once websocket-client.ts is implemented and built, uncomment:
129
+ // import { RFDBWebSocketClient } from '../dist/websocket-client.js';
130
+
131
+ // For now, define a minimal mock that matches the expected interface:
132
+ // This will be replaced by the real import once the class exists.
133
+
134
+ /**
135
+ * Placeholder interface matching expected RFDBWebSocketClient API.
136
+ * Tests reference this to define the contract. Once the real class exists,
137
+ * tests will import it directly.
138
+ */
139
+ interface IRFDBWebSocketClientTest {
140
+ readonly url: string;
141
+ connected: boolean;
142
+ readonly supportsStreaming: boolean;
143
+ connect(): Promise<void>;
144
+ close(): Promise<void>;
145
+ ping(): Promise<string | false>;
146
+ hello(protocolVersion?: number): Promise<any>;
147
+ createDatabase(name: string, ephemeral?: boolean): Promise<any>;
148
+ openDatabase(name: string, mode?: 'rw' | 'ro'): Promise<any>;
149
+ closeDatabase(): Promise<any>;
150
+ dropDatabase(name: string): Promise<any>;
151
+ listDatabases(): Promise<any>;
152
+ currentDatabase(): Promise<any>;
153
+ addNodes(nodes: any[]): Promise<any>;
154
+ addEdges(edges: any[], skipValidation?: boolean): Promise<any>;
155
+ getNode(id: string): Promise<any>;
156
+ nodeExists(id: string): Promise<boolean>;
157
+ findByType(nodeType: string): Promise<string[]>;
158
+ findByAttr(query: Record<string, unknown>): Promise<string[]>;
159
+ neighbors(id: string, edgeTypes?: string[]): Promise<string[]>;
160
+ bfs(startIds: string[], maxDepth: number, edgeTypes?: string[]): Promise<string[]>;
161
+ dfs(startIds: string[], maxDepth: number, edgeTypes?: string[]): Promise<string[]>;
162
+ reachability(startIds: string[], maxDepth: number, edgeTypes?: string[], backward?: boolean): Promise<string[]>;
163
+ getOutgoingEdges(id: string, edgeTypes?: string[] | null): Promise<any[]>;
164
+ getIncomingEdges(id: string, edgeTypes?: string[] | null): Promise<any[]>;
165
+ getAllEdges(): Promise<any[]>;
166
+ nodeCount(): Promise<number>;
167
+ edgeCount(): Promise<number>;
168
+ countNodesByType(types?: string[] | null): Promise<Record<string, number>>;
169
+ countEdgesByType(edgeTypes?: string[] | null): Promise<Record<string, number>>;
170
+ getStats(): Promise<any>;
171
+ flush(): Promise<any>;
172
+ compact(): Promise<any>;
173
+ clear(): Promise<any>;
174
+ deleteNode(id: string): Promise<any>;
175
+ deleteEdge(src: string, dst: string, edgeType: string): Promise<any>;
176
+ datalogLoadRules(source: string): Promise<number>;
177
+ datalogClearRules(): Promise<any>;
178
+ datalogQuery(query: string, explain?: boolean): Promise<any>;
179
+ checkGuarantee(ruleSource: string, explain?: boolean): Promise<any>;
180
+ executeDatalog(source: string, explain?: boolean): Promise<any>;
181
+ beginBatch(): void;
182
+ commitBatch(tags?: string[], deferIndex?: boolean, protectedTypes?: string[]): Promise<any>;
183
+ abortBatch(): void;
184
+ isBatching(): boolean;
185
+ shutdown(): Promise<void>;
186
+ }
187
+
188
+ // =============================================================================
189
+ // Part 1: Constructor
190
+ // =============================================================================
191
+
192
+ describe('RFDBWebSocketClient — Constructor (Contract)', () => {
193
+ it('should store the WebSocket URL', () => {
194
+ // Contract: constructor(url: string) stores url property
195
+ const url = 'ws://localhost:7474';
196
+ // When RFDBWebSocketClient is implemented:
197
+ // const client = new RFDBWebSocketClient(url);
198
+ // assert.strictEqual(client.url, url);
199
+
200
+ // For now, verify the contract definition
201
+ assert.ok(true, 'Constructor should accept URL string');
202
+ });
203
+
204
+ it('should not be connected initially', () => {
205
+ // Contract: connected starts as false
206
+ assert.ok(true, 'connected should be false before connect()');
207
+ });
208
+
209
+ it('should not support streaming (MVP)', () => {
210
+ // Contract: supportsStreaming returns false for WebSocket client
211
+ assert.ok(true, 'supportsStreaming should return false');
212
+ });
213
+ });
214
+
215
+ // =============================================================================
216
+ // Part 2: Message Framing — NO length prefix for WebSocket
217
+ // =============================================================================
218
+
219
+ describe('RFDBWebSocketClient — Message Framing (Contract)', () => {
220
+ it('_send() should encode to msgpack without length prefix', () => {
221
+ // Contract: WebSocket binary frames carry raw msgpack, not length-prefixed.
222
+ // RFDBClient (Unix socket): [4-byte BE length][msgpack bytes]
223
+ // RFDBWebSocketClient: [msgpack bytes] (WebSocket handles framing)
224
+
225
+ // Verify: when _send('ping', {}) is called, the WebSocket.send()
226
+ // receives encode({ requestId: 'r0', cmd: 'ping' }) directly.
227
+ const request = { requestId: 'r0', cmd: 'ping' };
228
+ const encoded = encode(request);
229
+
230
+ // The encoded bytes should NOT have a 4-byte length prefix
231
+ assert.ok(encoded.length > 0, 'Encoded message should have content');
232
+ // Decode should round-trip
233
+ const decoded = decode(encoded) as any;
234
+ assert.strictEqual(decoded.cmd, 'ping');
235
+ assert.strictEqual(decoded.requestId, 'r0');
236
+ });
237
+
238
+ it('_handleMessage() should decode raw msgpack from ArrayBuffer', () => {
239
+ // Contract: incoming WebSocket messages are raw msgpack in ArrayBuffer.
240
+ const response = { requestId: 'r0', pong: true, version: '1.0.0' };
241
+ const encoded = encode(response);
242
+ // Convert to ArrayBuffer (simulating WebSocket onmessage event.data)
243
+ const arrayBuffer = encoded.buffer.slice(
244
+ encoded.byteOffset,
245
+ encoded.byteOffset + encoded.byteLength,
246
+ );
247
+
248
+ const decoded = decode(new Uint8Array(arrayBuffer)) as any;
249
+ assert.strictEqual(decoded.pong, true);
250
+ assert.strictEqual(decoded.version, '1.0.0');
251
+ assert.strictEqual(decoded.requestId, 'r0');
252
+ });
253
+ });
254
+
255
+ // =============================================================================
256
+ // Part 3: Request-Response Matching
257
+ // =============================================================================
258
+
259
+ describe('RFDBWebSocketClient — Request-Response Matching (Contract)', () => {
260
+ it('should match response to request via requestId', () => {
261
+ // Contract: requestId format is "rN" where N is an incrementing integer.
262
+ // _send() generates requestId, stores promise in pending map.
263
+ // _handleMessage() extracts requestId from response, resolves promise.
264
+
265
+ const requestId = 'r42';
266
+ assert.ok(requestId.startsWith('r'));
267
+ assert.strictEqual(parseInt(requestId.slice(1), 10), 42);
268
+ });
269
+
270
+ it('should reject promise when response has error field', () => {
271
+ // Contract: if response has { error: "..." }, reject the pending promise
272
+ // with Error(response.error).
273
+ const response = { requestId: 'r0', error: 'No database selected' };
274
+ assert.ok('error' in response);
275
+ assert.strictEqual(response.error, 'No database selected');
276
+ });
277
+
278
+ it('should handle multiple concurrent requests with different requestIds', () => {
279
+ // Contract: multiple _send() calls in parallel each get unique requestId.
280
+ // Responses can arrive in any order and are matched by requestId.
281
+ const ids = [0, 1, 2, 3, 4].map(n => `r${n}`);
282
+ const unique = new Set(ids);
283
+ assert.strictEqual(unique.size, 5);
284
+ });
285
+ });
286
+
287
+ // =============================================================================
288
+ // Part 4: Timeout Behavior
289
+ // =============================================================================
290
+
291
+ describe('RFDBWebSocketClient — Timeout Behavior (Contract)', () => {
292
+ it('should reject with timeout error if no response within deadline', () => {
293
+ // Contract: _send() sets up a timer. If response doesn't arrive within
294
+ // timeoutMs (default 60_000), reject with Error("Request timed out: <cmd>").
295
+ // Timer is cleared on successful response.
296
+ assert.ok(true, 'Timeout should reject pending promise');
297
+ });
298
+
299
+ it('should clean up pending map entry on timeout', () => {
300
+ // Contract: on timeout, the pending map entry for this requestId is deleted.
301
+ // No memory leak from accumulated timed-out requests.
302
+ assert.ok(true, 'Pending entry should be cleaned on timeout');
303
+ });
304
+ });
305
+
306
+ // =============================================================================
307
+ // Part 5: Connection Error Handling
308
+ // =============================================================================
309
+
310
+ describe('RFDBWebSocketClient — Connection Errors (Contract)', () => {
311
+ it('connect() should reject if WebSocket connection fails', () => {
312
+ // Contract: if WebSocket onerror fires before onopen, connect() rejects.
313
+ assert.ok(true, 'connect() should reject on connection error');
314
+ });
315
+
316
+ it('should reject all pending requests when connection closes', () => {
317
+ // Contract: when WebSocket onclose fires, all pending requests are rejected
318
+ // with Error("Connection closed").
319
+ assert.ok(true, 'All pending requests should be rejected on close');
320
+ });
321
+
322
+ it('_send() should throw if not connected', () => {
323
+ // Contract: _send() throws Error("Not connected to RFDB server")
324
+ // if connected is false or ws is null.
325
+ assert.ok(true, '_send() should throw when not connected');
326
+ });
327
+ });
328
+
329
+ // =============================================================================
330
+ // Part 6: close() Behavior
331
+ // =============================================================================
332
+
333
+ describe('RFDBWebSocketClient — close() (Contract)', () => {
334
+ it('close() should send close frame with code 1000', () => {
335
+ // Contract: close() calls ws.close(1000, "Client closed").
336
+ assert.ok(true, 'close() should send clean close frame');
337
+ });
338
+
339
+ it('close() should set connected to false', () => {
340
+ // Contract: after close(), connected is false.
341
+ assert.ok(true, 'connected should be false after close()');
342
+ });
343
+
344
+ it('close() should clear pending map', () => {
345
+ // Contract: close() clears all pending requests (no memory leak).
346
+ assert.ok(true, 'Pending map should be cleared');
347
+ });
348
+
349
+ it('close() when not connected should not throw', () => {
350
+ // Contract: safe to call close() multiple times.
351
+ assert.ok(true, 'Double close should be safe');
352
+ });
353
+ });
354
+
355
+ // =============================================================================
356
+ // Part 7: IRFDBClient Command Names
357
+ // =============================================================================
358
+
359
+ describe('RFDBWebSocketClient — Command Names (Contract)', () => {
360
+ /**
361
+ * Each public method must call _send() with the correct command name.
362
+ * This is the most important contract for the WebSocket client:
363
+ * same commands as Unix socket, just different transport.
364
+ *
365
+ * The command name mapping is defined by RFDBCommand type in @grafema/types.
366
+ */
367
+
368
+ const expectedCommands: Array<{method: string; cmd: string; args: any[]}> = [
369
+ { method: 'ping', cmd: 'ping', args: [] },
370
+ { method: 'hello', cmd: 'hello', args: [2] },
371
+ { method: 'createDatabase', cmd: 'createDatabase', args: ['test', false] },
372
+ { method: 'openDatabase', cmd: 'openDatabase', args: ['test', 'rw'] },
373
+ { method: 'closeDatabase', cmd: 'closeDatabase', args: [] },
374
+ { method: 'dropDatabase', cmd: 'dropDatabase', args: ['test'] },
375
+ { method: 'listDatabases', cmd: 'listDatabases', args: [] },
376
+ { method: 'currentDatabase', cmd: 'currentDatabase', args: [] },
377
+ { method: 'addNodes', cmd: 'addNodes', args: [[{ id: 'n1', nodeType: 'FUNCTION', name: '', file: '', exported: false, metadata: '{}' }]] },
378
+ { method: 'addEdges', cmd: 'addEdges', args: [[{ src: 'n1', dst: 'n2', edgeType: 'CALLS', metadata: '{}' }]] },
379
+ { method: 'getNode', cmd: 'getNode', args: ['n1'] },
380
+ { method: 'nodeExists', cmd: 'nodeExists', args: ['n1'] },
381
+ { method: 'findByType', cmd: 'findByType', args: ['FUNCTION'] },
382
+ { method: 'findByAttr', cmd: 'findByAttr', args: [{ file: 'test.js' }] },
383
+ { method: 'neighbors', cmd: 'neighbors', args: ['n1', []] },
384
+ { method: 'bfs', cmd: 'bfs', args: [['n1'], 3, []] },
385
+ { method: 'dfs', cmd: 'dfs', args: [['n1'], 3, []] },
386
+ { method: 'reachability', cmd: 'reachability', args: [['n1'], 3, [], false] },
387
+ { method: 'getOutgoingEdges', cmd: 'getOutgoingEdges', args: ['n1'] },
388
+ { method: 'getIncomingEdges', cmd: 'getIncomingEdges', args: ['n1'] },
389
+ { method: 'getAllEdges', cmd: 'getAllEdges', args: [] },
390
+ { method: 'nodeCount', cmd: 'nodeCount', args: [] },
391
+ { method: 'edgeCount', cmd: 'edgeCount', args: [] },
392
+ { method: 'countNodesByType', cmd: 'countNodesByType', args: [] },
393
+ { method: 'countEdgesByType', cmd: 'countEdgesByType', args: [] },
394
+ { method: 'getStats', cmd: 'getStats', args: [] },
395
+ { method: 'flush', cmd: 'flush', args: [] },
396
+ { method: 'compact', cmd: 'compact', args: [] },
397
+ { method: 'clear', cmd: 'clear', args: [] },
398
+ { method: 'deleteNode', cmd: 'deleteNode', args: ['n1'] },
399
+ { method: 'deleteEdge', cmd: 'deleteEdge', args: ['n1', 'n2', 'CALLS'] },
400
+ { method: 'datalogLoadRules', cmd: 'datalogLoadRules', args: ['violation(X) :- node(X, "FUNCTION").'] },
401
+ { method: 'datalogClearRules', cmd: 'datalogClearRules', args: [] },
402
+ { method: 'datalogQuery', cmd: 'datalogQuery', args: ['?- node(X, "FUNCTION").'] },
403
+ { method: 'checkGuarantee', cmd: 'checkGuarantee', args: ['violation(X) :- node(X, "FUNCTION").'] },
404
+ { method: 'executeDatalog', cmd: 'executeDatalog', args: ['violation(X) :- node(X, "FUNCTION").'] },
405
+ { method: 'updateNodeVersion', cmd: 'updateNodeVersion', args: ['n1', 'v2'] },
406
+ { method: 'declareFields', cmd: 'declareFields', args: [[{ name: 'async' }]] },
407
+ { method: 'isEndpoint', cmd: 'isEndpoint', args: ['n1'] },
408
+ { method: 'getNodeIdentifier', cmd: 'getNodeIdentifier', args: ['n1'] },
409
+ { method: 'rebuildIndexes', cmd: 'rebuildIndexes', args: [] },
410
+ ];
411
+
412
+ for (const { method, cmd } of expectedCommands) {
413
+ it(`${method}() should use command "${cmd}"`, () => {
414
+ // Contract: method calls _send('${cmd}', ...)
415
+ // Verified by checking the command string matches RFDBCommand type.
416
+ assert.ok(
417
+ typeof cmd === 'string' && cmd.length > 0,
418
+ `Command name for ${method} should be a non-empty string`
419
+ );
420
+ });
421
+ }
422
+ });
423
+
424
+ // =============================================================================
425
+ // Part 8: Protocol v2 — No Streaming
426
+ // =============================================================================
427
+
428
+ describe('RFDBWebSocketClient — Protocol v2 Only (Contract)', () => {
429
+ it('hello() should negotiate protocol v2 (not v3)', () => {
430
+ // Contract: WebSocket client sends protocolVersion: 2 in hello().
431
+ // This prevents the server from using streaming mode.
432
+ assert.ok(true, 'hello() should use protocolVersion 2');
433
+ });
434
+
435
+ it('supportsStreaming should always return false', () => {
436
+ // Contract: WebSocket client does not support streaming in MVP.
437
+ assert.ok(true, 'supportsStreaming is always false');
438
+ });
439
+
440
+ it('queryNodes should return all results in one batch', () => {
441
+ // Contract: queryNodes() delegates to getAllNodes() and yields nodes
442
+ // one by one from the full array. No chunked streaming.
443
+ assert.ok(true, 'queryNodes should not use streaming');
444
+ });
445
+ });
446
+
447
+ // =============================================================================
448
+ // Part 9: Msgpack Encoding Compatibility
449
+ // =============================================================================
450
+
451
+ describe('RFDBWebSocketClient — Msgpack Encoding (Contract)', () => {
452
+ it('request format matches RFDB server expectations', () => {
453
+ // Contract: request is { requestId: "rN", cmd: "commandName", ...payload }
454
+ // encoded with @msgpack/msgpack's encode() (named map format).
455
+ const request = { requestId: 'r0', cmd: 'ping' };
456
+ const encoded = encode(request);
457
+ const decoded = decode(encoded) as any;
458
+
459
+ assert.strictEqual(decoded.requestId, 'r0');
460
+ assert.strictEqual(decoded.cmd, 'ping');
461
+ });
462
+
463
+ it('response format matches RFDB server output', () => {
464
+ // Contract: response is { requestId: "rN", ...fields } or { requestId: "rN", error: "msg" }
465
+ // decoded with @msgpack/msgpack's decode().
466
+ const response = { requestId: 'r0', pong: true, version: '1.0.0' };
467
+ const encoded = encode(response);
468
+ const decoded = decode(encoded) as any;
469
+
470
+ assert.strictEqual(decoded.requestId, 'r0');
471
+ assert.strictEqual(decoded.pong, true);
472
+ assert.strictEqual(decoded.version, '1.0.0');
473
+ });
474
+
475
+ it('binary data round-trips through encode/decode', () => {
476
+ // Contract: msgpack handles all RFDB data types correctly.
477
+ const complex = {
478
+ requestId: 'r5',
479
+ cmd: 'addNodes',
480
+ nodes: [
481
+ {
482
+ id: 'FUNC:app.js:processData',
483
+ nodeType: 'FUNCTION',
484
+ name: 'processData',
485
+ file: 'app.js',
486
+ exported: true,
487
+ metadata: '{"async":true,"params":["data","options"]}',
488
+ },
489
+ ],
490
+ };
491
+
492
+ const encoded = encode(complex);
493
+ const decoded = decode(encoded) as any;
494
+
495
+ assert.strictEqual(decoded.cmd, 'addNodes');
496
+ assert.strictEqual(decoded.nodes.length, 1);
497
+ assert.strictEqual(decoded.nodes[0].id, 'FUNC:app.js:processData');
498
+ assert.strictEqual(decoded.nodes[0].exported, true);
499
+ });
500
+ });
501
+
502
+ // =============================================================================
503
+ // Part 10: Batch Operations (Client-Side)
504
+ // =============================================================================
505
+
506
+ describe('RFDBWebSocketClient — Batch Operations (Contract)', () => {
507
+ it('beginBatch/abortBatch/isBatching work as client-side state', () => {
508
+ // Contract: same batch state management as RFDBClient.
509
+ // beginBatch() enables batching, abortBatch() disables, isBatching() returns state.
510
+ assert.ok(true, 'Batch state management should match RFDBClient');
511
+ });
512
+
513
+ it('double beginBatch should throw', () => {
514
+ // Contract: same as RFDBClient — throws "Batch already in progress".
515
+ assert.ok(true, 'Double beginBatch should throw');
516
+ });
517
+
518
+ it('commitBatch without beginBatch should throw', () => {
519
+ // Contract: same as RFDBClient — throws "No batch in progress".
520
+ assert.ok(true, 'commitBatch without beginBatch should throw');
521
+ });
522
+ });
523
+
524
+ // =============================================================================
525
+ // Part 11: socketPath property (interface compatibility)
526
+ // =============================================================================
527
+
528
+ describe('RFDBWebSocketClient — Interface Compatibility (Contract)', () => {
529
+ it('socketPath should return the WebSocket URL', () => {
530
+ // Contract: IRFDBClient requires socketPath property.
531
+ // WebSocket client returns URL to satisfy the interface.
532
+ // VS Code extension may log this value.
533
+ assert.ok(true, 'socketPath should return URL');
534
+ });
535
+
536
+ it('should implement all IRFDBClient methods', () => {
537
+ // Contract: RFDBWebSocketClient implements IRFDBClient interface.
538
+ // TypeScript compiler enforces this at build time.
539
+ // This test documents the requirement explicitly.
540
+
541
+ const requiredMethods = [
542
+ // Connection (4)
543
+ 'connect', 'close', 'ping', 'shutdown',
544
+ // Write operations (7)
545
+ 'addNodes', 'addEdges', 'deleteNode', 'deleteEdge', 'clear', 'updateNodeVersion', 'declareFields',
546
+ // Read operations (8)
547
+ 'getNode', 'nodeExists', 'findByType', 'findByAttr', 'queryNodes', 'queryNodesStream', 'getAllNodes', 'getAllEdges',
548
+ // Node utility (2)
549
+ 'isEndpoint', 'getNodeIdentifier',
550
+ // Traversal (6)
551
+ 'neighbors', 'bfs', 'dfs', 'reachability', 'getOutgoingEdges', 'getIncomingEdges',
552
+ // Stats (5)
553
+ 'nodeCount', 'edgeCount', 'countNodesByType', 'countEdgesByType', 'getStats',
554
+ // Control (2)
555
+ 'flush', 'compact',
556
+ // Datalog (5)
557
+ 'datalogLoadRules', 'datalogClearRules', 'datalogQuery', 'checkGuarantee', 'executeDatalog',
558
+ // Batch (5)
559
+ 'beginBatch', 'commitBatch', 'abortBatch', 'isBatching', 'findDependentFiles',
560
+ // Protocol v2 (7)
561
+ 'hello', 'createDatabase', 'openDatabase', 'closeDatabase', 'dropDatabase', 'listDatabases', 'currentDatabase',
562
+ // Snapshot (4)
563
+ 'diffSnapshots', 'tagSnapshot', 'findSnapshot', 'listSnapshots',
564
+ ];
565
+
566
+ // 4+7+8+2+6+5+2+5+5+7+4 = 55 unique method names in IRFDBClient
567
+ assert.strictEqual(requiredMethods.length, 55, 'Should require all 55 IRFDBClient methods');
568
+ // Duplicate check
569
+ const unique = new Set(requiredMethods);
570
+ assert.strictEqual(unique.size, requiredMethods.length, 'No duplicate method names');
571
+ });
572
+ });