mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 14:16:28 +03:00
* Tests: stop the harness leaking live-server processes
Nothing owned a live server past the exit paths JavaScript can observe. The
live unit tests spawn the server as a direct child and stop it with an HTTP
/stop plus proc.kill() inside an after() hook; the e2e session and the
target-context tests boot it through `live-server --background` / live.mjs,
which spawns a detached, unref'd daemon that only the `stop` verb ever ends.
A POSIX child does not die with its parent, and a detached daemon is orphaned
to pid 1 from birth, so any exit that skipped teardown (a node:test timeout, a
SIGKILL of the runner, a Ctrl-C, an assertion that threw before the hook) left
the server listening on a fixed live-suite port for good. scripts/run-tests.mjs
did not compensate: it used blocking spawnSync, so no signal handler could run;
it left suite commands in its own process group with nothing that could kill
that group; and it never checked afterwards whether anything survived. Days of
local runs accumulated 197 orphans on one machine, the oldest four days old,
until `bun run test:live` could not claim its ports.
The fix is structural rather than a cleanup sweep bolted on the end, and it is
deliberately implementation-agnostic so it holds for the Node scripts here and
for the Rust `impeccable live-server` on rust-swap:
- tests/lib/live-servers.mjs. armLiveServerReaper(), called once at module
scope by every test file that starts a server, stamps the process env with a
unique marker, installs exit and signal handlers, and spawns a detached
reaper holding a pipe to the process. SIGKILL the process and the pipe closes,
the reaper wakes on EOF and kills the servers carrying that marker. That is
the one case no in-process cleanup can reach. trackServerChild() also
registers direct children (live servers and fixture dev servers) so the
ordinary exits are a cheap kill by handle.
- scripts/lib/live-server-processes.mjs. The scan and kill primitives, shared
by the reaper and the runner. Processes are matched by the environment marker
the harness exported, never by name or port, so a sweep can only ever reach a
server this repo's tests started.
- scripts/run-tests.mjs. Each suite command now runs as its own process-group
leader with SIGINT/SIGTERM/SIGHUP forwarded to the group, and after every
suite the runner checks for live servers carrying that suite's run id. A
survivor is killed and fails the run, so the next leak surfaces in the run
that caused it instead of on a laptop days later. IMPECCABLE_SKIP_LEAK_CHECK=1
bypasses it. `bun run test:cleanup` sweeps leftovers from earlier runs.
- tests/live-server-leak.test.mjs pins the guarantee: it boots a real server
under a process it then SIGKILLs, and fails if the server outlives it. With
IMPECCABLE_NO_TEST_REAPER=1 the test fails, which is what makes it a
regression test rather than a tautology.
Verified: bun run test:live green with zero survivors; scoped live-e2e
(vite8-react-plain) matches pristine main test for test; the SIGKILL repro goes
from 2 orphans to 0; SIGINT and SIGKILL of the runner itself both leave nothing
behind; bun run build green.
Fixes #717
AI assistance: prepared by Claude Code under pbakaus's direction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
* Review fixes: scope the sweep to whole env entries only
Five review findings on #718, all in the matching layer that decides which
processes a sweep may touch.
The repository-path fallback is gone (Greptile P1). `bun run test:cleanup`
passed REPO_ROOT to findLiveServers, which then also matched any live-server
command line under the checkout, marker or not. A developer running
`impeccable live` in this repo has exactly that command line, so the cleanup
could have killed their own session. The PR promised matching on the exported
environment marker and nothing else; now it does. The cost is that a server
from a run predating the marker is no longer found and has to be killed by
hand, which is the right trade.
Environment entries are compared whole on macOS and BSD (Greptile P1). `ps -E`
flattens the environment into the command column, and that line was searched
with a plain substring test, so IMPECCABLE_TEST_REPO=/work/impeccable also
matched /work/impeccable-copy and one checkout's cleanup could reach a
neighbouring checkout's servers. envLineHasEntry() now requires the marker to
start an entry (line start or whitespace) and to end one (line end, or
whitespace followed by the next KEY=), which is the same whole-entry
comparison the Linux /proc branch already did. Six unit tests cover it,
including the adjacent-path negative case, and a live probe against real
`ps -E` output confirms an exact repo matches while /work/impeccable-copy and
a run-id prefix do not.
The SIGKILL regression test now skips on win32 with a stated reason (Copilot).
The reaper is a POSIX mechanism and armLiveServerReaper() does not arm it
there, so the test asserted a guarantee Windows does not make yet.
Signal exits use the shell convention 128 + signum in both the runner and the
test helper (Copilot, two threads). SIGHUP returned 143; it is 129. Read from
os.constants.signals rather than a hand-written table.
Verified: leak test 7/7 (2 guard, 5 matcher); bun run test:live 895 tests, 0
fail, 0 survivors; scoped live-e2e (vite8-react-plain) 3 pass / 1 fail,
matching pristine main; SIGKILL repro 3 servers up, 0 after; bun run build
green.
AI assistance: prepared by Claude Code under pbakaus's direction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
* Review fix: make marker values opaque so the matcher has no ambiguous case
Greptile's follow-up P1 on the parser was right, and the parser was the wrong
place to answer it. envLineHasEntry ended an entry at "whitespace followed by
the next KEY=", so a checkout path that extended another one with whitespace
plus a KEY=-shaped token still defeated it, which is exactly the ambiguity the
docblock admitted to. A format that cannot be parsed unambiguously should not
be handed ambiguous input.
So the fix is at the source: no marker value is a path any more. IMPECCABLE_TEST_REPO
now carries repoMarker(), the first 16 hex characters of the sha256 of the
checkout's real path, and the runner and the cleanup command both compute it
the same way from REPO_ROOT. Two checkouts whose paths share a prefix get
unrelated hashes, so a substring cannot arise in the first place, and every
spelling of one checkout (trailing slash, `.` segment, symlink, /private
prefix) resolves to one marker. The run id is now repoMarker plus 8 random
bytes of hex, and the process id p<pid> plus the same, both from a
whitespace-free alphabet.
With every value fixed-alphabet, envLineHasEntry needs only "starts an entry
and ends at whitespace or line end". The KEY= lookahead is gone and so is the
documented unresolvable case. assertMarkerValue keeps the invariant honest: it
refuses any value outside [A-Za-z0-9_-] with a message that says to hash it,
so a future caller that passes a path gets a loud error instead of a silent
mismatch. The readable path is still available for a human reading `ps -E`
output, exported separately as IMPECCABLE_TEST_REPO_PATH, which nothing
matches on and the docblock says so.
Matcher tests: the space-in-value case is gone, since that value can no longer
exist. Added a strict-prefix case (a longer hash-shaped value starting with the
marker), an adjacent-checkout case asserting the two hashes do not even share a
prefix, a symlink/trailing-slash case against real directories, an alphabet
check on all three generators, and one asserting assertMarkerValue throws.
Verified: leak test 10/10; bun run test:live 898 tests, 0 fail, 0 survivors;
scoped live-e2e (vite8-react-plain) 3 pass / 1 fail, matching pristine main;
SIGKILL repro 1 server up, 0 after; bun run build green. A probe against real
`ps -E` output with a hashed marker: this checkout 1 match, its trailing-slash
spelling 1, an adjacent checkout 0, exact run id 1, a run-id prefix 0.
AI assistance: prepared by Claude Code under pbakaus's direction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
* Review fixes: async group shutdown, and a Windows-safe symlink test
Two Cursor Bugbot findings, both real.
killCurrentGroup busy-waited on alive(child.pid) after sending SIGTERM, which
could never work. A dead child stays a zombie until its parent reaps it, the
parent here is the runner, and the runner reaps through libuv when the event
loop runs. The spin blocked the very loop that would have done the reaping and
then read the unreaped zombie as alive, so every SIGINT, SIGTERM and SIGHUP
burned the full 2s grace and ended in a needless SIGKILL. There is no waitpid
from JavaScript that sees through this, so the wait is now asynchronous and
keyed on the child's own exit event. The logic moved to
scripts/lib/process-group.mjs: trackChildExit exposes the exit as a flag and a
promise, stopGroup races that promise against the grace period and escalates to
SIGKILL only if it loses, and killGroupSync stays synchronous for
process.on('exit'), where nothing can be awaited, so it sends SIGTERM then
SIGKILL without pretending to wait. A second Ctrl-C now skips the grace period
entirely rather than queueing behind it.
Measured on a real SIGINT to a running live suite: 2027ms before, 34ms after.
tests/process-group.test.mjs pins both halves, including the escalation path
against a child that traps SIGTERM, which is not otherwise reachable from a
registered suite.
The repoMarker symlink test called symlinkSync with no type, which throws EPERM
on Windows without Developer Mode. It now passes 'junction' there and 'dir'
elsewhere, the same shape tests/concept-seed.test.mjs uses, and the
trailing-slash and dot-segment cases split into their own test so they keep
running on every platform regardless.
Merged origin/main (through #716) to re-level the branch.
Verified: leak and process-group tests 16/16; bun run test:live 900 tests, 0
fail, 0 survivors; scoped live-e2e (vite8-react-plain) now 4/4, with the
orphaned-session test that #716 fixed passing in 7.2s; bun run build green.
AI assistance: prepared by Claude Code under pbakaus's direction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
* Review fix: a second Ctrl-C must reach the group the first one is stopping
Cursor Bugbot caught a bug I introduced with the async shutdown, and it is the
same class of leak this PR exists to close. The signal handler cleared
currentChild before awaiting stopGroup, so a second Ctrl-C read a null handle:
killGroupSync did nothing, process.exit walked away from the SIGKILL escalation
still in flight, and because the suite is spawned detached it kept running
after the runner was gone. Impatience with a stuck suite produced exactly the
orphan the change is supposed to prevent.
The shutdown state machine moved into scripts/lib/process-group.mjs as
createGroupShutdown, which holds the group in `stopping` for as long as it is
being ended rather than dropping the only reference to it. A second signal
kills that handle and leaves; process.on('exit') looks at `current` or
`stopping`, so the last-resort path reaches a group mid-shutdown too. The
runner keeps no shutdown state of its own now, which is what made the bug
possible to write in the first place.
The extraction is what makes it testable: `exit` is injectable, so
tests/process-group.test.mjs can drive two signals at a stubborn child that
traps SIGTERM and assert the group dies in under 2s against a 30s grace. Point
that test at the old logic (killGroupSync on the cleared reference) and it
hangs out the full grace and fails, which is the check that it pins something
real. Five cases in all, including the exit-handler path and the no-child case.
Verified: process-group 10/10, live-server-leak 11/11; real double SIGINT to a
running live suite exits in 24ms with zero group members and zero servers left;
bun run test:live 900 tests, 0 fail, 0 survivors; scoped live-e2e
(vite8-react-plain) 4/4; bun run build green.
The core suite wedged twice locally in tests/build-phase.test.mjs, the
pre-existing unbounded-spawnSync hang noted in the PR description that
rust-swap's 47f18713 fixes. Unrelated to this change: CI is green on both Node
versions, and process-group.test.mjs passes inside that batch.
AI assistance: prepared by Claude Code under pbakaus's direction.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
501 lines
19 KiB
JavaScript
501 lines
19 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)
|
|
* ...or, for a fixture declaring runtime.appDir, one live.mjs boot from
|
|
* the repo root that resolves the app, starts the server, and injects
|
|
* - 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, existsSync, mkdirSync, 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';
|
|
import { armLiveServerReaper, trackServerChild } from '../lib/live-servers.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
const REPO_ROOT = join(__dirname, '..', '..');
|
|
const SCRIPTS_DIR = join(REPO_ROOT, 'skill', 'scripts');
|
|
const FIXTURES_DIR = join(REPO_ROOT, 'tests', 'framework-fixtures');
|
|
|
|
// Live servers here are detached daemons (`live-server --background`, or a full
|
|
// `live` boot), orphaned to pid 1 by design; teardown() is the only thing that
|
|
// stops them. The reaper covers the runs where teardown never happens.
|
|
armLiveServerReaper();
|
|
|
|
export { SCRIPTS_DIR, FIXTURES_DIR, REPO_ROOT };
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// App directory
|
|
//
|
|
// Most fixtures are their own app: the repo root is what the dev server
|
|
// serves. A fixture that declares `runtime.appDir` puts the served app one or
|
|
// more levels below the repo root (the shape live mode's root resolution has
|
|
// to auto-detect). For those, install, the live config, the dev server, and
|
|
// every fixture-relative source path belong to the app dir; git stays at the
|
|
// repo root.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function appDirFor(fixture) {
|
|
const dir = fixture?.runtime?.appDir;
|
|
return typeof dir === 'string' && dir !== '' && dir !== '.' ? dir : null;
|
|
}
|
|
|
|
export function appRootFor(tmp, fixture) {
|
|
const dir = appDirFor(fixture);
|
|
return dir ? join(tmp, dir) : tmp;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Stage
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export function stageFixture(name, fixture, { 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);
|
|
const appRoot = appRootFor(tmp, fixture);
|
|
mkdirSync(join(appRoot, '.impeccable', 'live'), { recursive: true });
|
|
writeFileSync(join(appRoot, '.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, { timeoutMs = readTimeoutEnv('IMPECCABLE_E2E_INSTALL_TIMEOUT_MS', 180_000) } = {}) {
|
|
const [cmd, ...args] = command;
|
|
const installArgs = addNpmInstallDefaults(cmd, args);
|
|
try {
|
|
execFileSync(cmd, installArgs, { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
|
|
repairMissingRollupOptionalBinary(tmp, { timeoutMs });
|
|
} catch (err) {
|
|
if (err.signal === 'SIGTERM' || err.signal === 'SIGKILL' || err.killed) {
|
|
err.message = `fixture dependency install timed out after ${timeoutMs}ms: ${cmd} ${installArgs.join(' ')}`;
|
|
}
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function repairMissingRollupOptionalBinary(tmp, { timeoutMs }) {
|
|
if (process.platform !== 'darwin' || process.arch !== 'arm64') return;
|
|
const rollupPackage = join(tmp, 'node_modules', 'rollup', 'package.json');
|
|
const nativePackage = join(tmp, 'node_modules', '@rollup', 'rollup-darwin-arm64', 'package.json');
|
|
if (!existsSync(rollupPackage) || existsSync(nativePackage)) return;
|
|
const version = JSON.parse(readFileSync(rollupPackage, 'utf-8')).version;
|
|
execFileSync('npm', [
|
|
'install', '--no-save', '--no-audit', '--no-fund', '--no-progress',
|
|
`@rollup/rollup-darwin-arm64@${version}`,
|
|
], { cwd: tmp, stdio: 'inherit', timeout: timeoutMs });
|
|
}
|
|
|
|
function addNpmInstallDefaults(cmd, args) {
|
|
if (cmd !== 'npm') return args;
|
|
if (!['install', 'ci'].includes(args[0])) return args;
|
|
const out = [...args];
|
|
// npm can omit platform-specific Rollup binaries unless optional
|
|
// dependencies are requested explicitly (npm/cli#4828). Astro/Vite then
|
|
// fail before Live starts on fresh staged fixtures.
|
|
for (const flag of ['--no-progress', '--include=optional']) {
|
|
if (!out.some((arg) => arg === flag || arg.startsWith(flag + '='))) out.push(flag);
|
|
}
|
|
return out;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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;
|
|
}
|
|
|
|
/**
|
|
* Full live boot through `live.mjs`, the entry point a real agent runs.
|
|
*
|
|
* Used by fixtures whose app is not at the repo root: `cwd` is the repo root,
|
|
* and live.mjs is the step that resolves the roots, persists the manifest and
|
|
* pointer, starts the server under the app, and injects the script tag there.
|
|
* Returns the parsed live.mjs payload plus the {pid, port, token} the rest of
|
|
* the session needs.
|
|
*/
|
|
export function runLiveBoot(cwd, appRoot) {
|
|
const out = execFileSync(
|
|
process.execPath,
|
|
[join(SCRIPTS_DIR, 'live.mjs')],
|
|
{ cwd, encoding: 'utf-8' },
|
|
);
|
|
let boot;
|
|
try {
|
|
boot = JSON.parse(out.trim());
|
|
} catch {
|
|
throw new Error('live.mjs returned unparseable output:\n' + out);
|
|
}
|
|
if (!boot.ok) throw new Error('live.mjs boot failed: ' + JSON.stringify(boot));
|
|
|
|
let pid = null;
|
|
try {
|
|
pid = JSON.parse(readFileSync(join(appRoot, '.impeccable', 'live', 'server.json'), 'utf-8')).pid;
|
|
} catch { /* reported below */ }
|
|
if (!pid || !boot.serverPort) {
|
|
throw new Error('live.mjs boot produced no reachable server: ' + JSON.stringify(boot));
|
|
}
|
|
return { boot, live: { pid, port: boot.serverPort, token: boot.serverToken } };
|
|
}
|
|
|
|
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, token) {
|
|
const out = execFileSync(
|
|
process.execPath,
|
|
[
|
|
join(SCRIPTS_DIR, 'live-inject.mjs'),
|
|
'--port', String(port),
|
|
...(token ? ['--token', String(token)] : []),
|
|
],
|
|
{
|
|
cwd: tmp,
|
|
encoding: 'utf-8',
|
|
env: { ...process.env },
|
|
},
|
|
);
|
|
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 = trackServerChild(spawn(cmd, args, {
|
|
cwd: tmp,
|
|
// runtime.env lets a fixture pin framework behavior. Astro 7 needs
|
|
// ASTRO_DEV_BACKGROUND set: it auto-detects AI-agent environments and
|
|
// daemonizes `astro dev`, which the harness reads as a crashed server.
|
|
env: { ...process.env, FORCE_COLOR: '0', NO_COLOR: '1', ...(runtime.env || {}) },
|
|
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 readyTimeoutMs = readTimeoutEnv(
|
|
'IMPECCABLE_E2E_DEV_READY_TIMEOUT_MS',
|
|
runtime.readyTimeoutMs ?? 120_000,
|
|
);
|
|
const timeout = setTimeout(() => {
|
|
reject(new Error(
|
|
`dev server ready timeout (${readyTimeoutMs}ms). Tail:\n${bufLog.join('')}`,
|
|
));
|
|
}, readyTimeoutMs);
|
|
|
|
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('') };
|
|
}
|
|
|
|
function readTimeoutEnv(name, fallback) {
|
|
const raw = process.env[name];
|
|
if (raw == null || raw === '') return fallback;
|
|
const parsed = Number(raw);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
export async function stopDevServer(child) {
|
|
if (!child || child.exitCode != null || child.signalCode != null) return;
|
|
let didExit = false;
|
|
const exited = new Promise((resolve) => child.once('exit', () => {
|
|
didExit = true;
|
|
resolve();
|
|
}));
|
|
child.kill('SIGTERM');
|
|
const timeoutPromise = new Promise((resolve) => setTimeout(resolve, 5_000));
|
|
await Promise.race([exited, timeoutPromise]);
|
|
if (!didExit && child.exitCode == null && child.signalCode == null) {
|
|
child.kill('SIGKILL');
|
|
await Promise.race([exited, new Promise((resolve) => setTimeout(resolve, 1_000))]);
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// 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 {string=} opts.fixtureRoot fixture directory; defaults to the public framework fixture tree
|
|
* @param {import('playwright').Browser} opts.browser shared browser instance
|
|
* @param {object} opts.agent VariantAgent (defaults to fake)
|
|
* @param {object|function=} opts.wrapTarget live-wrap target or event mapper
|
|
* @param {(context: object) => Promise<object|void>} [opts.startWorker]
|
|
* Optional production worker factory. Return {stop, done}; when used,
|
|
* omit `agent` so the deterministic in-process loop is not started.
|
|
* @param {(context: object) => Promise<void>|void} [opts.prepareTmp]
|
|
* @param {(msg: string) => void} [opts.log]
|
|
*
|
|
* The returned session carries `tmp` (staged repo root, where git lives) and
|
|
* `appRoot` (what the dev server serves). They are the same path unless the
|
|
* fixture declares `runtime.appDir`; resolve fixture-relative source paths
|
|
* against `appRoot`.
|
|
*/
|
|
export async function bootFixtureSession({
|
|
name,
|
|
fixture,
|
|
fixtureRoot,
|
|
browser,
|
|
agent,
|
|
wrapTarget,
|
|
startWorker,
|
|
prepareTmp,
|
|
log = () => {},
|
|
trace = () => {},
|
|
atomicDelayMs = 0,
|
|
keepTmp = false,
|
|
}) {
|
|
const runtime = fixture.runtime;
|
|
if (!runtime) throw new Error(`fixture ${name} has no runtime block`);
|
|
|
|
const tmp = stageFixture(name, fixture, { fixtureRoot });
|
|
const appDir = appDirFor(fixture);
|
|
const appRoot = appRootFor(tmp, fixture);
|
|
let live;
|
|
let liveBoot = null;
|
|
let dev;
|
|
let agentAbort;
|
|
let agentDone;
|
|
let externalWorker;
|
|
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 (externalWorker?.stop) await externalWorker.stop(); } catch {}
|
|
try { if (externalWorker?.done) await externalWorker.done.catch(() => {}); } catch {}
|
|
try { if (dev?.child) await stopDevServer(dev.child); } catch {}
|
|
try { if (live) stopLiveServer(appRoot); } catch {}
|
|
if (!keepTmp) {
|
|
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
|
} else {
|
|
log(`kept staged fixture at ${tmp}`);
|
|
}
|
|
};
|
|
|
|
const stopLiveForDeferredWork = () => {
|
|
if (!live) return;
|
|
stopLiveServer(appRoot);
|
|
live = null;
|
|
};
|
|
|
|
try {
|
|
const startedAt = Date.now();
|
|
if (prepareTmp) await prepareTmp({ tmp, appRoot, fixture, scriptsDir: SCRIPTS_DIR, trace, log });
|
|
trace('setup.install.start', { fixture: name });
|
|
log(`installing deps`);
|
|
runInstall(appRoot, runtime.install);
|
|
trace('setup.install.end', { fixture: name });
|
|
log(`deps installed in ${formatDuration(Date.now() - startedAt)}`);
|
|
|
|
const liveStartedAt = Date.now();
|
|
trace('setup.live_server.start', { fixture: name });
|
|
if (appDir) {
|
|
// The whole point of an appDir fixture: boot from the repo root and let
|
|
// live.mjs find the app, so the run proves root resolution rather than
|
|
// assuming it. live.mjs starts the server and injects in one step.
|
|
log(`booting live.mjs from the repo root (app is ${appDir}/)`);
|
|
const booted = runLiveBoot(tmp, appRoot);
|
|
liveBoot = booted.boot;
|
|
live = booted.live;
|
|
trace('setup.live_server.end', { fixture: name, port: live.port, appRoot: liveBoot.roots?.appRoot });
|
|
log(`live.mjs booted on ${live.port} (appRoot=${liveBoot.roots?.appRoot}) in ${formatDuration(Date.now() - liveStartedAt)}`);
|
|
} else {
|
|
log(`starting live-server`);
|
|
live = startLiveServer(tmp);
|
|
trace('setup.live_server.end', { fixture: name, port: live.port });
|
|
log(`live-server ready in ${formatDuration(Date.now() - liveStartedAt)}`);
|
|
}
|
|
|
|
if (startWorker) {
|
|
trace('setup.worker.start', { fixture: name });
|
|
externalWorker = await startWorker({ tmp, appRoot, fixture, scriptsDir: SCRIPTS_DIR, live, trace, log });
|
|
trace('setup.worker.end', { fixture: name });
|
|
}
|
|
|
|
if (!appDir) {
|
|
const injectStartedAt = Date.now();
|
|
trace('setup.inject.start', { fixture: name });
|
|
log(`live-inject --port ${live.port}`);
|
|
const injectResult = runInject(tmp, live.port, live.token);
|
|
if (!injectResult.ok) throw new Error('live-inject failed: ' + JSON.stringify(injectResult));
|
|
trace('setup.inject.end', { fixture: name, files: injectResult.files || injectResult.pageFiles || [] });
|
|
log(`live-inject complete in ${formatDuration(Date.now() - injectStartedAt)}`);
|
|
} else {
|
|
trace('setup.inject.end', { fixture: name, files: liveBoot.pageFiles || [] });
|
|
log(`live.mjs injected into ${(liveBoot.pageFiles || []).join(', ') || '(nothing)'}`);
|
|
}
|
|
|
|
const devStartedAt = Date.now();
|
|
trace('setup.dev_server.start', { fixture: name });
|
|
log(`spawning dev server: ${runtime.devCommand.join(' ')}`);
|
|
dev = startDevServer(appRoot, runtime);
|
|
const { port: devPort } = await dev.ready;
|
|
trace('setup.dev_server.end', { fixture: name, port: devPort });
|
|
log(`dev server ready on ${devPort} in ${formatDuration(Date.now() - devStartedAt)}`);
|
|
|
|
// Agent loop runs concurrently — abort on teardown.
|
|
if (agent) {
|
|
agentAbort = new AbortController();
|
|
const loopOptions = {
|
|
tmp: appRoot,
|
|
scriptsDir: SCRIPTS_DIR,
|
|
port: live.port,
|
|
token: live.token,
|
|
agent,
|
|
wrapTarget,
|
|
signal: agentAbort.signal,
|
|
trace,
|
|
atomicDelayMs,
|
|
steerSourceFile: runtime.steer?.sourceFile,
|
|
steerTarget: runtime.steer?.target,
|
|
};
|
|
agentDone = Promise.all([runAgentLoop({ ...loopOptions, log: (m) => log('[worker] ' + m) })]);
|
|
}
|
|
|
|
const scheme = runtime.scheme || 'http';
|
|
ctx = await browser.newContext({
|
|
ignoreHTTPSErrors: runtime.ignoreHTTPSErrors === true,
|
|
});
|
|
const page = await ctx.newPage();
|
|
const consoleErrors = [];
|
|
// Failed network requests, kept separately from console text so the
|
|
// assertions can key on the request URL rather than on Chromium's
|
|
// URL-less "Failed to load resource" console string.
|
|
const failedRequests = [];
|
|
page.on('pageerror', (err) => {
|
|
consoleErrors.push(`pageerror: ${err.message}\n${err.stack || ''}`);
|
|
});
|
|
page.on('console', (msg) => {
|
|
if (msg.type() === 'error') {
|
|
// Chromium reports resource failures with the URL only in the message
|
|
// location, not in the text. Append it so the console-hygiene filter
|
|
// can tell a favicon 404 from a live-preview 404.
|
|
let url = '';
|
|
try { url = msg.location()?.url || ''; } catch { /* older playwright */ }
|
|
consoleErrors.push(`console.error: ${msg.text()}${url ? ` [${url}]` : ''}`);
|
|
} else if (process.env.IMPECCABLE_E2E_CONSOLE && /\[impeccable\]|\[vite\]/.test(msg.text())) {
|
|
log(`[console.${msg.type()}] ${msg.text()}`);
|
|
}
|
|
});
|
|
page.on('requestfailed', (req) => {
|
|
let reason = 'request failed';
|
|
try { reason = req.failure()?.errorText || reason; } catch { /* ignore */ }
|
|
failedRequests.push({ url: req.url(), status: 0, reason });
|
|
});
|
|
page.on('response', (res) => {
|
|
const status = res.status();
|
|
if (status >= 400) failedRequests.push({ url: res.url(), status, reason: `HTTP ${status}` });
|
|
});
|
|
if (process.env.IMPECCABLE_E2E_CONSOLE) {
|
|
page.on('framenavigated', (frame) => {
|
|
if (frame === page.mainFrame()) log(`[nav] main frame → ${frame.url()}`);
|
|
});
|
|
}
|
|
|
|
const pageStartedAt = Date.now();
|
|
trace('setup.page_load.start', { fixture: name });
|
|
await page.goto(`${scheme}://127.0.0.1:${devPort}`, {
|
|
waitUntil: 'domcontentloaded',
|
|
timeout: 30_000,
|
|
});
|
|
trace('setup.page_load.end', { fixture: name });
|
|
log(`page loaded in ${formatDuration(Date.now() - pageStartedAt)}`);
|
|
|
|
return {
|
|
tmp,
|
|
appRoot,
|
|
appDir,
|
|
page,
|
|
ctx,
|
|
dev,
|
|
live,
|
|
liveBoot,
|
|
worker: externalWorker,
|
|
consoleErrors,
|
|
failedRequests,
|
|
stopLiveServer: stopLiveForDeferredWork,
|
|
teardown,
|
|
};
|
|
} catch (err) {
|
|
if (dev?.log) err.message += `\n\n--- dev server tail ---\n${dev.log()}`;
|
|
await teardown();
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
function formatDuration(ms) {
|
|
if (ms < 1_000) return `${ms}ms`;
|
|
return `${(ms / 1_000).toFixed(1)}s`;
|
|
}
|