Files
pbakaus_impeccable/tests/live-e2e/session.mjs
T
Paul BakausandClaude Opus 4.7 c8de59d81e test(live): add full-cycle E2E framework-fixture suite with pluggable agent
19 fixtures (11 styling/build variants + 4 conditional-render scenarios + 4
meta-frameworks) drive the entire user flow end-to-end: handshake, pick,
configure, Go, cycle, accept, carbonize cleanup. Each fixture installs real
deps, boots the framework dev server, and runs Playwright Chromium against a
deterministic fake agent that produces realistic variants (colocated style
with @scope rules, full data-impeccable-params manifests covering range +
steps + toggle, JSX/HTML/Svelte syntax-aware rendering).

The agent is pluggable via a one-method interface — generateVariants(event) —
so a future LLM-backed agent slots in by implementing the same shape. The
orchestrator handles wrap, file write, accept, and carbonize cleanup
deterministically regardless of which agent is plugged in.

Schema extensions (tests/framework-fixtures/README.md): runtime block adds
preActions / reloadProbe / pickSelector / scheme / ignoreHTTPSErrors so
fixtures can drive conditional UI (modal, tab, route) before pick and verify
the carbonized variant survives a reload.

Static fixture suite filtered to skip dirs without fixture.json so empty
scaffold dirs no longer break discovery. Total: 178 static checks, 19 E2E
full cycles, ~107s wall clock for the E2E suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 23:37:04 -07:00

251 lines
8.3 KiB
JavaScript

