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
+508
-87
@@ -22,11 +22,15 @@
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { appendFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, join, relative } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
import { createFakeAgent } from './live-e2e/agent.mjs';
|
||||
import {
|
||||
createFakeAgent,
|
||||
republishSvelteComponentVariants,
|
||||
FAKE_VARIANT_FONT_WEIGHTS,
|
||||
} from './live-e2e/agent.mjs';
|
||||
import { createLlmAgent, resolveLlmAgentConfig } from './live-e2e/agents/llm-agent.mjs';
|
||||
import { bootFixtureSession, FIXTURES_DIR } from './live-e2e/session.mjs';
|
||||
import {
|
||||
@@ -34,11 +38,14 @@ import {
|
||||
assertApplyDockLoading,
|
||||
assertAnnotationUploadEvent,
|
||||
assertSourceApplied,
|
||||
chooseTuneStep,
|
||||
clickExitLiveMode,
|
||||
cycleToVariant,
|
||||
clickAccept,
|
||||
clickApplyEdits,
|
||||
clickEditCopy,
|
||||
clickDiscard,
|
||||
clickMountRetry,
|
||||
clickSaveEdit,
|
||||
clickGo,
|
||||
clickNext,
|
||||
@@ -47,11 +54,18 @@ import {
|
||||
drawAnnotationPinAndStroke,
|
||||
getVisibleVariant,
|
||||
installLiveQueryHelpers,
|
||||
isMountErrorCardVisible,
|
||||
openTunePanel,
|
||||
pickElement,
|
||||
readComputedFontWeight,
|
||||
runLiveChromeBottomBarSmoke,
|
||||
setTuneRange,
|
||||
waitForApplyDockHidden,
|
||||
waitForBarHidden,
|
||||
waitForComputedFontWeight,
|
||||
waitForCycling,
|
||||
waitForMountErrorCard,
|
||||
waitForMountErrorCardGone,
|
||||
runInsertFlow,
|
||||
waitForHandshake,
|
||||
} from './live-e2e/ui.mjs';
|
||||
@@ -130,6 +144,33 @@ function shouldRunScenario(name) {
|
||||
return scenarioNames.size === 0 || scenarioNames.has('all') || scenarioNames.has(name);
|
||||
}
|
||||
|
||||
// Anything served out of the live preview / runtime tree. A 404 here is never
|
||||
// benign: it means the variant preview module or its runtime never loaded, and
|
||||
// the flow silently fell back to the untouched original.
|
||||
const LIVE_TREE_URL_RE = /\.impeccable-live|impeccable\/live\/preview/i;
|
||||
|
||||
// The two framework dev-mode notices every React fixture emits.
|
||||
const FRAMEWORK_NOISE_RE = /Download the React DevTools|StrictMode/i;
|
||||
|
||||
// Chromium's resource-failure console line. Only the favicon flavour is
|
||||
// allowlisted; a favicon is not part of any fixture and its absence proves
|
||||
// nothing about live mode.
|
||||
const RESOURCE_404_RE = /Failed to load resource: the server responded with a status of 404/i;
|
||||
const FAVICON_URL_RE = /favicon(\.[a-z0-9]+)?(\?|$)|\/favicon/i;
|
||||
|
||||
function isLiveTreeUrl(url) {
|
||||
return LIVE_TREE_URL_RE.test(String(url || ''));
|
||||
}
|
||||
|
||||
function isBenignConsoleError(entry) {
|
||||
const text = String(entry || '');
|
||||
// Live-tree failures are never allowlisted, whatever else they match.
|
||||
if (LIVE_TREE_URL_RE.test(text)) return false;
|
||||
if (FRAMEWORK_NOISE_RE.test(text)) return true;
|
||||
if (RESOURCE_404_RE.test(text)) return FAVICON_URL_RE.test(text);
|
||||
return false;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
if (fixtures.length === 0) return;
|
||||
try {
|
||||
@@ -216,7 +257,11 @@ for (const { name, fixture } of fixtures) {
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
|
||||
const { page, tmp, consoleErrors, teardown } = session;
|
||||
// `tmp` is the staged repo root; `appRoot` is the directory the dev
|
||||
// server serves. They differ only for fixtures declaring
|
||||
// `runtime.appDir`. Every fixture-relative source path resolves against
|
||||
// appRoot — the repo root may not contain a single source file.
|
||||
const { page, tmp, appRoot, consoleErrors, teardown } = session;
|
||||
const expectedCount = 3;
|
||||
const isInsert = fixture.runtime.mode === 'insert';
|
||||
const insertCfg = fixture.runtime.insert || {};
|
||||
@@ -228,15 +273,25 @@ for (const { name, fixture } of fixtures) {
|
||||
? insertDomSelector
|
||||
: pickSelector;
|
||||
const usesSvelteComponentPreview = fixtureUsesSvelteKitAdapter(fixture) || name === 'nuxt-vite7';
|
||||
const variantContentSelector = isInsert
|
||||
? (usesSvelteComponentPreview ? '.inserted-copy' : '[data-impeccable-variant="2"] .inserted-copy')
|
||||
// Component previews mount every variant into the same node, so one
|
||||
// selector serves all three; the HTML/JSX paths keep a div per variant.
|
||||
const variantContentSelectorFor = (variantNum) => (isInsert
|
||||
? (usesSvelteComponentPreview ? '.inserted-copy' : `[data-impeccable-variant="${variantNum}"] .inserted-copy`)
|
||||
: usesSvelteComponentPreview
|
||||
? pickSelector
|
||||
: '[data-impeccable-variant="2"] > :first-child';
|
||||
: `[data-impeccable-variant="${variantNum}"] > :first-child`);
|
||||
let stateProbeBaseline = null;
|
||||
let sourceFile = null;
|
||||
|
||||
try {
|
||||
// 0. Root resolution — only for fixtures whose app is not the repo
|
||||
// root. live.mjs booted from the repo root and had to find the app
|
||||
// on its own; everything after this depends on it having done so.
|
||||
if (session.appDir) {
|
||||
t.diagnostic(`Asserting resolved roots for nested app ${session.appDir}/`);
|
||||
assertNestedAppRoots(session);
|
||||
}
|
||||
|
||||
// 1. Handshake
|
||||
t.diagnostic('Waiting for live handshake');
|
||||
await waitForHandshake(page);
|
||||
@@ -255,7 +310,7 @@ for (const { name, fixture } of fixtures) {
|
||||
const steerTimeouts = agentMode === 'llm'
|
||||
? { unlockTimeoutMs: 90_000, selectorTimeoutMs: 45_000, runPreActions }
|
||||
: { runPreActions };
|
||||
await runSteerSmoke(page, tmp, fixture, (m) => t.diagnostic(m), steerTimeouts);
|
||||
await runSteerSmoke(page, appRoot, fixture, (m) => t.diagnostic(m), steerTimeouts);
|
||||
}
|
||||
|
||||
// 2. preActions — fixtures with hidden/conditional content (modals,
|
||||
@@ -278,7 +333,7 @@ for (const { name, fixture } of fixtures) {
|
||||
});
|
||||
} else {
|
||||
t.diagnostic(`Picking ${pickSelector}`);
|
||||
await pickElement(page, pickSelector);
|
||||
await pickElement(page, pickSelector, { position: fixture.runtime.pickPosition });
|
||||
|
||||
if (process.env.IMPECCABLE_E2E_DEBUG) {
|
||||
const barText = await page.evaluate(() => {
|
||||
@@ -317,14 +372,20 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
|
||||
// 5. Source-side check: wrapper + style + variants are present
|
||||
sourceFile = await locateSessionFile(tmp);
|
||||
sourceFile = await locateSessionFile(appRoot);
|
||||
if (session.appDir) {
|
||||
assert.ok(
|
||||
relative(tmp, sourceFile).startsWith(session.appDir + '/'),
|
||||
`session source must live under ${session.appDir}/, got ${relative(tmp, sourceFile)}`,
|
||||
);
|
||||
}
|
||||
const after = readFileSync(sourceFile, 'utf-8');
|
||||
const svelteComponentSession = svelteComponentTargetFor(sourceFile);
|
||||
if (svelteComponentSession) {
|
||||
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
|
||||
const variantFile = join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
|
||||
const variantFile = join(appRoot, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`);
|
||||
const variantBody = readFileSync(variantFile, 'utf-8');
|
||||
const routeBody = readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8');
|
||||
const routeBody = readFileSync(join(appRoot, svelteComponentSession.manifest.sourceFile), 'utf-8');
|
||||
assert.match(after, /"previewMode": "(?:svelte|vue)-component"/, 'framework component manifest inserted');
|
||||
if (isInsert) {
|
||||
assert.equal(svelteComponentSession.manifest.mode, 'insert', 'Svelte insert manifest marks insert mode');
|
||||
@@ -351,14 +412,14 @@ for (const { name, fixture } of fixtures) {
|
||||
}
|
||||
if (insertCfg.assertAnchorContains) {
|
||||
const anchorSource = svelteComponentSession
|
||||
? readFileSync(join(tmp, svelteComponentSession.manifest.sourceFile), 'utf-8')
|
||||
? readFileSync(join(appRoot, svelteComponentSession.manifest.sourceFile), 'utf-8')
|
||||
: after;
|
||||
assert.match(anchorSource, new RegExp(insertCfg.assertAnchorContains), 'anchor section untouched');
|
||||
}
|
||||
}
|
||||
if (svelteComponentSession) {
|
||||
const componentExtension = svelteComponentSession.manifest.componentExtension || 'svelte';
|
||||
assert.match(readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /<style\b/, 'component variant has a style block');
|
||||
assert.match(readFileSync(join(appRoot, svelteComponentSession.manifest.componentDir, `v2.${componentExtension}`), 'utf-8'), /<style\b/, 'component variant has a style block');
|
||||
} else if (sourceFile.endsWith('.astro')) {
|
||||
assert.match(after, /<style is:inline data-impeccable-css="/, 'Astro live CSS uses an inline compiler-bypassing style block');
|
||||
assert.match(
|
||||
@@ -379,7 +440,7 @@ for (const { name, fixture } of fixtures) {
|
||||
// emit no params per the live.md spec ("variants are fixed points").
|
||||
if (agentMode === 'fake') {
|
||||
const paramsSource = svelteComponentSession
|
||||
? readFileSync(join(tmp, svelteComponentSession.manifest.componentDir, 'params.json'), 'utf-8')
|
||||
? readFileSync(join(appRoot, svelteComponentSession.manifest.componentDir, 'params.json'), 'utf-8')
|
||||
: after;
|
||||
assert.match(paramsSource, svelteComponentSession ? /"1"\s*:/ : /data-impeccable-params=/, 'params manifest emitted');
|
||||
for (const kind of ['range', 'steps', 'toggle']) {
|
||||
@@ -400,75 +461,64 @@ for (const { name, fixture } of fixtures) {
|
||||
? fixture.runtime.variantSequence
|
||||
: [2];
|
||||
let visible = await readVisibleVariantForCycle(page);
|
||||
let checkedVariantTwoStyle = false;
|
||||
for (const targetVariant of cycleSequence) {
|
||||
t.diagnostic(`Cycling to variant ${targetVariant}`);
|
||||
let cycleAttempts = 0;
|
||||
while (visible !== targetVariant) {
|
||||
if (cycleAttempts++ > expectedCount + 6) {
|
||||
throw new Error(`variant ${targetVariant} did not become visible; last visible=${visible}`);
|
||||
}
|
||||
if (visible == null || visible < targetVariant) await clickNext(page);
|
||||
else await clickPrev(page);
|
||||
visible = await readVisibleVariantForCycle(page);
|
||||
}
|
||||
assert.equal(visible, targetVariant, `variant ${targetVariant} visible`);
|
||||
if (agentMode === 'fake' && targetVariant === 2 && !checkedVariantTwoStyle) {
|
||||
await page.waitForFunction((sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
return el && getComputedStyle(el).fontWeight === '900';
|
||||
}, variantContentSelector, { timeout: 5_000 }).catch(() => {});
|
||||
const variantWeight = await evaluatePageWithTimeout(
|
||||
// Render proof, not bar-label proof: the fake agent gives every variant
|
||||
// a distinct font-weight, so a computed read says which variant the
|
||||
// user is actually looking at. Checked for each variant the run reaches.
|
||||
const styleCheckedVariants = new Set();
|
||||
const assertVisibleVariantStyle = async (variantNum) => {
|
||||
if (agentMode !== 'fake') return;
|
||||
const expected = FAKE_VARIANT_FONT_WEIGHTS[variantNum];
|
||||
if (!expected || styleCheckedVariants.has(variantNum)) return;
|
||||
styleCheckedVariants.add(variantNum);
|
||||
const selector = variantContentSelectorFor(variantNum);
|
||||
await waitForComputedFontWeight(page, selector, expected, { timeout: 5_000 }).catch(() => {});
|
||||
const variantWeight = await readComputedFontWeight(page, selector).catch(() => null);
|
||||
if (variantWeight !== expected) {
|
||||
const styleSnapshot = await evaluatePageWithTimeout(
|
||||
page,
|
||||
(sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
return el ? getComputedStyle(el).fontWeight : null;
|
||||
},
|
||||
variantContentSelector,
|
||||
5_000,
|
||||
'variant font-weight read',
|
||||
);
|
||||
if (variantWeight !== '900') {
|
||||
const styleSnapshot = await evaluatePageWithTimeout(
|
||||
page,
|
||||
(sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
const styleEl = document.querySelector('style[data-impeccable-css]');
|
||||
const rules = [];
|
||||
for (const sheet of [...document.styleSheets]) {
|
||||
if (sheet.ownerNode !== styleEl) continue;
|
||||
try {
|
||||
rules.push(...[...sheet.cssRules].map((rule) => rule.cssText));
|
||||
} catch (err) {
|
||||
rules.push(`cssRules error: ${err.message}`);
|
||||
}
|
||||
const styleEl = document.querySelector('style[data-impeccable-css]');
|
||||
const rules = [];
|
||||
for (const sheet of [...document.styleSheets]) {
|
||||
if (sheet.ownerNode !== styleEl) continue;
|
||||
try {
|
||||
rules.push(...[...sheet.cssRules].map((rule) => rule.cssText));
|
||||
} catch (err) {
|
||||
rules.push(`cssRules error: ${err.message}`);
|
||||
}
|
||||
return {
|
||||
selector: sel,
|
||||
element: el?.outerHTML || null,
|
||||
parent: el?.parentElement?.outerHTML?.slice(0, 800) || null,
|
||||
computedWeight: el ? getComputedStyle(el).fontWeight : null,
|
||||
styleText: styleEl?.textContent || null,
|
||||
rules,
|
||||
};
|
||||
},
|
||||
variantContentSelector,
|
||||
5_000,
|
||||
'variant style snapshot',
|
||||
).catch((err) => ({ error: err.message }));
|
||||
t.diagnostic('--- variant style snapshot ---');
|
||||
t.diagnostic(JSON.stringify(styleSnapshot, null, 2));
|
||||
}
|
||||
assert.equal(
|
||||
variantWeight,
|
||||
'900',
|
||||
'event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant risk=unstyled_live_preview expected=font-weight 900 actual=' + variantWeight + ' suggestion=inspect live CSS style mode and selector shape',
|
||||
);
|
||||
checkedVariantTwoStyle = true;
|
||||
}
|
||||
return {
|
||||
selector: sel,
|
||||
element: el?.outerHTML || null,
|
||||
parent: el?.parentElement?.outerHTML?.slice(0, 800) || null,
|
||||
computedWeight: el ? getComputedStyle(el).fontWeight : null,
|
||||
styleText: styleEl?.textContent || null,
|
||||
rules,
|
||||
};
|
||||
},
|
||||
selector,
|
||||
5_000,
|
||||
'variant style snapshot',
|
||||
).catch((err) => ({ error: err.message }));
|
||||
t.diagnostic(`--- variant ${variantNum} style snapshot ---`);
|
||||
t.diagnostic(JSON.stringify(styleSnapshot, null, 2));
|
||||
}
|
||||
assert.equal(
|
||||
variantWeight,
|
||||
expected,
|
||||
`event=live_e2e.variant_css_applied actor=browser operation=render_visible_variant variant=${variantNum} risk=unstyled_live_preview expected=font-weight ${expected} actual=${variantWeight} suggestion=inspect live CSS style mode and selector shape`,
|
||||
);
|
||||
};
|
||||
await assertVisibleVariantStyle(visible);
|
||||
for (const targetVariant of cycleSequence) {
|
||||
t.diagnostic(`Cycling to variant ${targetVariant}`);
|
||||
visible = await cycleToVariant(page, targetVariant, expectedCount, {
|
||||
settleTimeout: agentMode === 'llm' ? 60_000 : 15_000,
|
||||
});
|
||||
assert.equal(visible, targetVariant, `variant ${targetVariant} visible`);
|
||||
await assertVisibleVariantStyle(targetVariant);
|
||||
}
|
||||
|
||||
if (reloadVariants && usesSvelteComponentPreview) {
|
||||
@@ -554,11 +604,11 @@ for (const { name, fixture } of fixtures) {
|
||||
const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget });
|
||||
if (svelteComponentTarget) {
|
||||
assert.equal(existsSync(svelteComponentTarget.manifestPath), false, 'Svelte temp preview session removed after accept');
|
||||
const snapshotPath = join(tmp, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`);
|
||||
const snapshotPath = join(appRoot, '.impeccable/live/sessions', `${svelteComponentTarget.manifest.id}.snapshot.json`);
|
||||
const snapshot = JSON.parse(readFileSync(snapshotPath, 'utf-8'));
|
||||
assert.equal(snapshot.phase, 'completed');
|
||||
assert.equal(snapshot.sourceFile, svelteComponentTarget.manifest.sourceFile);
|
||||
assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live/);
|
||||
assert.doesNotMatch(snapshot.sourceFile, /node_modules\/\.impeccable-live|\.impeccable\/live\/previews/);
|
||||
}
|
||||
assert.doesNotMatch(final, /data-impeccable-variants="/, 'variants wrapper removed');
|
||||
assert.doesNotMatch(final, /impeccable-variants-start/, 'variants-start marker removed');
|
||||
@@ -627,11 +677,22 @@ for (const { name, fixture } of fixtures) {
|
||||
await page.waitForSelector(expectSelector, { timeout: 10_000 });
|
||||
}
|
||||
|
||||
// 9c. Network hygiene — nothing under the live preview/runtime tree
|
||||
// may 404 or fail. A missing `.impeccable-live` module means the
|
||||
// preview never mounted, which the DOM assertions above can miss
|
||||
// when the original element still renders.
|
||||
const liveTreeFailures = (session.failedRequests || []).filter((f) => isLiveTreeUrl(f.url));
|
||||
assert.equal(
|
||||
liveTreeFailures.length,
|
||||
0,
|
||||
`live preview/runtime requests must all succeed, got:\n${
|
||||
liveTreeFailures.map((f) => `${f.reason} ${f.url}`).join('\n')
|
||||
}`,
|
||||
);
|
||||
|
||||
// 10. Console hygiene — no errors during the whole flow.
|
||||
if (fixture.runtime.probe?.expectConsoleClean) {
|
||||
const realErrors = consoleErrors.filter((e) =>
|
||||
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(e),
|
||||
);
|
||||
const realErrors = consoleErrors.filter((e) => !isBenignConsoleError(e));
|
||||
if (realErrors.length > 0) {
|
||||
t.diagnostic('--- console errors ---');
|
||||
for (const e of realErrors) t.diagnostic(e);
|
||||
@@ -667,6 +728,201 @@ for (const { name, fixture } of fixtures) {
|
||||
|
||||
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Params end-to-end: knob → accept → baked literal in source.
|
||||
//
|
||||
// The Svelte accept pipeline bakes params mechanically, so the unit
|
||||
// tests already cover the transform. What they cannot cover is that the
|
||||
// value the user actually dialled in the Tune popover is the value that
|
||||
// reaches it. This drives the real controls and reads the real source.
|
||||
// -----------------------------------------------------------------
|
||||
if (shouldRunScenario('params') && fixture.runtime.paramsScenario) {
|
||||
it('bakes tuned param values into the accepted source', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
const scenario = fixture.runtime.paramsScenario;
|
||||
const targetVariant = scenario.variant || 2;
|
||||
const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 });
|
||||
const { session, sourceFile, svelteComponentTarget } = run;
|
||||
const { page } = session;
|
||||
try {
|
||||
await cycleToVariant(page, targetVariant, 3);
|
||||
|
||||
t.diagnostic('Opening the Tune popover');
|
||||
await openTunePanel(page);
|
||||
const applied = await setTuneRange(page, scenario.rangeLabel, scenario.rangeValue);
|
||||
assert.equal(applied, scenario.rangeValue, 'range knob took the requested value');
|
||||
await chooseTuneStep(page, scenario.stepsLabel, scenario.stepsOptionLabel);
|
||||
|
||||
t.diagnostic(`Accepting variant ${targetVariant} with tuned params`);
|
||||
await clickAccept(page, { expectedVariant: targetVariant });
|
||||
await waitForBarHidden(page);
|
||||
const final = await waitForSourceClean(sourceFile, 20_000, { svelteComponentTarget });
|
||||
|
||||
for (const needle of scenario.expectSourceContains || []) {
|
||||
assert.ok(
|
||||
final.includes(needle),
|
||||
`accepted source should bake ${JSON.stringify(needle)}; got:\n${final}`,
|
||||
);
|
||||
}
|
||||
for (const needle of scenario.expectSourceMissing || []) {
|
||||
assert.equal(
|
||||
final.includes(needle),
|
||||
false,
|
||||
`accepted source should drop the unchosen branch ${JSON.stringify(needle)}; got:\n${final}`,
|
||||
);
|
||||
}
|
||||
// The generic contract, independent of this fixture's values: every
|
||||
// preview-only param hook is gone once the source is accepted.
|
||||
assert.doesNotMatch(final, /data-p-[A-Za-z0-9_-]+/, 'no data-p-* param attributes survive accept');
|
||||
assert.doesNotMatch(final, /var\(--p-/, 'no unbaked var(--p-*) survives accept');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------
|
||||
// Failure injection for component previews.
|
||||
// -----------------------------------------------------------------
|
||||
if (fixture.runtime.componentFailureScenarios) {
|
||||
const failure = fixture.runtime.componentFailureScenarios;
|
||||
const brokenVariant = failure.variant || 2;
|
||||
|
||||
if (shouldRunScenario('mount-failure')) {
|
||||
it('surfaces a broken variant without losing the session, and recovers on Retry', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
// Auto-repair off: this scenario is about what the USER sees and can
|
||||
// do when a publish is unrenderable. An agent that silently
|
||||
// republishes would hide exactly the surface under test.
|
||||
const run = await bootComponentScenario({
|
||||
t,
|
||||
name,
|
||||
fixture,
|
||||
expectedCount: 3,
|
||||
agent: createFakeAgent({ autoRepairMountFailures: false }),
|
||||
});
|
||||
const { session, svelteComponentTarget } = run;
|
||||
const { page, appRoot } = session;
|
||||
try {
|
||||
const sessionId = svelteComponentTarget.manifest.id;
|
||||
const variantPath = revisionVariantPath(appRoot, run.manifestPath, brokenVariant);
|
||||
const saved = readFileSync(variantPath, 'utf-8');
|
||||
// Corrupt rather than delete: a published variant that does not
|
||||
// compile is the failure an agent actually produces, and it keeps
|
||||
// the module resolvable so the recovery is about the content.
|
||||
t.diagnostic(`Corrupting published revision file ${relative(appRoot, variantPath)}`);
|
||||
writeFileSync(variantPath, '<div class="impeccable-broken-variant">{ not valid svelte\n', 'utf-8');
|
||||
|
||||
// One step forward onto the variant whose module is gone. The step
|
||||
// deliberately does not settle, so drive the click directly.
|
||||
t.diagnostic(`Stepping onto the broken variant ${brokenVariant}`);
|
||||
await clickNext(page);
|
||||
|
||||
const cardText = await waitForMountErrorCard(page, { variant: brokenVariant });
|
||||
assert.match(cardText, /Retry/, 'the mount-error card offers a Retry');
|
||||
|
||||
// The session must survive the failure: the old behaviour wiped
|
||||
// local state and orphaned a session the server still tracked.
|
||||
const barVisible = await page.evaluate(() => {
|
||||
const bar = window.__impeccableLiveQuery('#impeccable-live-bar');
|
||||
return Boolean(bar) && bar.style.display !== 'none';
|
||||
});
|
||||
assert.equal(barVisible, true, 'the cycling bar survives a mount failure');
|
||||
const stored = await readLiveSessionStorage(page);
|
||||
assert.equal(stored?.id, sessionId, 'the local session record survives a mount failure');
|
||||
|
||||
const events = await waitForJournalEvent(appRoot, sessionId, 'variant_mount_failed', 15_000);
|
||||
const failedEvent = events.at(-1);
|
||||
assert.equal(failedEvent.variant, brokenVariant, 'the journal names the variant that failed');
|
||||
assert.ok(failedEvent.error, 'the journal carries the mount error text');
|
||||
|
||||
// A failed step reverts to the variant that still renders, so the
|
||||
// comparison is usable rather than blank; Retry re-imports the
|
||||
// session, and the repaired variant is reachable again.
|
||||
t.diagnostic('Restoring the revision file and clicking Retry');
|
||||
writeFileSync(variantPath, saved, 'utf-8');
|
||||
await clickMountRetry(page);
|
||||
await waitForMountErrorCardGone(page);
|
||||
await cycleToVariant(page, brokenVariant, 3, { settleTimeout: 20_000 });
|
||||
await waitForComputedFontWeight(
|
||||
page,
|
||||
fixture.runtime.pickSelector || 'h1.hero-title',
|
||||
FAKE_VARIANT_FONT_WEIGHTS[brokenVariant],
|
||||
{ timeout: 20_000 },
|
||||
);
|
||||
assert.equal(await getVisibleVariant(page), brokenVariant, 'the repaired variant is the visible one');
|
||||
assert.equal(await isMountErrorCardVisible(page), false, 'the repaired mount raises no new error');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('republish')) {
|
||||
it('mounts republished variant content instead of a cached compile', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 });
|
||||
const { session } = run;
|
||||
const { page, appRoot } = session;
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
try {
|
||||
await cycleToVariant(page, brokenVariant, 3);
|
||||
await waitForComputedFontWeight(page, pickSelector, FAKE_VARIANT_FONT_WEIGHTS[brokenVariant]);
|
||||
const beforeRevision = readManifest(run.manifestPath).revision;
|
||||
|
||||
t.diagnostic('Republishing every variant with new styling');
|
||||
await republishSvelteComponentVariants({
|
||||
tmp: appRoot,
|
||||
manifestFile: relative(appRoot, run.manifestPath),
|
||||
live: session.live,
|
||||
css: (variantNum, shape) => `${shape.rootSelector} { font-weight: ${100 * variantNum}; }`,
|
||||
});
|
||||
|
||||
// A stale transform cache would keep serving the previous compile;
|
||||
// the server-stamped revision dir is what makes the import path new.
|
||||
await waitForComputedFontWeight(page, pickSelector, String(100 * brokenVariant), { timeout: 20_000 });
|
||||
const afterRevision = readManifest(run.manifestPath).revision;
|
||||
assert.ok(
|
||||
afterRevision > beforeRevision,
|
||||
`republish must bump the preview revision (${beforeRevision} → ${afterRevision})`,
|
||||
);
|
||||
assert.equal(await isMountErrorCardVisible(page), false, 'a clean republish shows no mount error');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (shouldRunScenario('storage-loss') && failure.storageLoss !== false) {
|
||||
it('rehydrates the comparison from the server after localStorage is cleared', liveE2eTestOptions, async (t) => {
|
||||
if (!canRunFakeAgentScenario(t)) return;
|
||||
const run = await bootComponentScenario({ t, name, fixture, expectedCount: 3 });
|
||||
const { session } = run;
|
||||
const { page } = session;
|
||||
try {
|
||||
await cycleToVariant(page, brokenVariant, 3);
|
||||
assert.ok(await readLiveSessionStorage(page), 'a local session record exists before the wipe');
|
||||
|
||||
t.diagnostic('Clearing localStorage and reloading');
|
||||
await page.evaluate(() => localStorage.clear());
|
||||
assert.equal(await readLiveSessionStorage(page), null, 'the local session record really was wiped');
|
||||
await page.reload({ waitUntil: 'domcontentloaded' });
|
||||
await waitForHandshake(page);
|
||||
if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions);
|
||||
|
||||
// Nothing local is left: reaching CYCLING again can only come from
|
||||
// the server's durable session record.
|
||||
await waitForCycling(page, 3, { timeout: 30_000 });
|
||||
const restored = await readLiveSessionStorage(page);
|
||||
assert.equal(restored?.id, run.svelteComponentTarget.manifest.id, 'the adopted session keeps its id');
|
||||
assert.equal(restored?.expected, 3, 'the adopted session knows the variant count');
|
||||
} finally {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (shouldRunScenario('missed-done') && fixture.runtime.missedDoneReloadScenario) {
|
||||
it('recovers when the preflight reload makes the browser miss the done broadcast', liveE2eTestOptions, async (t) => {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
@@ -692,7 +948,7 @@ for (const { name, fixture } of fixtures) {
|
||||
atomicDelayMs: 2500,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const { page, tmp, teardown } = session;
|
||||
const { page, appRoot, teardown } = session;
|
||||
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
||||
try {
|
||||
await waitForHandshake(page);
|
||||
@@ -731,7 +987,7 @@ for (const { name, fixture } of fixtures) {
|
||||
// this stale copy, forcing the completion-driven fallback through
|
||||
// its retry path — a single no-retry read here strands the tab in
|
||||
// GENERATING forever.
|
||||
const scaffoldSourceFile = join(tmp, fixture.runtime.missedDoneReloadScenario.sourceFile);
|
||||
const scaffoldSourceFile = join(appRoot, fixture.runtime.missedDoneReloadScenario.sourceFile);
|
||||
const scaffoldOnlySource = readFileSync(scaffoldSourceFile, 'utf-8');
|
||||
let staleSourceServed = false;
|
||||
await page.context().route('**/source?token=*', (route) => {
|
||||
@@ -867,7 +1123,7 @@ for (const { name, fixture } of fixtures) {
|
||||
assert.ok(existsSync(generateEvent.screenshotPath), 'annotation screenshot file exists');
|
||||
assert.match(generateEvent.screenshotPath, /\.impeccable\/live\/annotations\//, 'annotation screenshot is stored under live annotations');
|
||||
|
||||
const sourceFile = await locateSessionFile(session.tmp);
|
||||
const sourceFile = await locateSessionFile(session.appRoot);
|
||||
const svelteComponentTarget = svelteComponentTargetFor(sourceFile);
|
||||
await clickNext(page);
|
||||
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible after annotated generate');
|
||||
@@ -909,6 +1165,163 @@ for (const { name, fixture } of fixtures) {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The params and failure-injection scenarios assert on deterministic variant
|
||||
* content (exact CSS values, exact font weights). An LLM agent legitimately
|
||||
* produces neither, so they only run against the fake agent.
|
||||
*/
|
||||
function canRunFakeAgentScenario(t) {
|
||||
if (manualOnly || process.env.IMPECCABLE_E2E_MANUAL_SCENARIO) {
|
||||
t.skip('manual scenario filter is active');
|
||||
return false;
|
||||
}
|
||||
if ((process.env.IMPECCABLE_E2E_AGENT || 'fake') !== 'fake') {
|
||||
t.skip('scenario asserts on deterministic fake-agent output');
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boot a fixture straight to CYCLING on a component-preview session and hand
|
||||
* back the handles the scenarios need: the manifest, the route source, and the
|
||||
* session itself. Callers own teardown.
|
||||
*/
|
||||
async function bootComponentScenario({ t, name, fixture, expectedCount = 3, agent }) {
|
||||
const session = await bootFixtureSession({
|
||||
name,
|
||||
fixture,
|
||||
browser,
|
||||
agent: agent || createFakeAgent(),
|
||||
wrapTarget: wrapTargetFromPickedElement,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
try {
|
||||
const { page, appRoot } = session;
|
||||
await waitForHandshake(page);
|
||||
if (fixture.runtime.preActions) await runPreActions(page, fixture.runtime.preActions);
|
||||
await pickElement(page, fixture.runtime.pickSelector || 'h1.hero-title', {
|
||||
position: fixture.runtime.pickPosition,
|
||||
});
|
||||
await clickGo(page);
|
||||
await waitForCyclingRobust(page, expectedCount, {
|
||||
agentMode: 'fake',
|
||||
preActions: fixture.runtime.preActions,
|
||||
log: (m) => t.diagnostic(m),
|
||||
});
|
||||
const manifestPath = await locateSessionFile(appRoot);
|
||||
const svelteComponentTarget = svelteComponentTargetFor(manifestPath);
|
||||
assert.ok(svelteComponentTarget, `expected a component-preview session, got ${manifestPath}`);
|
||||
return {
|
||||
session,
|
||||
manifestPath,
|
||||
svelteComponentTarget,
|
||||
sourceFile: manifestPath,
|
||||
};
|
||||
} catch (err) {
|
||||
await teardownAndResetBrowser(session.teardown);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function readManifest(manifestPath) {
|
||||
return JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The file the browser actually imports for a variant: the server snapshots
|
||||
* every publish into a fresh `r<N>/` dir and points the manifest at it, so the
|
||||
* session dir's copy is not what a mount reads.
|
||||
*/
|
||||
function revisionVariantPath(appRoot, manifestPath, variantNum) {
|
||||
const manifest = readManifest(manifestPath);
|
||||
const dir = manifest.revisionDir || manifest.componentDir;
|
||||
assert.ok(dir, 'manifest carries a preview directory');
|
||||
const extension = manifest.componentExtension || 'svelte';
|
||||
return join(appRoot, dir, `v${variantNum}.${extension}`);
|
||||
}
|
||||
|
||||
/** Poll the session journal for events of a given type. */
|
||||
async function waitForJournalEvent(appRoot, sessionId, type, timeoutMs = 15_000) {
|
||||
const journalPath = join(appRoot, '.impeccable', 'live', 'sessions', `${sessionId}.jsonl`);
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let seen = [];
|
||||
while (Date.now() < deadline) {
|
||||
seen = readJournalEvents(journalPath).filter((event) => event?.type === type);
|
||||
if (seen.length > 0) return seen;
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(
|
||||
`no ${type} event in ${journalPath} after ${timeoutMs}ms; saw types: ${
|
||||
[...new Set(readJournalEvents(journalPath).map((e) => e?.type))].join(', ') || '(none)'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
function readJournalEvents(journalPath) {
|
||||
if (!existsSync(journalPath)) return [];
|
||||
return readFileSync(journalPath, 'utf-8')
|
||||
.split('\n')
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try { return JSON.parse(line)?.event || null; } catch { return null; }
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
/**
|
||||
* Assert the roots live.mjs resolved for an `appDir` fixture.
|
||||
*
|
||||
* The boot ran from the staged repo root, which carries no dev-server config
|
||||
* of its own. Live mode has to pick the nested app, keep PRODUCT.md/DESIGN.md
|
||||
* discovery at the git root, and leave its session state under the app plus a
|
||||
* pointer at the repo root. A repo-root server.json means the session forked
|
||||
* into a second, empty project — the failure this fixture exists to catch.
|
||||
*/
|
||||
function assertNestedAppRoots(session) {
|
||||
const { tmp, appRoot, appDir, liveBoot } = session;
|
||||
assert.ok(liveBoot, 'appDir fixtures boot through live.mjs and keep its payload');
|
||||
assert.equal(liveBoot.ok, true, `live.mjs boot payload: ${JSON.stringify(liveBoot)}`);
|
||||
|
||||
const roots = liveBoot.roots || {};
|
||||
assert.ok(
|
||||
roots.appRoot && roots.appRoot.endsWith(`/${appDir}`),
|
||||
`roots.appRoot should end with /${appDir}, got ${roots.appRoot}`,
|
||||
);
|
||||
assertSamePath(roots.appRoot, appRoot, 'roots.appRoot');
|
||||
assertSamePath(roots.repoRoot, tmp, 'roots.repoRoot');
|
||||
assertSamePath(roots.contextRoot, tmp, 'roots.contextRoot');
|
||||
assertSamePath(liveBoot.projectRoot, appRoot, 'projectRoot');
|
||||
|
||||
assert.equal(liveBoot.hasProduct, true, 'PRODUCT.md at the repo root is read from the nested app');
|
||||
assert.equal(liveBoot.hasDesign, true, 'DESIGN.md at the repo root is read from the nested app');
|
||||
|
||||
assert.ok(
|
||||
existsSync(join(appRoot, '.impeccable/live/roots.json')),
|
||||
'the resolved manifest is persisted under the app',
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(join(appRoot, '.impeccable/live/server.json')),
|
||||
'the live server registers itself under the app',
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(join(tmp, '.impeccable/live/app-root.json')),
|
||||
'the repo root gets a pointer to the app',
|
||||
);
|
||||
assert.equal(
|
||||
existsSync(join(tmp, '.impeccable/live/server.json')),
|
||||
false,
|
||||
'no live session state may be written at the repo root',
|
||||
);
|
||||
}
|
||||
|
||||
function assertSamePath(actual, expected, label) {
|
||||
const resolve = (p) => {
|
||||
try { return realpathSync(String(p)); } catch { return String(p); }
|
||||
};
|
||||
assert.equal(resolve(actual), resolve(expected), `${label}: ${actual} !== ${expected}`);
|
||||
}
|
||||
|
||||
function recordGenerateEvents(agent, events) {
|
||||
return {
|
||||
...agent,
|
||||
@@ -964,6 +1377,11 @@ async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error
|
||||
writeFileSync(join(dir, 'error.txt'), String(error?.stack || error?.message || error || ''), 'utf-8');
|
||||
writeFileSync(join(dir, 'fixture.json'), JSON.stringify(fixture, null, 2), 'utf-8');
|
||||
writeFileSync(join(dir, 'console-errors.log'), (session.consoleErrors || []).join('\n'), 'utf-8');
|
||||
writeFileSync(
|
||||
join(dir, 'failed-requests.log'),
|
||||
(session.failedRequests || []).map((f) => `${f.reason} ${f.url}`).join('\n'),
|
||||
'utf-8',
|
||||
);
|
||||
writeFileSync(join(dir, 'dev-server.log'), session.dev?.log?.() || '', 'utf-8');
|
||||
writeCommandOutput(dir, 'git-status.txt', tmp, ['status', '--short']);
|
||||
writeCommandOutput(dir, 'git-diff.patch', tmp, ['diff', '--', '.']);
|
||||
@@ -983,6 +1401,7 @@ async function captureLiveE2eFailure({ name, fixture, session, sourceFile, error
|
||||
for (const file of walkSources(tmp)) copyFileFromTmp(tmp, file, join(dir, 'sources'));
|
||||
copyDirIfExists(join(tmp, '.impeccable', 'live'), join(dir, 'impeccable-live'));
|
||||
copyDirIfExists(join(tmp, 'node_modules', '.impeccable-live'), join(dir, 'impeccable-live-preview'));
|
||||
copyDirIfExists(join(tmp, '.impeccable', 'live', 'previews'), join(dir, 'impeccable-live-previews'));
|
||||
|
||||
if (session.page) {
|
||||
const html = await withCaptureTimeout(session.page.content(), 5_000, 'page content').catch((err) => `capture failed: ${err.message}`);
|
||||
@@ -1176,7 +1595,7 @@ async function runManualScenarioActions(page, actions, { t, fixture, session, de
|
||||
}
|
||||
|
||||
async function runManualEditStage(page, stage, { t, fixture, session, agentMode, defaultSelector }) {
|
||||
const { tmp } = session;
|
||||
const tmp = session.appRoot;
|
||||
|
||||
if (stage.beforeManualEdit) {
|
||||
await runManualScenarioActions(page, stage.beforeManualEdit, {
|
||||
@@ -1323,7 +1742,7 @@ async function runAcceptedVariantCycle(page, { t, fixture, session, pickSelector
|
||||
await clickNext(page);
|
||||
assert.equal(await getVisibleVariant(page), 2, 'variant 2 visible before manual scenario accept');
|
||||
await clickAccept(page, { expectedVariant: 2 });
|
||||
const sourceFile = await locateSessionFile(session.tmp);
|
||||
const sourceFile = await locateSessionFile(session.appRoot);
|
||||
await waitForSourceClean(sourceFile, 20_000);
|
||||
await waitForBarHidden(page, { timeout: 10_000 }).catch(() => {});
|
||||
|
||||
@@ -1610,6 +2029,7 @@ function svelteComponentTargetFor(filePath) {
|
||||
if (manifest.previewMode !== 'svelte-component' || !manifest.sourceFile || !manifest.componentDir) return null;
|
||||
const sep = pathSepFor(filePath);
|
||||
const markers = [
|
||||
`${sep}.impeccable${sep}live${sep}previews${sep}`,
|
||||
`${sep}node_modules${sep}.impeccable-live${sep}`,
|
||||
`${sep}src${sep}lib${sep}impeccable${sep}`,
|
||||
`${sep}app${sep}.impeccable-live${sep}`,
|
||||
@@ -1712,6 +2132,7 @@ async function locateSessionFile(tmp) {
|
||||
function walkComponentManifests(root) {
|
||||
const results = [];
|
||||
const stack = [
|
||||
join(root, '.impeccable/live/previews'),
|
||||
join(root, 'node_modules/.impeccable-live'),
|
||||
join(root, 'src/lib/impeccable'),
|
||||
join(root, 'app/.impeccable-live'),
|
||||
|
||||
Reference in New Issue
Block a user