1/**
2 * Tests for the live variant server.
3 * Run with: node --test tests/live-server.test.mjs
4 */
5
6import { describe, it, before, after } from 'node:test';
7import assert from 'node:assert/strict';
8import { existsSync, mkdtempSync, readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs';
9import { join } from 'node:path';
10import { tmpdir } from 'node:os';
11import { execFileSync, execSync, spawn } from 'node:child_process';
12import {
13 getDesignSidecarPath,
14 getLiveServerPath,
15 getLiveSessionsDir,
16} from '../skill/scripts/impeccable-paths.mjs';
17
18const REPO_ROOT = process.cwd();
19const SERVER_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-server.mjs');
20const COMPLETE_SCRIPT = join(REPO_ROOT, 'skill/scripts/live-complete.mjs');
21// ---------------------------------------------------------------------------
22// Helper: start/stop server for integration tests
23// ---------------------------------------------------------------------------
24
25function startServer(port = 8499, { cwd = REPO_ROOT } = {}) {
26 return new Promise((resolve, reject) => {
27 const proc = spawn('node', [SERVER_SCRIPT, '--port=' + port], {
28 cwd,
29 stdio: ['ignore', 'pipe', 'pipe'],
30 env: { ...process.env },
31 });
32 let output = '';
33 proc.stdout.on('data', (d) => {
34 output += d.toString();
35 if (output.includes('running on')) {
36 // Read token from PID file
37 try {
38 const info = JSON.parse(readFileSync(getLiveServerPath(cwd), 'utf-8'));
39 resolve({ proc, port: info.port, token: info.token, cwd });
40 } catch {
41 reject(new Error('Server started but PID file not readable'));
42 }
43 }
44 });
45 proc.stderr.on('data', (d) => { output += d.toString(); });
46 proc.on('error', reject);
47 setTimeout(() => reject(new Error('Server start timeout. Output: ' + output)), 5000);
48 });
49}
50
51async function stopServer(port, token) {
52 try {
53 await fetch(`http://localhost:${port}/stop?token=${token}`);
54 } catch { /* server already gone */ }
55}
56
57async function drainPolls(server) {
58 let drained;
59 do {
60 const r = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=1`);
61 drained = await r.json();
62 if (drained.id) {
63 await fetch(`http://localhost:${server.port}/poll`, {
64 method: 'POST',
65 headers: { 'Content-Type': 'application/json' },
66 body: JSON.stringify({ token: server.token, id: drained.id, type: 'done' }),
67 });
68 }
69 } while (drained.type !== 'timeout');
70}
71
72// ---------------------------------------------------------------------------
73// Server integration tests
74// ---------------------------------------------------------------------------
75
76describe('live-server integration', () => {
77 let server;
78 let serverCwd;
79
80 before(async () => {
81 // Run the shared server against an isolated tmpdir so journals/snapshots
82 // never land in the real repo's `.impeccable/live/sessions/`. Those would
83 // otherwise be replayed into the poll queue on the next real `live` run.
84 serverCwd = mkdtempSync(join(tmpdir(), 'impeccable-live-server-'));
85 // The /source endpoint test below reads package.json from the server's
86 // cwd, so seed a minimal one that contains the substring it asserts on.
87 writeFileSync(join(serverCwd, 'package.json'), JSON.stringify({ name: 'impeccable' }));
88 server = await startServer(8499, { cwd: serverCwd });
89 });
90
91 after(async () => {
92 if (server) {
93 await stopServer(server.port, server.token);
94 server.proc.kill();
95 }
96 if (serverCwd) {
97 rmSync(serverCwd, { recursive: true, force: true });
98 }
99 });
100
101 it('/health returns correct status', async () => {
102 const res = await fetch(`http://localhost:${server.port}/health`);
103 assert.equal(res.status, 200);
104 const data = await res.json();
105 assert.equal(data.status, 'ok');
106 assert.equal(data.port, server.port);
107 assert.equal(data.mode, 'variant');
108 assert.equal(typeof data.hasProjectContext, 'boolean');
109 assert.equal(data.connectedClients, 0);
110 });
111
112 it('/status returns durable recovery state', async () => {
113 await drainPolls(server);
114 const eventRes = await fetch(`http://localhost:${server.port}/events`, {
115 method: 'POST',
116 headers: { 'Content-Type': 'application/json' },
117 body: JSON.stringify({
118 token: server.token,
119 type: 'generate',
120 id: 'a1b2c3d5',
121 action: 'impeccable',
122 count: 1,
123 pageUrl: '/',
124 element: { outerHTML: '<button>Book</button>' },
125 }),
126 });
127 assert.equal(eventRes.status, 200);
128
129 const res = await fetch(`http://localhost:${server.port}/status?token=${server.token}`);
130 assert.equal(res.status, 200);
131 const data = await res.json();
132 assert.equal(data.status, 'ok');
133 assert.equal(data.activeSessions.some((s) => s.id === 'a1b2c3d5'), true);
134 assert.equal(data.pendingEvents.some((e) => e.id === 'a1b2c3d5' && e.type === 'generate'), true);
135
136 await drainPolls(server);
137 });
138
139 it('/live.js serves script with token injected', async () => {
140 const res = await fetch(`http://localhost:${server.port}/live.js`);
141 assert.equal(res.status, 200);
142 assert.equal(res.headers.get('content-type'), 'application/javascript');
143 const text = await res.text();
144 assert.ok(text.includes('__IMPECCABLE_TOKEN__'));
145 assert.ok(text.includes(server.token));
146 assert.ok(text.includes('__IMPECCABLE_PORT__'));
147 const sessionHelperIndex = text.indexOf('__IMPECCABLE_LIVE_SESSION__');
148 const browserInitIndex = text.indexOf('__IMPECCABLE_LIVE_INIT__');
149 assert.ok(sessionHelperIndex !== -1);
150 assert.ok(browserInitIndex !== -1);
151 assert.ok(
152 sessionHelperIndex < browserInitIndex,
153 'event=live_server.browser_helper_order actor=browser operation=load_live_js risk=session_helper_missing_before_browser_init expected=session helper before live init actual=' + sessionHelperIndex + ':' + browserInitIndex,
154 );
155 });
156
157 it('/design-system.json reads DESIGN.md plus .impeccable/design.json', async () => {
158 const tmp = mkdtempSync(join(tmpdir(), 'impeccable-design-system-'));
159 let designServer;
160 try {
161 writeFileSync(join(tmp, 'DESIGN.md'), `---
162name: Temp System
163description: Temporary design context
164colors: {}
165---
166
167# Temp System
168`);
169 const sidecarPath = getDesignSidecarPath(tmp);
170 mkdirSync(join(tmp, '.impeccable'), { recursive: true });
171 writeFileSync(sidecarPath, JSON.stringify({ version: 2, source: 'new-sidecar' }));
172
173 designServer = await startServer(8520, { cwd: tmp });
174 const res = await fetch(`http://localhost:${designServer.port}/design-system.json?token=${designServer.token}`);
175 const data = await res.json();
176
177 assert.equal(res.status, 200);
178 assert.equal(data.hasMd, true);
179 assert.equal(data.hasSidecar, true);
180 assert.equal(data.parsed.frontmatter.name, 'Temp System');
181 assert.equal(data.sidecar.source, 'new-sidecar');
182 } finally {
183 if (designServer) {
184 await stopServer(designServer.port, designServer.token);
185 designServer.proc.kill();
186 }
187 rmSync(tmp, { recursive: true, force: true });
188 }
189 });
190
191 it('/design-system.json falls back to legacy root DESIGN.json', async () => {
192 const tmp = mkdtempSync(join(tmpdir(), 'impeccable-design-system-legacy-'));
193 let designServer;
194 try {
195 writeFileSync(join(tmp, 'DESIGN.md'), `---
196name: Legacy System
197description: Legacy design context
198colors: {}
199---
200
201# Legacy System
202`);
203 writeFileSync(join(tmp, 'DESIGN.json'), JSON.stringify({ version: 2, source: 'legacy-sidecar' }));
204
205 designServer = await startServer(8521, { cwd: tmp });
206 const res = await fetch(`http://localhost:${designServer.port}/design-system.json?token=${designServer.token}`);
207 const data = await res.json();
208
209 assert.equal(res.status, 200);
210 assert.equal(data.hasMd, true);
211 assert.equal(data.hasSidecar, true);
212 assert.equal(data.parsed.frontmatter.name, 'Legacy System');
213 assert.equal(data.sidecar.source, 'legacy-sidecar');
214 } finally {
215 if (designServer) {
216 await stopServer(designServer.port, designServer.token);
217 designServer.proc.kill();
218 }
219 rmSync(tmp, { recursive: true, force: true });
220 }
221 });
222
223 it('/detect.js serves the detection overlay', async () => {
224 const res = await fetch(`http://localhost:${server.port}/detect.js`);
225 // May 404 if detect-antipatterns-browser.js hasn't been built
226 assert.ok(res.status === 200 || res.status === 404);
227 });
228
229 it('/poll returns timeout when no events queued', async () => {
230 const res = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=500`);
231 assert.equal(res.status, 200);
232 const data = await res.json();
233 assert.equal(data.type, 'timeout');
234 });
235
236 it('/poll rejects invalid token', async () => {
237 const res = await fetch(`http://localhost:${server.port}/poll?token=wrong&timeout=100`);
238 assert.equal(res.status, 401);
239 });
240
241 it('/stop rejects invalid token', async () => {
242 const res = await fetch(`http://localhost:${server.port}/stop?token=wrong`);
243 assert.equal(res.status, 401);
244 });
245
246 it('POST /events rejects invalid token', async () => {
247 const res = await fetch(`http://localhost:${server.port}/events`, {
248 method: 'POST',
249 headers: { 'Content-Type': 'application/json' },
250 body: JSON.stringify({ token: 'wrong', type: 'exit' }),
251 });
252 assert.equal(res.status, 401);
253 });
254
255 it('POST /events validates event structure', async () => {
256 const res = await fetch(`http://localhost:${server.port}/events`, {
257 method: 'POST',
258 headers: { 'Content-Type': 'application/json' },
259 body: JSON.stringify({ token: server.token, type: 'generate' }), // missing required fields
260 });
261 assert.equal(res.status, 400);
262 const data = await res.json();
263 assert.ok(data.error.includes('generate'));
264 });
265
266 // Regression: ids reach `execFileSync` argv and DOM attribute selectors.
267 // Anything outside the strict generator pattern must be rejected before it
268 // can leak into a downstream child_process or selector.
269 it('POST /events rejects accept with shell metacharacters in id', async () => {
270 const res = await fetch(`http://localhost:${server.port}/events`, {
271 method: 'POST',
272 headers: { 'Content-Type': 'application/json' },
273 body: JSON.stringify({
274 token: server.token,
275 type: 'accept',
276 id: '"; rm -rf /; #',
277 variantId: '0',
278 }),
279 });
280 assert.equal(res.status, 400);
281 const data = await res.json();
282 assert.ok(data.error.includes('id'));
283 });
284
285 it('POST /events rejects accept with non-numeric variantId', async () => {
286 const res = await fetch(`http://localhost:${server.port}/events`, {
287 method: 'POST',
288 headers: { 'Content-Type': 'application/json' },
289 body: JSON.stringify({
290 token: server.token,
291 type: 'accept',
292 id: 'a1b2c3d4',
293 variantId: '0; touch /tmp/owned',
294 }),
295 });
296 assert.equal(res.status, 400);
297 const data = await res.json();
298 assert.ok(data.error.includes('variantId'));
299 });
300
301 it('POST /events rejects discard with malformed id', async () => {
302 const res = await fetch(`http://localhost:${server.port}/events`, {
303 method: 'POST',
304 headers: { 'Content-Type': 'application/json' },
305 body: JSON.stringify({ token: server.token, type: 'discard', id: 'not a uuid' }),
306 });
307 assert.equal(res.status, 400);
308 const data = await res.json();
309 assert.ok(data.error.includes('id'));
310 });
311
312 it('POST /events accepts valid exit event', async () => {
313 const res = await fetch(`http://localhost:${server.port}/events`, {
314 method: 'POST',
315 headers: { 'Content-Type': 'application/json' },
316 body: JSON.stringify({ token: server.token, type: 'exit' }),
317 });
318 assert.equal(res.status, 200);
319 const data = await res.json();
320 assert.equal(data.ok, true);
321 });
322
323 it('events flow from browser POST to agent poll', async () => {
324 // Drain any queued events from previous tests
325 await drainPolls(server);
326
327 // Start a poll (will block until event arrives or timeout)
328 const pollPromise = fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=5000`)
329 .then(r => r.json());
330
331 // Give the poll a moment to register
332 await new Promise(r => setTimeout(r, 100));
333
334 // Send a generate event (simulating browser)
335 const postRes = await fetch(`http://localhost:${server.port}/events`, {
336 method: 'POST',
337 headers: { 'Content-Type': 'application/json' },
338 body: JSON.stringify({
339 token: server.token,
340 type: 'generate',
341 id: 'a1b2c3d4',
342 action: 'bolder',
343 count: 2,
344 element: { outerHTML: '<div>test</div>', tagName: 'div' },
345 }),
346 });
347 assert.equal(postRes.status, 200);
348
349 // Poll should resolve with the event
350 const event = await pollPromise;
351 assert.equal(event.type, 'generate');
352 assert.equal(event.id, 'a1b2c3d4');
353 assert.equal(event.action, 'bolder');
354 assert.equal(event.count, 2);
355
356 await fetch(`http://localhost:${server.port}/poll`, {
357 method: 'POST',
358 headers: { 'Content-Type': 'application/json' },
359 body: JSON.stringify({ token: server.token, id: 'test-e2e-1', type: 'done' }),
360 });
361 });
362
363 it('persists browser events to the durable session journal before poll delivery', async () => {
364 await drainPolls(server);
365 const journalPath = join(getLiveSessionsDir(server.cwd), 'a1b2c3d6.jsonl');
366 rmSync(journalPath, { force: true });
367
368 const postRes = await fetch(`http://localhost:${server.port}/events`, {
369 method: 'POST',
370 headers: { 'Content-Type': 'application/json' },
371 body: JSON.stringify({
372 token: server.token,
373 type: 'generate',
374 id: 'a1b2c3d6',
375 action: 'layout',
376 count: 3,
377 pageUrl: 'http://localhost:4321/',
378 element: { outerHTML: '<section>persist</section>', tagName: 'section' },
379 }),
380 });
381 assert.equal(postRes.status, 200);
382
383 assert.equal(
384 existsSync(journalPath),
385 true,
386 'event=live_server.journal_before_poll actor=browser operation=post_generate risk=server_restart_loses_unpolled_event expected=journal exists before agent poll actual=missing suggestion=append to live-session-store before enqueueing event',
387 );
388 const journal = readFileSync(journalPath, 'utf-8');
389 assert.match(journal, /"type":"generate"/);
390
391 await fetch(`http://localhost:${server.port}/poll`, {
392 method: 'POST',
393 headers: { 'Content-Type': 'application/json' },
394 body: JSON.stringify({ token: server.token, id: 'a1b2c3d6', type: 'done' }),
395 });
396 });
397
398 it('accepts checkpoint events without exposing them as agent poll work', async () => {
399 await drainPolls(server);
400 const res = await fetch(`http://localhost:${server.port}/events`, {
401 method: 'POST',
402 headers: { 'Content-Type': 'application/json' },
403 body: JSON.stringify({
404 token: server.token,
405 type: 'checkpoint',
406 id: 'a1b2c3d7',
407 phase: 'cycling',
408 revision: 2,
409 owner: 'browser-a',
410 arrivedVariants: 3,
411 visibleVariant: 2,
412 paramValues: { density: 'packed' },
413 }),
414 });
415 assert.equal(res.status, 200);
416
417 const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json());
418 assert.equal(
419 polled.type,
420 'timeout',
421 'event=live_server.checkpoint_not_polled actor=browser operation=checkpoint risk=checkpoint_starves_agent_queue expected=timeout actual=' + polled.type + ' suggestion=journal checkpoint without enqueueing agent work',
422 );
423
424 const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3d7.snapshot.json'), 'utf-8'));
425 assert.equal(snapshot.visibleVariant, 2);
426 assert.deepEqual(snapshot.paramValues, { density: 'packed' });
427 });
428
429 it('redelivers an unacknowledged browser event after helper server restart', async () => {
430 const tmp = mkdtempSync(join(tmpdir(), 'impeccable-server-restart-'));
431 let firstServer;
432 let restarted;
433 try {
434 firstServer = await startServer(8519, { cwd: tmp });
435 const postRes = await fetch(`http://localhost:${firstServer.port}/events`, {
436 method: 'POST',
437 headers: { 'Content-Type': 'application/json' },
438 body: JSON.stringify({
439 token: firstServer.token,
440 type: 'generate',
441 id: 'a1b2c3d8',
442 action: 'polish',
443 count: 2,
444 pageUrl: 'http://localhost:4321/',
445 element: { outerHTML: '<section>restart</section>', tagName: 'section' },
446 }),
447 });
448 assert.equal(postRes.status, 200);
449
450 await stopServer(firstServer.port, firstServer.token);
451 firstServer.proc.kill();
452 firstServer = null;
453
454 restarted = await startServer(8519, { cwd: tmp });
455 const replayed = await fetch(`http://localhost:${restarted.port}/poll?token=${restarted.token}&timeout=250&leaseMs=50`).then(r => r.json());
456
457 assert.equal(
458 replayed.id,
459 'a1b2c3d8',
460 'event=live_server.restart_replay actor=agent operation=poll_after_helper_restart risk=server_restart_loses_unpolled_event expected=a1b2c3d8 actual=' + replayed.id + ' suggestion=rebuild pending poll queue from live-session-store active snapshots on startup',
461 );
462 assert.equal(replayed.type, 'generate');
463 } finally {
464 if (firstServer) {
465 await stopServer(firstServer.port, firstServer.token);
466 firstServer.proc.kill();
467 }
468 if (restarted) {
469 await stopServer(restarted.port, restarted.token);
470 restarted.proc.kill();
471 }
472 rmSync(tmp, { recursive: true, force: true });
473 }
474 });
475
476 it('records explicit completion acknowledgements as completed durable sessions', async () => {
477 await drainPolls(server);
478 await fetch(`http://localhost:${server.port}/events`, {
479 method: 'POST',
480 headers: { 'Content-Type': 'application/json' },
481 body: JSON.stringify({
482 token: server.token,
483 type: 'generate',
484 id: 'a1b2c3d9',
485 action: 'impeccable',
486 count: 1,
487 pageUrl: '/',
488 element: { outerHTML: '<button>Done</button>' },
489 }),
490 });
491 const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50`).then(r => r.json());
492 assert.equal(polled.id, 'a1b2c3d9');
493 const ack = await fetch(`http://localhost:${server.port}/poll`, {
494 method: 'POST',
495 headers: { 'Content-Type': 'application/json' },
496 body: JSON.stringify({ token: server.token, id: 'a1b2c3d9', type: 'complete' }),
497 });
498 assert.equal(ack.status, 200);
499 const snapshot = JSON.parse(readFileSync(join(getLiveSessionsDir(server.cwd), 'a1b2c3d9.snapshot.json'), 'utf-8'));
500 assert.equal(snapshot.phase, 'completed');
501 });
502
503 it('manual live-complete acknowledges the running helper queue before writing fallback journal state', async () => {
504 await drainPolls(server);
505 await fetch(`http://localhost:${server.port}/events`, {
506 method: 'POST',
507 headers: { 'Content-Type': 'application/json' },
508 body: JSON.stringify({
509 token: server.token,
510 type: 'generate',
511 id: 'a1b2c3dc',
512 action: 'impeccable',
513 count: 1,
514 pageUrl: '/',
515 element: { outerHTML: '<button>Manual</button>' },
516 }),
517 });
518 const polled = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=50`).then(r => r.json());
519 assert.equal(polled.id, 'a1b2c3dc');
520
521 const completed = JSON.parse(execFileSync(process.execPath, [COMPLETE_SCRIPT, '--id', 'a1b2c3dc'], { cwd: server.cwd, encoding: 'utf-8' }));
522 assert.equal(completed.phase, 'completed');
523
524 await new Promise(r => setTimeout(r, 75));
525 const stale = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=50`).then(r => r.json());
526 assert.equal(
527 stale.type,
528 'timeout',
529 'event=live_complete.running_server_ack actor=agent operation=manual_complete risk=completed_session_redelivered_from_memory expected=timeout actual=' + stale.id,
530 );
531 });
532
533 it('does not drop polled events until the agent acknowledges them', async () => {
534 await drainPolls(server);
535
536 const postRes = await fetch(`http://localhost:${server.port}/events`, {
537 method: 'POST',
538 headers: { 'Content-Type': 'application/json' },
539 body: JSON.stringify({
540 token: server.token,
541 type: 'generate',
542 id: 'a1b2c3da',
543 action: 'polish',
544 count: 2,
545 element: { outerHTML: '<section>lease</section>', tagName: 'section' },
546 }),
547 });
548 assert.equal(postRes.status, 200);
549
550 const first = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=50`).then(r => r.json());
551 assert.equal(first.id, 'a1b2c3da');
552
553 const leased = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=25&leaseMs=50`).then(r => r.json());
554 assert.equal(leased.type, 'timeout', 'leased event should not be redelivered before lease expiry');
555
556 await new Promise(r => setTimeout(r, 75));
557 const redelivered = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=50`).then(r => r.json());
558 assert.equal(
559 redelivered.id,
560 'a1b2c3da',
561 'event=live_poll.lease_redelivery actor=agent operation=poll_after_missed_ack risk=agent_missed_event_loses_live_state expected=same event redelivered after lease expiry actual=' + redelivered.id + ' suggestion=inspect pending event lease bookkeeping',
562 );
563
564 await fetch(`http://localhost:${server.port}/poll`, {
565 method: 'POST',
566 headers: { 'Content-Type': 'application/json' },
567 body: JSON.stringify({ token: server.token, id: 'a1b2c3da', type: 'done' }),
568 });
569 const acked = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=50&leaseMs=50`).then(r => r.json());
570 assert.equal(acked.type, 'timeout', 'acked event should be removed from the poll queue');
571 });
572
573 it('wakes a parked poll as soon as a missed-ack lease expires', async () => {
574 await drainPolls(server);
575
576 const postRes = await fetch(`http://localhost:${server.port}/events`, {
577 method: 'POST',
578 headers: { 'Content-Type': 'application/json' },
579 body: JSON.stringify({
580 token: server.token,
581 type: 'generate',
582 id: 'a1b2c3db',
583 action: 'polish',
584 count: 1,
585 element: { outerHTML: '<section>wakeup</section>', tagName: 'section' },
586 }),
587 });
588 assert.equal(postRes.status, 200);
589
590 const first = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100&leaseMs=60`).then(r => r.json());
591 assert.equal(first.id, 'a1b2c3db');
592
593 const startedAt = Date.now();
594 const redelivered = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=500&leaseMs=60`).then(r => r.json());
595 const elapsed = Date.now() - startedAt;
596
597 assert.equal(
598 redelivered.id,
599 'a1b2c3db',
600 'event=live_poll.lease_expiry_wakeup actor=agent operation=poll_before_lease_expiry risk=parked_poll_waits_full_timeout expected=a1b2c3db actual=' + redelivered.id,
601 );
602 assert.ok(
603 elapsed < 250,
604 'event=live_poll.lease_expiry_latency actor=agent operation=poll_before_lease_expiry risk=redelivery_waits_full_timeout expected=<250 actual=' + elapsed,
605 );
606
607 await fetch(`http://localhost:${server.port}/poll`, {
608 method: 'POST',
609 headers: { 'Content-Type': 'application/json' },
610 body: JSON.stringify({ token: server.token, id: 'a1b2c3db', type: 'done' }),
611 });
612 });
613
614 it('agent reply is forwarded via SSE to browser', async () => {
615 // Use raw HTTP to read SSE (no EventSource in Node.js)
616 const controller = new AbortController();
617 const sseRes = await fetch(
618 `http://localhost:${server.port}/events?token=${server.token}`,
619 { signal: controller.signal }
620 );
621 assert.equal(sseRes.status, 200);
622 assert.equal(sseRes.headers.get('content-type'), 'text/event-stream');
623
624 // Read the first message (should be "connected")
625 const reader = sseRes.body.getReader();
626 const decoder = new TextDecoder();
627 const { value: chunk1 } = await reader.read();
628 const text1 = decoder.decode(chunk1);
629 assert.ok(text1.includes('"connected"'));
630
631 // Send a reply from the agent
632 await fetch(`http://localhost:${server.port}/poll`, {
633 method: 'POST',
634 headers: { 'Content-Type': 'application/json' },
635 body: JSON.stringify({ token: server.token, id: 'sse-test', type: 'done', file: 'x.html' }),
636 });
637
638 // Read the next SSE message
639 const { value: chunk2 } = await reader.read();
640 const text2 = decoder.decode(chunk2);
641 assert.ok(text2.includes('"done"'));
642 assert.ok(text2.includes('sse-test'));
643
644 controller.abort();
645 });
646
647 it('/source reads project files with valid token', async () => {
648 const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=package.json`);
649 assert.equal(res.status, 200);
650 const text = await res.text();
651 assert.ok(text.includes('"impeccable"'));
652 });
653
654 it('/source rejects path traversal', async () => {
655 const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=../../../etc/passwd`);
656 assert.equal(res.status, 400);
657 });
658
659 it('/source rejects invalid token', async () => {
660 const res = await fetch(`http://localhost:${server.port}/source?token=wrong&path=package.json`);
661 assert.equal(res.status, 401);
662 });
663
664 it('/source returns 404 for missing files', async () => {
665 try {
666 const res = await fetch(`http://localhost:${server.port}/source?token=${server.token}&path=nonexistent.xyz`);
667 assert.equal(res.status, 404);
668 } catch {
669 // Server may close socket on 404 for some Node versions
670 assert.ok(true, 'Server rejected request for missing file');
671 }
672 });
673
674 it('/modern-screenshot.js serves the vendored UMD build', async () => {
675 const res = await fetch(`http://localhost:${server.port}/modern-screenshot.js`);
676 assert.equal(res.status, 200);
677 assert.equal(res.headers.get('content-type'), 'application/javascript');
678 const text = await res.text();
679 // Sanity: the UMD build self-registers as window.modernScreenshot.
680 assert.ok(text.includes('modernScreenshot'));
681 });
682
683 it('POST /annotation rejects invalid token', async () => {
684 const res = await fetch(`http://localhost:${server.port}/annotation?token=wrong&eventId=abc`, {
685 method: 'POST', headers: { 'Content-Type': 'image/png' }, body: new Uint8Array([0x89, 0x50, 0x4e, 0x47]),
686 });
687 assert.equal(res.status, 401);
688 });
689
690 it('POST /annotation rejects invalid eventId', async () => {
691 const res = await fetch(`http://localhost:${server.port}/annotation?token=${server.token}&eventId=has%20spaces`, {
692 method: 'POST', headers: { 'Content-Type': 'image/png' }, body: new Uint8Array([0x89]),
693 });
694 assert.equal(res.status, 400);
695 });
696
697 it('POST /annotation rejects non-PNG content-type', async () => {
698 const res = await fetch(`http://localhost:${server.port}/annotation?token=${server.token}&eventId=abc`, {
699 method: 'POST', headers: { 'Content-Type': 'application/octet-stream' }, body: new Uint8Array([0x89]),
700 });
701 assert.equal(res.status, 415);
702 });
703
704 it('POST /annotation writes PNG to session dir and returns path', async () => {
705 const eventId = 'test-' + Math.random().toString(36).slice(2, 10);
706 // Minimal valid PNG header + IEND chunk (enough to prove we wrote bytes)
707 const png = new Uint8Array([
708 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a,
709 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82,
710 ]);
711 const res = await fetch(`http://localhost:${server.port}/annotation?token=${server.token}&eventId=${eventId}`, {
712 method: 'POST', headers: { 'Content-Type': 'image/png' }, body: png,
713 });
714 assert.equal(res.status, 200);
715 const data = await res.json();
716 assert.equal(data.ok, true);
717 assert.ok(data.path.endsWith(eventId + '.png'));
718 const written = readFileSync(data.path);
719 assert.equal(written.length, png.length);
720 });
721
722 it('POST /events accepts generate with optional annotation fields', async () => {
723 // Drain any queued events from previous tests
724 let drained;
725 do {
726 const r = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100`);
727 drained = await r.json();
728 } while (drained.type !== 'timeout');
729
730 const postRes = await fetch(`http://localhost:${server.port}/events`, {
731 method: 'POST', headers: { 'Content-Type': 'application/json' },
732 body: JSON.stringify({
733 token: server.token, type: 'generate',
734 id: 'aa11bb22', action: 'polish', count: 2,
735 element: { outerHTML: '<div>x</div>', tagName: 'div' },
736 screenshotPath: '/tmp/fake.png',
737 comments: [{ x: 10, y: 20, text: 'tighten this' }],
738 strokes: [{ points: [[0, 0], [10, 10]] }],
739 }),
740 });
741 assert.equal(postRes.status, 200);
742
743 const pollRes = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=2000`);
744 const event = await pollRes.json();
745 assert.equal(event.id, 'aa11bb22');
746 assert.equal(event.screenshotPath, '/tmp/fake.png');
747 assert.equal(event.comments.length, 1);
748 assert.equal(event.strokes.length, 1);
749 });
750
751 it('POST /events rejects generate with malformed annotation fields', async () => {
752 const postRes = await fetch(`http://localhost:${server.port}/events`, {
753 method: 'POST', headers: { 'Content-Type': 'application/json' },
754 body: JSON.stringify({
755 token: server.token, type: 'generate',
756 id: 'cc33dd44', action: 'polish', count: 2,
757 element: { outerHTML: '<div>x</div>', tagName: 'div' },
758 comments: 'not-an-array',
759 }),
760 });
761 assert.equal(postRes.status, 400);
762 const data = await postRes.json();
763 assert.ok(data.error.includes('comments'));
764 });
765});