/**
* Per-fixture session lifecycle for live-mode E2E tests.
*
* Composes:
* - tmp staging (clones the fixture, git init, writes the inject config)
* - npm install (the fixture's runtime.install command)
* - live-server.mjs --background (returns {pid, port, token})
* - live-inject.mjs --port (patches the framework HTML entry)
* - the fixture's framework dev server (vite, vite dev, npx vite, ...)
* - Playwright Chromium page
* - the fake-agent poll loop (in this same node process)
*
* Returns handles + a single `teardown()` that cleans them all up in order.
*/
import { execFileSync, spawn } from 'node:child_process';
import { cpSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { runAgentLoop } from './agent.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const REPO_ROOT = join(__dirname, '..', '..');
const SCRIPTS_DIR = join(REPO_ROOT, 'source', 'skills', 'impeccable', 'scripts');
const FIXTURES_DIR = join(REPO_ROOT, 'tests', 'framework-fixtures');
export { SCRIPTS_DIR, FIXTURES_DIR, REPO_ROOT };
// ---------------------------------------------------------------------------
// Stage
// ---------------------------------------------------------------------------
export function stageFixture(name, fixture) {
const fixtureRoot = join(FIXTURES_DIR, name);
const gitignore = readFileSync(join(fixtureRoot, 'gitignore.txt'), 'utf-8');
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-e2e-'));
cpSync(join(fixtureRoot, 'files'), tmp, { recursive: true });
writeFileSync(join(tmp, '.gitignore'), gitignore);
writeFileSync(join(tmp, 'impeccable-live.config.json'), JSON.stringify(fixture.config));
execFileSync('git', ['init', '-q'], { cwd: tmp });
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmp });
execFileSync('git', ['config', 'user.name', 'Fixture'], { cwd: tmp });
execFileSync('git', ['add', '-A'], { cwd: tmp });
execFileSync('git', ['commit', '-qm', 'fixture'], { cwd: tmp });
return tmp;
}
export function runInstall(tmp, command) {
const [cmd, ...args] = command;
execFileSync(cmd, args, { cwd: tmp, stdio: 'inherit' });
}
// ---------------------------------------------------------------------------
// live-server (background mode prints {pid, port, token})
// ---------------------------------------------------------------------------
export function startLiveServer(tmp) {
const out = execFileSync(
process.execPath,
[join(SCRIPTS_DIR, 'live-server.mjs'), '--background'],
{ cwd: tmp, encoding: 'utf-8' },
);
const jsonLine = out.trim().split('\n').filter(Boolean).pop();
const info = JSON.parse(jsonLine);
if (!info.port || !info.pid) {
throw new Error('live-server --background returned unexpected payload: ' + jsonLine);
}
return info;
}
export function stopLiveServer(tmp) {
try {
execFileSync(
process.execPath,
[join(SCRIPTS_DIR, 'live-server.mjs'), 'stop', '--keep-inject'],
{ cwd: tmp, stdio: 'ignore' },
);
} catch { /* already gone */ }
}
export function runInject(tmp, port) {
const out = execFileSync(
process.execPath,
[join(SCRIPTS_DIR, 'live-inject.mjs'), '--port', String(port)],
{
cwd: tmp,
encoding: 'utf-8',
env: { ...process.env, IMPECCABLE_LIVE_CONFIG: join(tmp, 'impeccable-live.config.json') },
},
);
const last = out.trim().split('\n').filter(Boolean).pop();
return JSON.parse(last);
}
// ---------------------------------------------------------------------------
// Framework dev server
// ---------------------------------------------------------------------------
export function startDevServer(tmp, runtime) {
const [cmd, ...args] = runtime.devCommand;
const child = spawn(cmd, args, {
cwd: tmp,
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
});
const readyRe = new RegExp(runtime.readyPattern);
const bufLog = [];
const capture = (chunk) => {
const s = chunk.toString();
bufLog.push(s);
if (bufLog.length > 200) bufLog.shift();
};
child.stdout.on('data', capture);
child.stderr.on('data', capture);
const ready = new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error(
`dev server ready timeout (${runtime.readyTimeoutMs}ms). Tail:\n${bufLog.join('')}`,
));
}, runtime.readyTimeoutMs ?? 120_000);
const checkMatch = (buf) => {
const m = buf.toString().match(readyRe);
if (m && m[1]) {
clearTimeout(timeout);
resolve({ port: Number(m[1]) });
}
};
child.stdout.on('data', checkMatch);
child.stderr.on('data', checkMatch);
child.on('exit', (code) => {
clearTimeout(timeout);
reject(new Error(`dev server exited before ready (code=${code}). Tail:\n${bufLog.join('')}`));
});
});
return { child, ready, log: () => bufLog.join('') };
}
export async function stopDevServer(child) {
if (!child || child.killed) return;
const exited = new Promise((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5_000));
await Promise.race([exited, timeoutPromise]);
if (!child.killed) child.kill('SIGKILL');
}
// ---------------------------------------------------------------------------
// Composite: full stage → ready
// ---------------------------------------------------------------------------
/**
* Boots everything and returns the connected page + handles + teardown.
*
* @param {object} opts
* @param {string} opts.name fixture name
* @param {object} opts.fixture fixture.json contents
* @param {import('playwright').Browser} opts.browser shared browser instance
* @param {object} opts.agent VariantAgent (defaults to fake)
* @param {(msg: string) => void} [opts.log]
*/
export async function bootFixtureSession({ name, fixture, browser, agent, log = () => {} }) {
const runtime = fixture.runtime;
if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
const tmp = stageFixture(name, fixture);
let live;
let dev;
let agentAbort;
let agentDone;
let ctx;
const teardown = async () => {
try { if (ctx) await ctx.close(); } catch {}
try { if (agentAbort) agentAbort.abort(); } catch {}
try { if (agentDone) await agentDone.catch(() => {}); } catch {}
try { if (dev?.child) await stopDevServer(dev.child); } catch {}
try { if (live) stopLiveServer(tmp); } catch {}
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
};
try {
log(`installing deps`);
runInstall(tmp, runtime.install);
log(`starting live-server`);
live = startLiveServer(tmp);
log(`live-inject --port ${live.port}`);
const injectResult = runInject(tmp, live.port);
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
dev = startDevServer(tmp, runtime);
const { port: devPort } = await dev.ready;
log(`dev server ready on ${devPort}`);
// Agent loop runs concurrently — abort on teardown.
agentAbort = new AbortController();
agentDone = runAgentLoop({
tmp,
scriptsDir: SCRIPTS_DIR,
port: live.port,
token: live.token,
agent,
signal: agentAbort.signal,
log: (m) => log('[agent] ' + m),
});
const scheme = runtime.scheme || 'http';
ctx = await browser.newContext({
ignoreHTTPSErrors: runtime.ignoreHTTPSErrors === true,
});
const page = await ctx.newPage();
const consoleErrors = [];
page.on('pageerror', (err) => {
consoleErrors.push(`pageerror: ${err.message}\n${err.stack || ''}`);
});
page.on('console', (msg) => {
if (msg.type() === 'error') consoleErrors.push(`console.error: ${msg.text()}`);
});
await page.goto(`${scheme}://127.0.0.1:${devPort}`, {
waitUntil: 'domcontentloaded',
timeout: 30_000,
});
return {
tmp,
page,
ctx,
dev,
live,
consoleErrors,
teardown,
};
} catch (err) {
if (dev?.log) err.message += `\n\n--- dev server tail ---\n${dev.log()}`;
await teardown();
throw err;
}
}