mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 17:16:46 +03:00
Live v2: root manifest, mount-ack protocol, AST scaffolder, mechanical accept
A ground-up hardening of live mode, driven by a production session in a nested-app monorepo that hit six distinct failure classes. Full design rationale in docs/LIVE-REWRITE-PLAN.md; every Codex-reported failure now has a mechanical fix and a regression test. Roots: live/roots.mjs resolves appRoot/repoRoot/contextRoot once at boot (keyed on dev-server configs, not monorepo brand markers), persists a manifest, and every live CLI re-anchors onto it at startup, so a helper run from the wrong directory can no longer fork session state. Context files are discovered upward to the git root. Render truth: variant_mounted / variant_mount_failed events give the journal per-variant mount state; failures reach the agent's poll queue, raise a persistent error card with Retry (no more localStorage wipe), and an attach probe names root/dev-server mismatches explicitly. The browser rehydrates from the server when localStorage is gone. Svelte: the scaffolder now parses with the app's own svelte 5 compiler. Control flow survives (an each collection crosses the contract as one structured prop), keyed each blocks hydrate synthetic keys, and anything a detached preview cannot support falls back to source-preview instead of shipping a wrong scaffold. Preview modules live in per-publish revision directories, defeating stale transform caches. Accept: CSS is reconciled, not appended. Matching selectors are replaced, params bake from params.json kinds, the compiler's unused-selector pass prunes superseded rules (pre-existing dead rules protected), a selector- loss postcondition refuses any write that would drop hand-written rules, and live-complete refuses to finish while live plumbing remains in source. Also: framework registry (live/frameworks/) with a crash-safe injection journal, session-store snapshot caching with read-only reads, protocol enum consolidation, steer Send button, honest DESIGN-panel empty states. Testing: new unit suites (roots, AST scaffolder, accept CSS, accept pipeline, framework conformance); e2e now fails on preview-tree 404s, proves computed-style mount for every variant, drives the Tune panel through baked params, and injects failures (broken mounts, republish, storage loss). New runtime fixtures: monorepo-nested-vite (repo root != app root) and vite8-sveltekit-stateful (each blocks + state). Nightly full-matrix cron. An independent adversarial review pass preceded this commit; its blocker and major findings are fixed and regression-tested. This work was produced with AI assistance (Claude Code). Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Code
parent
839dd10079
commit
17dabf4b7e
+127
-22
@@ -6,6 +6,8 @@
|
||||
* - 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)
|
||||
@@ -28,6 +30,27 @@ const FIXTURES_DIR = join(REPO_ROOT, 'tests', 'framework-fixtures');
|
||||
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -38,8 +61,9 @@ export function stageFixture(name, fixture, { fixtureRoot = join(FIXTURES_DIR, n
|
||||
const tmp = mkdtempSync(join(tmpdir(), 'impeccable-e2e-'));
|
||||
cpSync(join(fixtureRoot, 'files'), tmp, { recursive: true });
|
||||
writeFileSync(join(tmp, '.gitignore'), gitignore);
|
||||
mkdirSync(join(tmp, '.impeccable', 'live'), { recursive: true });
|
||||
writeFileSync(join(tmp, '.impeccable', 'live', 'config.json'), JSON.stringify(fixture.config));
|
||||
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 });
|
||||
@@ -107,6 +131,39 @@ export function startLiveServer(tmp) {
|
||||
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(
|
||||
@@ -228,6 +285,11 @@ export async function stopDevServer(child) {
|
||||
* 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,
|
||||
@@ -247,7 +309,10 @@ export async function bootFixtureSession({
|
||||
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;
|
||||
@@ -261,7 +326,7 @@ export async function bootFixtureSession({
|
||||
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(tmp); } catch {}
|
||||
try { if (live) stopLiveServer(appRoot); } catch {}
|
||||
if (!keepTmp) {
|
||||
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
|
||||
} else {
|
||||
@@ -271,44 +336,61 @@ export async function bootFixtureSession({
|
||||
|
||||
const stopLiveForDeferredWork = () => {
|
||||
if (!live) return;
|
||||
stopLiveServer(tmp);
|
||||
stopLiveServer(appRoot);
|
||||
live = null;
|
||||
};
|
||||
|
||||
try {
|
||||
const startedAt = Date.now();
|
||||
if (prepareTmp) await prepareTmp({ tmp, fixture, scriptsDir: SCRIPTS_DIR, trace, log });
|
||||
if (prepareTmp) await prepareTmp({ tmp, appRoot, fixture, scriptsDir: SCRIPTS_DIR, trace, log });
|
||||
trace('setup.install.start', { fixture: name });
|
||||
log(`installing deps`);
|
||||
runInstall(tmp, runtime.install);
|
||||
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 });
|
||||
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 (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, fixture, scriptsDir: SCRIPTS_DIR, live, trace, log });
|
||||
externalWorker = await startWorker({ tmp, appRoot, fixture, scriptsDir: SCRIPTS_DIR, live, trace, log });
|
||||
trace('setup.worker.end', { fixture: name });
|
||||
}
|
||||
|
||||
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)}`);
|
||||
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(tmp, runtime);
|
||||
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)}`);
|
||||
@@ -317,7 +399,7 @@ export async function bootFixtureSession({
|
||||
if (agent) {
|
||||
agentAbort = new AbortController();
|
||||
const loopOptions = {
|
||||
tmp,
|
||||
tmp: appRoot,
|
||||
scriptsDir: SCRIPTS_DIR,
|
||||
port: live.port,
|
||||
token: live.token,
|
||||
@@ -338,15 +420,34 @@ export async function bootFixtureSession({
|
||||
});
|
||||
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') consoleErrors.push(`console.error: ${msg.text()}`);
|
||||
else if (process.env.IMPECCABLE_E2E_CONSOLE && /\[impeccable\]|\[vite\]/.test(msg.text())) {
|
||||
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()}`);
|
||||
@@ -364,12 +465,16 @@ export async function bootFixtureSession({
|
||||
|
||||
return {
|
||||
tmp,
|
||||
appRoot,
|
||||
appDir,
|
||||
page,
|
||||
ctx,
|
||||
dev,
|
||||
live,
|
||||
liveBoot,
|
||||
worker: externalWorker,
|
||||
consoleErrors,
|
||||
failedRequests,
|
||||
stopLiveServer: stopLiveForDeferredWork,
|
||||
teardown,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user