live-server.test.mjs

  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});