/** * Tests for the live variant server. * Run with: node --test tests/live-server.test.mjs */ import { describe, it, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { execSync, spawn } from 'node:child_process'; const SERVER_SCRIPT = 'source/skills/impeccable/scripts/live-server.mjs'; // Matches LIVE_PID_FILE in live-server.mjs: project root, not tmpdir(). const PID_FILE = join(process.cwd(), '.impeccable-live.json'); // --------------------------------------------------------------------------- // Helper: start/stop server for integration tests // --------------------------------------------------------------------------- function startServer(port = 8499) { return new Promise((resolve, reject) => { const proc = spawn('node', [SERVER_SCRIPT, '--port=' + port], { stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env }, }); let output = ''; proc.stdout.on('data', (d) => { output += d.toString(); if (output.includes('running on')) { // Read token from PID file try { const info = JSON.parse(readFileSync(PID_FILE, 'utf-8')); resolve({ proc, port: info.port, token: info.token }); } catch { reject(new Error('Server started but PID file not readable')); } } }); proc.stderr.on('data', (d) => { output += d.toString(); }); proc.on('error', reject); setTimeout(() => reject(new Error('Server start timeout. Output: ' + output)), 5000); }); } async function stopServer(port, token) { try { await fetch(`http://localhost:${port}/stop?token=${token}`); } catch { /* server already gone */ } } // --------------------------------------------------------------------------- // Server integration tests // --------------------------------------------------------------------------- describe('live-server integration', () => { let server; before(async () => { server = await startServer(8499); }); after(async () => { if (server) { await stopServer(server.port, server.token); server.proc.kill(); } }); it('/health returns correct status', async () => { const res = await fetch(`http://localhost:${server.port}/health`); assert.equal(res.status, 200); const data = await res.json(); assert.equal(data.status, 'ok'); assert.equal(data.port, server.port); assert.equal(data.mode, 'variant'); assert.equal(typeof data.hasProjectContext, 'boolean'); assert.equal(data.connectedClients, 0); }); it('/live.js serves script with token injected', async () => { const res = await fetch(`http://localhost:${server.port}/live.js`); assert.equal(res.status, 200); assert.equal(res.headers.get('content-type'), 'application/javascript'); const text = await res.text(); assert.ok(text.includes('__IMPECCABLE_TOKEN__')); assert.ok(text.includes(server.token)); assert.ok(text.includes('__IMPECCABLE_PORT__')); }); it('/detect.js serves the detection overlay', async () => { const res = await fetch(`http://localhost:${server.port}/detect.js`); // May 404 if detect-antipatterns-browser.js hasn't been built assert.ok(res.status === 200 || res.status === 404); }); it('/poll returns timeout when no events queued', async () => { const res = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=500`); assert.equal(res.status, 200); const data = await res.json(); assert.equal(data.type, 'timeout'); }); it('/poll rejects invalid token', async () => { const res = await fetch(`http://localhost:${server.port}/poll?token=wrong&timeout=100`); assert.equal(res.status, 401); }); it('/stop rejects invalid token', async () => { const res = await fetch(`http://localhost:${server.port}/stop?token=wrong`); assert.equal(res.status, 401); }); it('POST /events rejects invalid token', async () => { const res = await fetch(`http://localhost:${server.port}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: 'wrong', type: 'exit' }), }); assert.equal(res.status, 401); }); it('POST /events validates event structure', async () => { const res = await fetch(`http://localhost:${server.port}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: server.token, type: 'generate' }), // missing required fields }); assert.equal(res.status, 400); const data = await res.json(); assert.ok(data.error.includes('generate')); }); // Regression: ids reach `execFileSync` argv and DOM attribute selectors. // Anything outside the strict generator pattern must be rejected before it // can leak into a downstream child_process or selector. it('POST /events rejects accept with shell metacharacters in id', async () => { const res = await fetch(`http://localhost:${server.port}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: server.token, type: 'accept', id: '"; rm -rf /; #', variantId: '0', }), }); assert.equal(res.status, 400); const data = await res.json(); assert.ok(data.error.includes('id')); }); it('POST /events rejects accept with non-numeric variantId', async () => { const res = await fetch(`http://localhost:${server.port}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: server.token, type: 'accept', id: 'a1b2c3d4', variantId: '0; touch /tmp/owned', }), }); assert.equal(res.status, 400); const data = await res.json(); assert.ok(data.error.includes('variantId')); }); it('POST /events rejects discard with malformed id', async () => { const res = await fetch(`http://localhost:${server.port}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: server.token, type: 'discard', id: 'not a uuid' }), }); assert.equal(res.status, 400); const data = await res.json(); assert.ok(data.error.includes('id')); }); it('POST /events accepts valid exit event', async () => { const res = await fetch(`http://localhost:${server.port}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: server.token, type: 'exit' }), }); assert.equal(res.status, 200); const data = await res.json(); assert.equal(data.ok, true); }); it('events flow from browser POST to agent poll', async () => { // Drain any queued events from previous tests let drained; do { const r = await fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=100`); drained = await r.json(); } while (drained.type !== 'timeout'); // Start a poll (will block until event arrives or timeout) const pollPromise = fetch(`http://localhost:${server.port}/poll?token=${server.token}&timeout=5000`) .then(r => r.json()); // Give the poll a moment to register await new Promise(r => setTimeout(r, 100)); // Send a generate event (simulating browser) const postRes = await fetch(`http://localhost:${server.port}/events`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: server.token, type: 'generate', id: 'a1b2c3d4', action: 'bolder', count: 2, element: { outerHTML: '