session.mjs

  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}