@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.
- package/LICENSE +21 -0
- package/README.md +155 -0
- package/loader.mjs +11 -0
- package/overlay/dist/.gitkeep +0 -0
- package/overlay/dist/overlay.js +1547 -0
- package/package.json +57 -0
- package/panel/dist/assets/index-BUKLf5aN.css +1 -0
- package/panel/dist/assets/index-Cr2RD_Gn.js +549 -0
- package/panel/dist/index.html +25 -0
- package/server/app.ts +117 -0
- package/server/index.ts +57 -0
- package/server/mcp-tools.ts +356 -0
- package/server/queue.ts +281 -0
- package/server/tailwind-adapter.ts +17 -0
- package/server/tailwind-v3.ts +159 -0
- package/server/tailwind-v4.ts +160 -0
- package/server/tailwind.ts +50 -0
- package/server/tests/server.integration.test.ts +698 -0
- package/server/websocket.ts +130 -0
- package/shared/types.ts +304 -0
|
@@ -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
|
+
});
|