@bitovi/vybit 0.4.4

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,698 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { createServer, type Server } from 'http';
3
+ import { WebSocket } from 'ws';
4
+
5
+ import { Client } from '@modelcontextprotocol/sdk/client/index.js';
6
+ import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
7
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
8
+
9
+ import { createApp } from '../app.js';
10
+ import { setupWebSocket } from '../websocket.js';
11
+ import { registerMcpTools } from '../mcp-tools.js';
12
+ import {
13
+ clearAll,
14
+ getByStatus,
15
+ getCounts,
16
+ getNextCommitted,
17
+ markCommitImplementing,
18
+ markCommitImplemented,
19
+ markImplementing,
20
+ markImplemented,
21
+ onCommitted,
22
+ getQueueUpdate,
23
+ } from '../queue.js';
24
+
25
+ import type { Patch } from '../../shared/types.js';
26
+
27
+ // ---------------------------------------------------------------------------
28
+ // Helpers
29
+ // ---------------------------------------------------------------------------
30
+
31
+ function makePatch(overrides: Partial<Patch> = {}): Patch {
32
+ return {
33
+ id: overrides.id ?? crypto.randomUUID(),
34
+ kind: 'class-change',
35
+ elementKey: 'TestComponent::0/1',
36
+ status: 'staged',
37
+ originalClass: 'px-4',
38
+ newClass: 'px-8',
39
+ property: 'px',
40
+ timestamp: new Date().toISOString(),
41
+ component: { name: 'TestComponent' },
42
+ target: { tag: 'button', classes: 'px-4 py-2 bg-blue-500', innerText: 'Click me' },
43
+ context: '<button class="px-4 py-2 bg-blue-500">Click me</button>',
44
+ ...overrides,
45
+ };
46
+ }
47
+
48
+ /** Wait for the panel WS client to receive a message matching a predicate. */
49
+ function waitForPanelMessage(
50
+ messages: any[],
51
+ predicate: (msg: any) => boolean,
52
+ timeoutMs = 5000,
53
+ ): Promise<any> {
54
+ return new Promise((resolve, reject) => {
55
+ // Check existing messages first
56
+ const existing = messages.find(predicate);
57
+ if (existing) { resolve(existing); return; }
58
+
59
+ const startLen = messages.length;
60
+ const interval = setInterval(() => {
61
+ for (let i = startLen; i < messages.length; i++) {
62
+ if (predicate(messages[i])) {
63
+ clearInterval(interval);
64
+ clearTimeout(timer);
65
+ resolve(messages[i]);
66
+ return;
67
+ }
68
+ }
69
+ }, 50);
70
+ const timer = setTimeout(() => {
71
+ clearInterval(interval);
72
+ reject(new Error(`Timed out waiting for panel message. Got ${messages.length} messages: ${JSON.stringify(messages.map(m => m.type))}`));
73
+ }, timeoutMs);
74
+ });
75
+ }
76
+
77
+ function connectWs(port: number, role: 'overlay' | 'panel' | 'design'): Promise<{ ws: WebSocket; messages: any[] }> {
78
+ return new Promise((resolve, reject) => {
79
+ const ws = new WebSocket(`ws://localhost:${port}`);
80
+ const messages: any[] = [];
81
+ ws.on('open', () => {
82
+ ws.send(JSON.stringify({ type: 'REGISTER', role }));
83
+ // Small delay to let registration complete
84
+ setTimeout(() => resolve({ ws, messages }), 100);
85
+ });
86
+ ws.on('message', (raw) => {
87
+ messages.push(JSON.parse(String(raw)));
88
+ });
89
+ ws.on('error', reject);
90
+ });
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Test Suite
95
+ // ---------------------------------------------------------------------------
96
+
97
+ describe('Server integration tests', () => {
98
+ let httpServer: Server;
99
+ let port: number;
100
+ let mcpServer: McpServer;
101
+ let mcpClient: Client;
102
+ let overlayWs: WebSocket;
103
+ let overlayMessages: any[];
104
+ let panelWs: WebSocket;
105
+ let panelMessages: any[];
106
+
107
+ beforeEach(async () => {
108
+ // Reset queue state
109
+ clearAll();
110
+
111
+ // Set up HTTP + WS server on random port
112
+ const packageRoot = new URL('../..', import.meta.url).pathname;
113
+ const app = createApp(packageRoot);
114
+ httpServer = createServer(app);
115
+ const { broadcastPatchUpdate } = setupWebSocket(httpServer);
116
+
117
+ await new Promise<void>((resolve) => {
118
+ httpServer.listen(0, () => resolve());
119
+ });
120
+ const addr = httpServer.address();
121
+ port = typeof addr === 'object' && addr ? addr.port : 0;
122
+
123
+ // Set up MCP server + client via InMemoryTransport
124
+ const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
125
+
126
+ mcpServer = new McpServer(
127
+ { name: 'test-server', version: '0.1.0' },
128
+ { capabilities: { tools: {} } },
129
+ );
130
+ registerMcpTools(mcpServer, {
131
+ broadcastPatchUpdate,
132
+ getNextCommitted,
133
+ onCommitted,
134
+ markCommitImplementing,
135
+ markCommitImplemented,
136
+ markImplementing,
137
+ markImplemented,
138
+ getByStatus,
139
+ getCounts,
140
+ getQueueUpdate,
141
+ clearAll,
142
+ });
143
+ await mcpServer.connect(serverTransport);
144
+
145
+ mcpClient = new Client(
146
+ { name: 'test-client', version: '0.1.0' },
147
+ { capabilities: {} },
148
+ );
149
+ await mcpClient.connect(clientTransport);
150
+
151
+ // Connect overlay + panel WS clients
152
+ const overlay = await connectWs(port, 'overlay');
153
+ overlayWs = overlay.ws;
154
+ overlayMessages = overlay.messages;
155
+
156
+ const panel = await connectWs(port, 'panel');
157
+ panelWs = panel.ws;
158
+ panelMessages = panel.messages;
159
+ });
160
+
161
+ afterEach(async () => {
162
+ overlayWs?.close();
163
+ panelWs?.close();
164
+ await mcpClient?.close?.();
165
+ await mcpServer?.close?.();
166
+ await new Promise<void>((resolve, reject) => {
167
+ httpServer?.close((err) => (err ? reject(err) : resolve()));
168
+ });
169
+ clearAll();
170
+ });
171
+
172
+ // -----------------------------------------------------------------------
173
+ // a. Stage → WS notification
174
+ // -----------------------------------------------------------------------
175
+ it('PATCH_STAGED → panel receives QUEUE_UPDATE with draftCount: 1', async () => {
176
+ const patch = makePatch();
177
+
178
+ // Clear the initial QUEUE_UPDATE the panel got on registration
179
+ panelMessages.length = 0;
180
+
181
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
182
+
183
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
184
+ m.type === 'QUEUE_UPDATE' && m.draftCount === 1,
185
+ );
186
+ expect(msg.draftCount).toBe(1);
187
+ expect(msg.draft).toHaveLength(1);
188
+ expect(msg.draft[0].id).toBe(patch.id);
189
+ });
190
+
191
+ // -----------------------------------------------------------------------
192
+ // b. Commit → WS notification
193
+ // -----------------------------------------------------------------------
194
+ it('PATCH_COMMIT → panel receives QUEUE_UPDATE with committedCount: 1', async () => {
195
+ const patch = makePatch();
196
+
197
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
198
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
199
+
200
+ panelMessages.length = 0;
201
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
202
+
203
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
204
+ m.type === 'QUEUE_UPDATE' && m.committedCount === 1,
205
+ );
206
+ expect(msg.committedCount).toBe(1);
207
+ expect(msg.commits).toHaveLength(1);
208
+ expect(msg.commits[0].patches).toHaveLength(1);
209
+ expect(msg.commits[0].patches[0].id).toBe(patch.id);
210
+ });
211
+
212
+ // -----------------------------------------------------------------------
213
+ // c. GET /patches?status=committed
214
+ // -----------------------------------------------------------------------
215
+ it('GET /patches?status=committed returns the committed patch', async () => {
216
+ const patch = makePatch();
217
+
218
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
219
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
220
+
221
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
222
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.committedCount === 1);
223
+
224
+ const res = await fetch(`http://localhost:${port}/patches?status=committed`);
225
+ const data = await res.json();
226
+ expect(Array.isArray(data)).toBe(true);
227
+ expect(data).toHaveLength(1);
228
+ expect(data[0].id).toBe(patch.id);
229
+ expect(data[0].status).toBe('committed');
230
+ });
231
+
232
+ // -----------------------------------------------------------------------
233
+ // d. get_next_change returns immediately when committed commit exists
234
+ // -----------------------------------------------------------------------
235
+ it('get_next_change returns committed commit as raw JSON (single content item)', async () => {
236
+ const patch = makePatch();
237
+
238
+ // Stage + commit
239
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
240
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
241
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
242
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.committedCount === 1);
243
+
244
+ panelMessages.length = 0;
245
+
246
+ const result = await mcpClient.callTool({ name: 'get_next_change' });
247
+
248
+ // Single content item: raw commit JSON only
249
+ expect(result.content).toHaveLength(1);
250
+
251
+ const [commitContent] = result.content as any[];
252
+ expect(commitContent.type).toBe('text');
253
+ const commitData = JSON.parse(commitContent.text);
254
+ expect(commitData.patches).toHaveLength(1);
255
+ expect(commitData.patches[0].id).toBe(patch.id);
256
+
257
+ // Panel should get QUEUE_UPDATE with implementingCount: 1
258
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
259
+ m.type === 'QUEUE_UPDATE' && m.implementingCount === 1,
260
+ );
261
+ expect(msg.implementingCount).toBe(1);
262
+ });
263
+
264
+ // -----------------------------------------------------------------------
265
+ // e. get_next_change waits then resolves
266
+ // -----------------------------------------------------------------------
267
+ it('get_next_change waits for commit then resolves', async () => {
268
+ const patch = makePatch();
269
+
270
+ // Start get_next_change BEFORE any patches exist — it should block
271
+ const resultPromise = mcpClient.callTool({ name: 'get_next_change' });
272
+
273
+ // Small delay, then stage + commit
274
+ await new Promise((r) => setTimeout(r, 200));
275
+
276
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
277
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
278
+
279
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
280
+
281
+ // The get_next_change should now resolve
282
+ const result = await resultPromise;
283
+ expect(result.content).toHaveLength(1);
284
+
285
+ const commitData = JSON.parse((result.content as any[])[0].text);
286
+ expect(commitData.patches).toHaveLength(1);
287
+ expect(commitData.patches[0].id).toBe(patch.id);
288
+ });
289
+
290
+ // -----------------------------------------------------------------------
291
+ // e2. implement_next_change returns commit + loop instructions
292
+ // -----------------------------------------------------------------------
293
+ it('implement_next_change returns committed commit with loop instructions', async () => {
294
+ const patch = makePatch();
295
+
296
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
297
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
298
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
299
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.committedCount === 1);
300
+
301
+ panelMessages.length = 0;
302
+
303
+ const result = await mcpClient.callTool({ name: 'implement_next_change' });
304
+
305
+ // Two content items: structured JSON + markdown instructions
306
+ expect(result.content).toHaveLength(2);
307
+
308
+ const [jsonContent, mdContent] = result.content as any[];
309
+
310
+ // First: structured data with isComplete + commit
311
+ expect(jsonContent.type).toBe('text');
312
+ const data = JSON.parse(jsonContent.text);
313
+ expect(data.isComplete).toBe(false);
314
+ expect(data.commit.patches).toHaveLength(1);
315
+ expect(data.commit.patches[0].id).toBe(patch.id);
316
+ expect(data.nextAction).toContain('implement_next_change');
317
+
318
+ // Second: markdown instructions
319
+ expect(mdContent.type).toBe('text');
320
+ expect(mdContent.text).toContain('implement_next_change');
321
+ expect(mdContent.text).toContain('mark_change_implemented');
322
+ expect(mdContent.text).toContain(patch.originalClass);
323
+ expect(mdContent.text).toContain(patch.newClass);
324
+ });
325
+
326
+ // -----------------------------------------------------------------------
327
+ // e3. implement_next_change waits then resolves
328
+ // -----------------------------------------------------------------------
329
+ it('implement_next_change waits for commit then resolves', async () => {
330
+ const patch = makePatch();
331
+
332
+ const resultPromise = mcpClient.callTool({ name: 'implement_next_change' });
333
+
334
+ await new Promise((r) => setTimeout(r, 200));
335
+
336
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
337
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
338
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
339
+
340
+ const result = await resultPromise;
341
+ expect(result.content).toHaveLength(2);
342
+ const resultData = JSON.parse((result.content as any[])[0].text);
343
+ expect(resultData.commit.patches[0].id).toBe(patch.id);
344
+ });
345
+
346
+ // -----------------------------------------------------------------------
347
+ // f. GET /patches?status=implementing
348
+ // -----------------------------------------------------------------------
349
+ it('GET /patches?status=implementing returns the patch after get_next_change', async () => {
350
+ const patch = makePatch();
351
+
352
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
353
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
354
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
355
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.committedCount === 1);
356
+
357
+ await mcpClient.callTool({ name: 'get_next_change' });
358
+
359
+ const res = await fetch(`http://localhost:${port}/patches?status=implementing`);
360
+ const data = await res.json();
361
+ expect(data).toHaveLength(1);
362
+ expect(data[0].id).toBe(patch.id);
363
+ expect(data[0].status).toBe('implementing');
364
+ });
365
+
366
+ // -----------------------------------------------------------------------
367
+ // g. mark_change_implemented (legacy ids)
368
+ // -----------------------------------------------------------------------
369
+ it('mark_change_implemented → panel receives QUEUE_UPDATE with implementedCount: 1', async () => {
370
+ const patch = makePatch();
371
+
372
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
373
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
374
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
375
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.committedCount === 1);
376
+
377
+ await mcpClient.callTool({ name: 'get_next_change' });
378
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.implementingCount === 1);
379
+
380
+ panelMessages.length = 0;
381
+
382
+ const result = await mcpClient.callTool({
383
+ name: 'mark_change_implemented',
384
+ arguments: { ids: [patch.id] },
385
+ });
386
+ // Two content items: structured JSON + loop directive text
387
+ expect(result.content).toHaveLength(2);
388
+ const resultData = JSON.parse((result.content as any[])[0].text);
389
+ expect(resultData.moved).toBe(1);
390
+ expect(resultData.isComplete).toBe(false);
391
+ expect(resultData.nextAction).toContain('implement_next_change');
392
+
393
+ // Loop directive text should tell agent to call implement_next_change
394
+ const loopText = (result.content as any[])[1].text;
395
+ expect(loopText).toContain('implement_next_change');
396
+
397
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
398
+ m.type === 'QUEUE_UPDATE' && m.implementedCount === 1,
399
+ );
400
+ expect(msg.implementedCount).toBe(1);
401
+ });
402
+
403
+ // -----------------------------------------------------------------------
404
+ // h. GET /patches?status=implemented
405
+ // -----------------------------------------------------------------------
406
+ it('GET /patches?status=implemented returns the implemented patch', async () => {
407
+ const patch = makePatch();
408
+
409
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
410
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
411
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
412
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.committedCount === 1);
413
+ await mcpClient.callTool({ name: 'get_next_change' });
414
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.implementingCount === 1);
415
+ await mcpClient.callTool({ name: 'mark_change_implemented', arguments: { ids: [patch.id] } });
416
+
417
+ const res = await fetch(`http://localhost:${port}/patches?status=implemented`);
418
+ const data = await res.json();
419
+ expect(data).toHaveLength(1);
420
+ expect(data[0].id).toBe(patch.id);
421
+ expect(data[0].status).toBe('implemented');
422
+ });
423
+
424
+ // -----------------------------------------------------------------------
425
+ // i. list_changes with status filter
426
+ // -----------------------------------------------------------------------
427
+ it('list_changes with status filter returns matching patches', async () => {
428
+ const patch = makePatch();
429
+
430
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
431
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
432
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
433
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.committedCount === 1);
434
+ await mcpClient.callTool({ name: 'implement_next_change' });
435
+ await mcpClient.callTool({ name: 'mark_change_implemented', arguments: { ids: [patch.id] } });
436
+
437
+ const result = await mcpClient.callTool({
438
+ name: 'list_changes',
439
+ arguments: { status: 'implemented' },
440
+ });
441
+ const data = JSON.parse((result.content as any[])[0].text);
442
+ expect(data).toHaveLength(1);
443
+ expect(data[0].id).toBe(patch.id);
444
+ expect(data[0].status).toBe('implemented');
445
+ });
446
+
447
+ // -----------------------------------------------------------------------
448
+ // j. list_changes without filter returns queue state
449
+ // -----------------------------------------------------------------------
450
+ it('list_changes without filter returns queue state', async () => {
451
+ const patch = makePatch();
452
+
453
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
454
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
455
+
456
+ const result = await mcpClient.callTool({
457
+ name: 'list_changes',
458
+ arguments: {},
459
+ });
460
+ const data = JSON.parse((result.content as any[])[0].text);
461
+ expect(data.draftCount).toBe(1);
462
+ expect(data.draft).toHaveLength(1);
463
+ expect(data.draft[0].id).toBe(patch.id);
464
+ });
465
+
466
+ // -----------------------------------------------------------------------
467
+ // k. discard_all_changes
468
+ // -----------------------------------------------------------------------
469
+ it('discard_all_changes clears everything and notifies panel', async () => {
470
+ const patch = makePatch();
471
+
472
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
473
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
474
+
475
+ panelMessages.length = 0;
476
+
477
+ const result = await mcpClient.callTool({ name: 'discard_all_changes' });
478
+ const counts = JSON.parse((result.content as any[])[0].text);
479
+ expect(counts.staged).toBe(1);
480
+
481
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
482
+ m.type === 'QUEUE_UPDATE' && m.draftCount === 0 && m.committedCount === 0,
483
+ );
484
+ expect(msg.draftCount).toBe(0);
485
+ expect(msg.committedCount).toBe(0);
486
+ expect(msg.implementingCount).toBe(0);
487
+ expect(msg.implementedCount).toBe(0);
488
+ });
489
+
490
+ // -----------------------------------------------------------------------
491
+ // l. MESSAGE_STAGE → message patch in draft
492
+ // -----------------------------------------------------------------------
493
+ it('MESSAGE_STAGE → panel receives QUEUE_UPDATE with message in draft', async () => {
494
+ panelMessages.length = 0;
495
+
496
+ panelWs.send(JSON.stringify({
497
+ type: 'MESSAGE_STAGE',
498
+ id: 'msg-1',
499
+ message: 'Make description more readable',
500
+ elementKey: 'Card',
501
+ }));
502
+
503
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
504
+ m.type === 'QUEUE_UPDATE' && m.draftCount === 1,
505
+ );
506
+ expect(msg.draftCount).toBe(1);
507
+ expect(msg.draft).toHaveLength(1);
508
+ expect(msg.draft[0].kind).toBe('message');
509
+ expect(msg.draft[0].message).toBe('Make description more readable');
510
+ });
511
+
512
+ // -----------------------------------------------------------------------
513
+ // m. Mixed class-change + message commit
514
+ // -----------------------------------------------------------------------
515
+ it('commit with class-change + message preserves order', async () => {
516
+ const patch = makePatch();
517
+
518
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
519
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
520
+
521
+ panelWs.send(JSON.stringify({
522
+ type: 'MESSAGE_STAGE',
523
+ id: 'msg-1',
524
+ message: 'Explain this change',
525
+ elementKey: 'Card',
526
+ }));
527
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 2);
528
+
529
+ panelMessages.length = 0;
530
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id, 'msg-1'] }));
531
+
532
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
533
+ m.type === 'QUEUE_UPDATE' && m.committedCount === 1,
534
+ );
535
+ expect(msg.commits).toHaveLength(1);
536
+ expect(msg.commits[0].patches).toHaveLength(2);
537
+ expect(msg.commits[0].patches[0].kind).toBe('class-change');
538
+ expect(msg.commits[0].patches[1].kind).toBe('message');
539
+ });
540
+
541
+ // -----------------------------------------------------------------------
542
+ // n. mark_change_implemented with commitId + results
543
+ // -----------------------------------------------------------------------
544
+ it('mark_change_implemented with commitId and per-patch results', async () => {
545
+ const patch = makePatch();
546
+
547
+ overlayWs.send(JSON.stringify({ type: 'PATCH_STAGED', patch }));
548
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.draftCount === 1);
549
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [patch.id] }));
550
+ await waitForPanelMessage(panelMessages, (m) => m.type === 'QUEUE_UPDATE' && m.committedCount === 1);
551
+
552
+ const implResult = await mcpClient.callTool({ name: 'implement_next_change' });
553
+ const implData = JSON.parse((implResult.content as any[])[0].text);
554
+ const commitId = implData.commit.id;
555
+
556
+ panelMessages.length = 0;
557
+
558
+ const result = await mcpClient.callTool({
559
+ name: 'mark_change_implemented',
560
+ arguments: {
561
+ commitId,
562
+ results: [{ patchId: patch.id, success: true }],
563
+ },
564
+ });
565
+ expect(result.content).toHaveLength(2);
566
+ const resultData = JSON.parse((result.content as any[])[0].text);
567
+ expect(resultData.moved).toBe(1);
568
+
569
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
570
+ m.type === 'QUEUE_UPDATE' && m.implementedCount === 1,
571
+ );
572
+ expect(msg.implementedCount).toBe(1);
573
+ });
574
+
575
+ // -----------------------------------------------------------------------
576
+ // o. DESIGN_SUBMIT → design patch in draft with image
577
+ // -----------------------------------------------------------------------
578
+ it('DESIGN_SUBMIT → panel receives QUEUE_UPDATE with design patch including image', async () => {
579
+ const designWsConn = await connectWs(port, 'design');
580
+ const designWs = designWsConn.ws;
581
+
582
+ // A small 1x1 red PNG as a data URL
583
+ const testImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
584
+
585
+ panelMessages.length = 0;
586
+
587
+ designWs.send(JSON.stringify({
588
+ type: 'DESIGN_SUBMIT',
589
+ image: testImage,
590
+ componentName: 'Hero',
591
+ target: { tag: 'div', classes: 'flex items-center', innerText: 'Hello' },
592
+ context: '<div class="flex items-center">Hello</div>',
593
+ insertMode: 'before',
594
+ canvasWidth: 400,
595
+ canvasHeight: 300,
596
+ }));
597
+
598
+ const msg = await waitForPanelMessage(panelMessages, (m) =>
599
+ m.type === 'QUEUE_UPDATE' && m.draftCount === 1,
600
+ );
601
+ expect(msg.draftCount).toBe(1);
602
+ expect(msg.draft).toHaveLength(1);
603
+ expect(msg.draft[0].kind).toBe('design');
604
+ expect(msg.draft[0].image).toBe(testImage);
605
+ expect(msg.draft[0].component?.name).toBe('Hero');
606
+
607
+ designWs.close();
608
+ });
609
+
610
+ // -----------------------------------------------------------------------
611
+ // p. DESIGN_SUBMIT → overlay receives DESIGN_SUBMITTED with image
612
+ // -----------------------------------------------------------------------
613
+ it('DESIGN_SUBMIT → overlay receives DESIGN_SUBMITTED echo', async () => {
614
+ const designWsConn = await connectWs(port, 'design');
615
+ const designWs = designWsConn.ws;
616
+
617
+ const testImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
618
+
619
+ overlayMessages.length = 0;
620
+
621
+ designWs.send(JSON.stringify({
622
+ type: 'DESIGN_SUBMIT',
623
+ image: testImage,
624
+ componentName: 'Card',
625
+ target: { tag: 'section', classes: 'p-4', innerText: 'Content' },
626
+ context: '<section class="p-4">Content</section>',
627
+ insertMode: 'after',
628
+ canvasWidth: 600,
629
+ canvasHeight: 400,
630
+ }));
631
+
632
+ const msg = await waitForPanelMessage(overlayMessages, (m) =>
633
+ m.type === 'DESIGN_SUBMITTED',
634
+ );
635
+ expect(msg.type).toBe('DESIGN_SUBMITTED');
636
+ expect(msg.image).toBe(testImage);
637
+
638
+ designWs.close();
639
+ });
640
+
641
+ // -----------------------------------------------------------------------
642
+ // q. Design patch full lifecycle: stage → commit → implement
643
+ // -----------------------------------------------------------------------
644
+ it('design patch flows through commit → implement_next_change → mark_change_implemented', async () => {
645
+ const designWsConn = await connectWs(port, 'design');
646
+ const designWs = designWsConn.ws;
647
+
648
+ const testImage = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==';
649
+
650
+ panelMessages.length = 0;
651
+
652
+ designWs.send(JSON.stringify({
653
+ type: 'DESIGN_SUBMIT',
654
+ image: testImage,
655
+ componentName: 'Nav',
656
+ target: { tag: 'nav', classes: 'flex', innerText: 'Menu' },
657
+ context: '<nav class="flex">Menu</nav>',
658
+ insertMode: 'first-child',
659
+ canvasWidth: 500,
660
+ canvasHeight: 350,
661
+ }));
662
+
663
+ // Wait for draft
664
+ const draftMsg = await waitForPanelMessage(panelMessages, (m) =>
665
+ m.type === 'QUEUE_UPDATE' && m.draftCount === 1,
666
+ );
667
+ const designPatchId = draftMsg.draft[0].id;
668
+
669
+ // Commit the design patch
670
+ panelMessages.length = 0;
671
+ panelWs.send(JSON.stringify({ type: 'PATCH_COMMIT', ids: [designPatchId] }));
672
+ await waitForPanelMessage(panelMessages, (m) =>
673
+ m.type === 'QUEUE_UPDATE' && m.committedCount === 1,
674
+ );
675
+
676
+ // implement_next_change should return the design commit with image
677
+ panelMessages.length = 0;
678
+ const implResult = await mcpClient.callTool({ name: 'implement_next_change' });
679
+ const implData = JSON.parse((implResult.content as any[])[0].text);
680
+ expect(implData.commit.patches).toHaveLength(1);
681
+ expect(implData.commit.patches[0].kind).toBe('design');
682
+ expect(implData.commit.patches[0].image).toBe(testImage);
683
+
684
+ // Mark implemented
685
+ panelMessages.length = 0;
686
+ await mcpClient.callTool({
687
+ name: 'mark_change_implemented',
688
+ arguments: { commitId: implData.commit.id, results: [{ patchId: designPatchId, success: true }] },
689
+ });
690
+
691
+ const finalMsg = await waitForPanelMessage(panelMessages, (m) =>
692
+ m.type === 'QUEUE_UPDATE' && m.implementedCount === 1,
693
+ );
694
+ expect(finalMsg.implementedCount).toBe(1);
695
+
696
+ designWs.close();
697
+ });
698
+ });