1/**
2 * Per-fixture session lifecycle for live-mode E2E tests.
3 *
4 * Composes:
5 * - tmp staging (clones the fixture, git init, writes the inject config)
6 * - npm install (the fixture's runtime.install command)
7 * - live-server.mjs --background (returns {pid, port, token})
8 * - live-inject.mjs --port (patches the framework HTML entry)
9 * - the fixture's framework dev server (vite, vite dev, npx vite, ...)
10 * - Playwright Chromium page
11 * - the fake-agent poll loop (in this same node process)
12 *
13 * Returns handles + a single `teardown()` that cleans them all up in order.
14 */
15
16import { execFileSync, spawn } from 'node:child_process';
17import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
18import { tmpdir } from 'node:os';
19import { join, dirname } from 'node:path';
20import { fileURLToPath } from 'node:url';
21
22import { runAgentLoop } from './agent.mjs';
23
24const __dirname = dirname(fileURLToPath(import.meta.url));
25const REPO_ROOT = join(__dirname, '..', '..');
26const SCRIPTS_DIR = join(REPO_ROOT, 'skill', 'scripts');
27const FIXTURES_DIR = join(REPO_ROOT, 'tests', 'framework-fixtures');
28
29export { SCRIPTS_DIR, FIXTURES_DIR, REPO_ROOT };
30
31// ---------------------------------------------------------------------------
32// Stage
33// ---------------------------------------------------------------------------
34
35export function stageFixture(name, fixture) {
36 const fixtureRoot = join(FIXTURES_DIR, name);
37 const gitignore = readFileSync(join(fixtureRoot, 'gitignore.txt'), 'utf-8');
38
39 const tmp = mkdtempSync(join(tmpdir(), 'impeccable-e2e-'));
40 cpSync(join(fixtureRoot, 'files'), tmp, { recursive: true });
41 writeFileSync(join(tmp, '.gitignore'), gitignore);
42 mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
43 writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config));
44
45 execFileSync('git', ['init', '-q'], { cwd: tmp });
46 execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp });
47 execFileSync('git', ['config', 'user.name', 'Fixture'], { cwd: tmp });
48 execFileSync('git', ['add', '-A'], { cwd: tmp });
49 execFileSync('git', ['commit', '-qm', 'fixture'], { cwd: tmp });
50
51 return tmp;
52}
53
54export function runInstall(tmp, command) {
55 const [cmd, ...args] = command;
56 execFileSync(cmd, args, { cwd: tmp, stdio: 'inherit' });
57}
58
59// ---------------------------------------------------------------------------
60// live-server (background mode prints {pid, port, token})
61// ---------------------------------------------------------------------------
62
63export function startLiveServer(tmp) {
64 const out = execFileSync(
65 process.execPath,
66 [join(SCRIPTS_DIR, 'live-server.mjs'), '--background'],
67 { cwd: tmp, encoding: 'utf-8' },
68 );
69 const jsonLine = out.trim().split('\n').filter(Boolean).pop();
70 const info = JSON.parse(jsonLine);
71 if (!info.port || !info.pid) {
72 throw new Error('live-server --background returned unexpected payload: ' + jsonLine);
73 }
74 return info;
75}
76
77export function stopLiveServer(tmp) {
78 try {
79 execFileSync(
80 process.execPath,
81 [join(SCRIPTS_DIR, 'live-server.mjs'), 'stop', '--keep-inject'],
82 { cwd: tmp, stdio: 'ignore' },
83 );
84 } catch { /* already gone */ }
85}
86
87export function runInject(tmp, port) {
88 const out = execFileSync(
89 process.execPath,
90 [join(SCRIPTS_DIR, 'live-inject.mjs'), '--port', String(port)],
91 {
92 cwd: tmp,
93 encoding: 'utf-8',
94 env: { ...process.env },
95 },
96 );
97 const last = out.trim().split('\n').filter(Boolean).pop();
98 return JSON.parse(last);
99}
100
101// ---------------------------------------------------------------------------
102// Framework dev server
103// ---------------------------------------------------------------------------
104
105export function startDevServer(tmp, runtime) {
106 const [cmd, ...args] = runtime.devCommand;
107 const child = spawn(cmd, args, {
108 cwd: tmp,
109 env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
110 stdio: ['ignore', 'pipe', 'pipe'],
111 });
112
113 const readyRe = new RegExp(runtime.readyPattern);
114 const bufLog = [];
115 const capture = (chunk) => {
116 const s = chunk.toString();
117 bufLog.push(s);
118 if (bufLog.length > 200) bufLog.shift();
119 };
120 child.stdout.on('data', capture);
121 child.stderr.on('data', capture);
122
123 const ready = new Promise((resolve, reject) => {
124 const timeout = setTimeout(() => {
125 reject(new Error(
126 `dev server ready timeout (${runtime.readyTimeoutMs}ms). Tail:\n${bufLog.join('')}`,
127 ));
128 }, runtime.readyTimeoutMs ?? 120_000);
129
130 const checkMatch = (buf) => {
131 const m = buf.toString().match(readyRe);
132 if (m && m[1]) {
133 clearTimeout(timeout);
134 resolve({ port: Number(m[1]) });
135 }
136 };
137 child.stdout.on('data', checkMatch);
138 child.stderr.on('data', checkMatch);
139 child.on('exit', (code) => {
140 clearTimeout(timeout);
141 reject(new Error(`dev server exited before ready (code=${code}). Tail:\n${bufLog.join('')}`));
142 });
143 });
144
145 return { child, ready, log: () => bufLog.join('') };
146}
147
148export async function stopDevServer(child) {
149 if (!child || child.killed) return;
150 const exited = new Promise((resolve) => child.once('exit', resolve));
151 child.kill('SIGTERM');
152 const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5_000));
153 await Promise.race([exited, timeoutPromise]);
154 if (!child.killed) child.kill('SIGKILL');
155}
156
157// ---------------------------------------------------------------------------
158// Composite: full stage → ready
159// ---------------------------------------------------------------------------
160
161/**
162 * Boots everything and returns the connected page + handles + teardown.
163 *
164 * @param {object} opts
165 * @param {string} opts.name fixture name
166 * @param {object} opts.fixture fixture.json contents
167 * @param {import('playwright').Browser} opts.browser shared browser instance
168 * @param {object} opts.agent VariantAgent (defaults to fake)
169 * @param {(msg: string) => void} [opts.log]
170 */
171export async function bootFixtureSession({ name, fixture, browser, agent, log = () => {} }) {
172 const runtime = fixture.runtime;
173 if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
174
175 const tmp = stageFixture(name, fixture);
176 let live;
177 let dev;
178 let agentAbort;
179 let agentDone;
180 let ctx;
181
182 const teardown = async () => {
183 try { if (ctx) await ctx.close(); } catch {}
184 try { if (agentAbort) agentAbort.abort(); } catch {}
185 try { if (agentDone) await agentDone.catch(() => {}); } catch {}
186 try { if (dev?.child) await stopDevServer(dev.child); } catch {}
187 try { if (live) stopLiveServer(tmp); } catch {}
188 try { rmSync(tmp, { recursive: true, force: true }); } catch {}
189 };
190
191 try {
192 log(`installing deps`);
193 runInstall(tmp, runtime.install);
194
195 log(`starting live-server`);
196 live = startLiveServer(tmp);
197
198 log(`live-inject --port ${live.port}`);
199 const injectResult = runInject(tmp, live.port);
200 if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
201
202 log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
203 dev = startDevServer(tmp, runtime);
204 const { port: devPort } = await dev.ready;
205 log(`dev server ready on ${devPort}`);
206
207 // Agent loop runs concurrently — abort on teardown.
208 agentAbort = new AbortController();
209 agentDone = runAgentLoop({
210 tmp,
211 scriptsDir: SCRIPTS_DIR,
212 port: live.port,
213 token: live.token,
214 agent,
215 signal: agentAbort.signal,
216 log: (m) => log('[agent] ' + m),
217 });
218
219 const scheme = runtime.scheme || 'http';
220 ctx = await browser.newContext({
221 ignoreHTTPSErrors: runtime.ignoreHTTPSErrors === true,
222 });
223 const page = await ctx.newPage();
224 const consoleErrors = [];
225 page.on('pageerror', (err) => {
226 consoleErrors.push(`pageerror: ${err.message}\n${err.stack || ''}`);
227 });
228 page.on('console', (msg) => {
229 if (msg.type() === 'error') consoleErrors.push(`console.error: ${msg.text()}`);
230 });
231
232 await page.goto(`${scheme}://127.0.0.1:${devPort}`, {
233 waitUntil: 'domcontentloaded',
234 timeout: 30_000,
235 });
236
237 return {
238 tmp,
239 page,
240 ctx,
241 dev,
242 live,
243 consoleErrors,
244 teardown,
245 };
246 } catch (err) {
247 if (dev?.log) err.message += `\n\n--- dev server tail ---\n${dev.log()}`;
248 await teardown();
249 throw err;
250 }
251}