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
@@ -674,6 +674,62 @@ export async function clickPrev(page) {
|
||||
await clickBarButton(page, '←');
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until one variant step has fully landed: the bar counter, the bar's own
|
||||
* notion of the visible variant, and (on component previews) the variant that
|
||||
* is actually mounted all agree.
|
||||
*
|
||||
* Reading the counter alone is not enough. The counter can still show the
|
||||
* previous step when the next click goes out, and two clicks that arrive
|
||||
* inside one settle window leave the session on a variant nobody asked for.
|
||||
*/
|
||||
export async function waitForVariantSettled(page, expected, count, { timeout = 15_000 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
({ expected, count, barSel }) => {
|
||||
const bar = window.__impeccableLiveQuery(barSel);
|
||||
if (!bar || !(bar.textContent || '').includes(`${expected}/${count}`)) return false;
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.();
|
||||
if (!debugState) return true;
|
||||
if (debugState.visibleVariant !== expected) return false;
|
||||
if (debugState.hasSvelteComponentSession && debugState.mountedSvelteVariant !== expected) return false;
|
||||
return true;
|
||||
},
|
||||
{ expected, count, barSel: BAR_ID },
|
||||
{ timeout },
|
||||
);
|
||||
} catch (err) {
|
||||
const debugState = await page
|
||||
.evaluate(() => window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null)
|
||||
.catch(() => null);
|
||||
throw new Error(
|
||||
`variant ${expected}/${count} never settled; debugState=${JSON.stringify(debugState)} (${err.message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Step the comparison to `targetVariant`, one settled click at a time.
|
||||
* Returns the variant now visible.
|
||||
*/
|
||||
export async function cycleToVariant(page, targetVariant, count, { settleTimeout = 15_000 } = {}) {
|
||||
let visible = await getVisibleVariant(page);
|
||||
let steps = 0;
|
||||
while (visible !== targetVariant) {
|
||||
if (steps++ > count + 6) {
|
||||
throw new Error(`variant ${targetVariant} did not become visible; last visible=${visible}`);
|
||||
}
|
||||
const forward = visible == null || visible < targetVariant;
|
||||
const next = visible == null ? 1 : (forward ? visible + 1 : visible - 1);
|
||||
if (forward) await clickNext(page);
|
||||
else await clickPrev(page);
|
||||
await waitForVariantSettled(page, next, count, { timeout: settleTimeout });
|
||||
visible = await getVisibleVariant(page);
|
||||
}
|
||||
return visible;
|
||||
}
|
||||
|
||||
function barButtonMatch(label) {
|
||||
if (label instanceof RegExp) return { kind: 'regex', source: label.source, flags: label.flags };
|
||||
if (label && typeof label === 'object' && label.ariaLabel) return { kind: 'aria', value: label.ariaLabel };
|
||||
@@ -769,6 +825,192 @@ export async function getVisibleVariant(page) {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tune popover
|
||||
//
|
||||
// buildParamsPanel renders one row per param with no stable ids: a label row
|
||||
// (label span + readout span) followed by the control (range input, toggle
|
||||
// track button, or a segmented row of buttons). The label text is the only
|
||||
// handle a user has too, so that is what these helpers match on.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const TUNE_BUTTON = '[data-iceq-tune="1"]';
|
||||
const PARAMS_PANEL_ID = '#impeccable-live-params-panel';
|
||||
|
||||
/** Open the Tune popover and wait for its rows to render. */
|
||||
export async function openTunePanel(page, { timeout = 5_000 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
await page.waitForFunction(
|
||||
(sel) => {
|
||||
const tune = window.__impeccableLiveQuery(sel);
|
||||
return Boolean(tune) && tune.disabled === false;
|
||||
},
|
||||
TUNE_BUTTON,
|
||||
{ timeout },
|
||||
);
|
||||
await clickLiveControl(page, TUNE_BUTTON);
|
||||
await page.waitForFunction(
|
||||
(sel) => (window.__impeccableLiveQuery(sel)?.querySelectorAll(':scope > div > div').length || 0) > 0,
|
||||
PARAMS_PANEL_ID,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag a `range` knob to `value`. Setting `.value` + dispatching `input` is
|
||||
* what a real drag produces; the panel's listener reads the input, not the
|
||||
* event, so this exercises the same code path.
|
||||
*/
|
||||
export async function setTuneRange(page, label, value) {
|
||||
await installLiveQueryHelpers(page);
|
||||
const applied = await page.evaluate(({ panelSel, label, value }) => {
|
||||
const panel = window.__impeccableLiveQuery(panelSel);
|
||||
const row = [...(panel?.querySelectorAll(':scope > div > div') || [])]
|
||||
.find((candidate) => candidate.querySelector('span')?.textContent?.trim() === label);
|
||||
const input = row?.querySelector('input[type="range"]');
|
||||
if (!input) return null;
|
||||
input.value = String(value);
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
return parseFloat(input.value);
|
||||
}, { panelSel: PARAMS_PANEL_ID, label, value });
|
||||
if (applied == null) {
|
||||
throw new Error(`Tune range "${label}" not found. ${await describeTunePanel(page)}`);
|
||||
}
|
||||
return applied;
|
||||
}
|
||||
|
||||
/** Panel contents + variant state, for failures that would otherwise be mute. */
|
||||
async function describeTunePanel(page) {
|
||||
const snapshot = await page.evaluate((panelSel) => {
|
||||
const panel = window.__impeccableLiveQuery(panelSel);
|
||||
const rows = [...(panel?.querySelectorAll(':scope > div > div') || [])]
|
||||
.map((row) => (row.querySelector('span')?.textContent || '').trim());
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null;
|
||||
return {
|
||||
rows,
|
||||
barText: debugState?.barText || null,
|
||||
visibleVariant: debugState?.visibleVariant ?? null,
|
||||
mountedSvelteVariant: debugState?.mountedSvelteVariant ?? null,
|
||||
state: debugState?.state || null,
|
||||
};
|
||||
}, PARAMS_PANEL_ID).catch((err) => ({ error: err.message }));
|
||||
return `Panel snapshot: ${JSON.stringify(snapshot)}`;
|
||||
}
|
||||
|
||||
/** Click one option of a `steps` param by its visible option label. */
|
||||
export async function chooseTuneStep(page, label, optionLabel) {
|
||||
await installLiveQueryHelpers(page);
|
||||
const clicked = await page.evaluate(({ panelSel, label, optionLabel }) => {
|
||||
const panel = window.__impeccableLiveQuery(panelSel);
|
||||
const row = [...(panel?.querySelectorAll(':scope > div > div') || [])]
|
||||
.find((candidate) => candidate.querySelector('span')?.textContent?.trim() === label);
|
||||
if (!row) return 'no-row';
|
||||
const button = [...row.querySelectorAll('button')]
|
||||
.find((btn) => (btn.textContent || '').trim() === optionLabel);
|
||||
if (!button) return 'no-option';
|
||||
button.click();
|
||||
return 'ok';
|
||||
}, { panelSel: PARAMS_PANEL_ID, label, optionLabel });
|
||||
if (clicked !== 'ok') {
|
||||
throw new Error(`Tune steps "${label}" option "${optionLabel}" not found (${clicked})`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mount-error card (component previews)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MOUNT_ERROR_ID = '#impeccable-live-mount-error';
|
||||
const MOUNT_RETRY = '[data-impeccable-mount-retry="true"]';
|
||||
|
||||
/** Wait for the persistent mount-error card and return its text. */
|
||||
export async function waitForMountErrorCard(page, { variant, timeout = 20_000 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
await page.waitForFunction(
|
||||
({ sel, variant }) => {
|
||||
const card = window.__impeccableLiveQuery(sel);
|
||||
if (!card) return false;
|
||||
if (variant == null) return true;
|
||||
return (card.textContent || '').includes(`Variant ${variant} failed to load`);
|
||||
},
|
||||
{ sel: MOUNT_ERROR_ID, variant: variant ?? null },
|
||||
{ timeout },
|
||||
);
|
||||
return page.evaluate((sel) => window.__impeccableLiveQuery(sel)?.textContent || '', MOUNT_ERROR_ID);
|
||||
}
|
||||
|
||||
export async function isMountErrorCardVisible(page) {
|
||||
await installLiveQueryHelpers(page);
|
||||
return page.evaluate((sel) => Boolean(window.__impeccableLiveQuery(sel)), MOUNT_ERROR_ID);
|
||||
}
|
||||
|
||||
/** Click the card's Retry button (re-imports the manifest's current revision). */
|
||||
export async function clickMountRetry(page) {
|
||||
await installLiveQueryHelpers(page);
|
||||
const clicked = await page.evaluate(({ cardSel, retrySel }) => {
|
||||
const button = window.__impeccableLiveQuery(cardSel)?.querySelector(retrySel);
|
||||
if (!button) return false;
|
||||
button.click();
|
||||
return true;
|
||||
}, { cardSel: MOUNT_ERROR_ID, retrySel: MOUNT_RETRY });
|
||||
if (!clicked) throw new Error('mount-error Retry button not found');
|
||||
}
|
||||
|
||||
export async function waitForMountErrorCardGone(page, { timeout = 20_000 } = {}) {
|
||||
await installLiveQueryHelpers(page);
|
||||
await page.waitForFunction(
|
||||
(sel) => !window.__impeccableLiveQuery(sel),
|
||||
MOUNT_ERROR_ID,
|
||||
{ timeout },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait until the picked element renders with `expected` computed font-weight.
|
||||
* The fake agent gives every variant a distinct weight, so this is the render
|
||||
* proof that variant N actually mounted (see FAKE_VARIANT_FONT_WEIGHTS).
|
||||
*/
|
||||
export async function waitForComputedFontWeight(page, selector, expected, { timeout = 10_000 } = {}) {
|
||||
try {
|
||||
await page.waitForFunction(
|
||||
({ sel, expected }) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
return Boolean(el) && getComputedStyle(el).fontWeight === expected;
|
||||
},
|
||||
{ sel: selector, expected: String(expected) },
|
||||
{ timeout },
|
||||
);
|
||||
} catch (err) {
|
||||
const snapshot = await page.evaluate((sel) => {
|
||||
const el = document.querySelector(sel);
|
||||
const debugState = window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null;
|
||||
const mount = document.querySelector('[data-impeccable-component-mount]');
|
||||
return {
|
||||
found: Boolean(el),
|
||||
fontWeight: el ? getComputedStyle(el).fontWeight : null,
|
||||
outerHTML: el?.outerHTML?.slice(0, 400) || null,
|
||||
mountHTML: mount?.outerHTML?.slice(0, 400) || null,
|
||||
barText: debugState?.barText || null,
|
||||
state: debugState?.state || null,
|
||||
visibleVariant: debugState?.visibleVariant ?? null,
|
||||
mountedSvelteVariant: debugState?.mountedSvelteVariant ?? null,
|
||||
};
|
||||
}, selector).catch((snapErr) => ({ error: snapErr.message }));
|
||||
throw new Error(
|
||||
`expected computed font-weight ${expected} on ${selector}; snapshot: ${JSON.stringify(snapshot)} (${err.message})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function readComputedFontWeight(page, selector) {
|
||||
return page.evaluate((sel) => {
|
||||
const query = window.__impeccableLiveQuery || ((s) => document.querySelector(s));
|
||||
const el = query(sel) || document.querySelector(sel);
|
||||
return el ? getComputedStyle(el).fontWeight : null;
|
||||
}, selector);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click Accept — sends accept event with current variantId + paramValues.
|
||||
* The bar transitions to a "Saving..." spinner, then a green confirmed row.
|
||||
|
||||
Reference in New Issue
Block a user