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
+349
-7
@@ -89,13 +89,25 @@ export const STEER_MARKER_VALUE = 'e2e';
|
||||
* - first variant visible (no display:none), rest hidden by the agent caller
|
||||
* - inner content = single <h1> per variant
|
||||
*/
|
||||
export function createFakeAgent() {
|
||||
export function createFakeAgent({ autoRepairMountFailures = true } = {}) {
|
||||
return {
|
||||
// Read by runAgentLoop's `variant_mount_failed` handler. Scenarios that
|
||||
// assert on the persistent mount-error card turn this off so the agent
|
||||
// does not republish the session out from under them.
|
||||
autoRepairMountFailures,
|
||||
|
||||
/** @type {LiveAgent['generateVariants']} */
|
||||
async generateVariants(event, context = {}) {
|
||||
if (event.mode === 'insert') {
|
||||
return generateInsertFakeVariants(context);
|
||||
}
|
||||
// Contract-v2 Svelte component previews get their own author path: the
|
||||
// scaffolder already wrote stubs whose control flow and prop references
|
||||
// are correct, so the only honest thing a variant can change is style.
|
||||
if (context.wrapInfo?.previewMode === 'svelte-component') {
|
||||
const svelteOutput = await generateSvelteComponentFakeVariants(event, context);
|
||||
if (svelteOutput) return svelteOutput;
|
||||
}
|
||||
const text = event.element?.textContent?.trim() || extractText(event.element?.outerHTML) || 'Title';
|
||||
const tag = (event.element?.tagName || 'h1').toLowerCase();
|
||||
const cls = (event.element?.classes || ['hero-title'])
|
||||
@@ -105,7 +117,14 @@ export function createFakeAgent() {
|
||||
const preservedAttrs = buildPreservedVariantAttrs(event.element || {}, cls);
|
||||
const elementOpen = `<${tag}${preservedAttrs}>`;
|
||||
const elementClose = `</${tag}>`;
|
||||
const variantHtml = `${elementOpen}${htmlEscape(text)}${elementClose}`;
|
||||
// The JSX source may bind its text through an expression (`{item.title}`
|
||||
// inside a `.map()`). The DOM only ever hands the agent the rendered
|
||||
// string, so writing that back would freeze one item's text into the
|
||||
// template for every item. The Svelte path already solves this with a
|
||||
// prop contract; on the source-preview path the equivalent is to carry
|
||||
// the original expression through untouched.
|
||||
const sourceExprInner = await readJsxExpressionInner(context);
|
||||
const variantHtml = `${elementOpen}${sourceExprInner ?? htmlEscape(text)}${elementClose}`;
|
||||
const useAstroGlobalCss = context.wrapInfo?.styleMode === 'astro-global-prefixed';
|
||||
|
||||
// Variant 1 — red color, with a `range` param tuning hue lightness.
|
||||
@@ -158,20 +177,25 @@ export function createFakeAgent() {
|
||||
// Scoped CSS for most frameworks. Astro component styles are transformed
|
||||
// and scoped by the compiler, so live preview CSS must use a global style
|
||||
// tag plus explicit variant prefixes instead of raw @scope rules.
|
||||
// Each variant carries a distinct font-weight so the E2E suite can prove
|
||||
// which variant is actually rendered, not merely which one the bar says
|
||||
// is selected. Keep these in sync with FAKE_VARIANT_FONT_WEIGHTS.
|
||||
const scopedCss = useAstroGlobalCss
|
||||
? [
|
||||
`[data-impeccable-variant="1"] > ${tag} {`,
|
||||
' font-weight: 300;',
|
||||
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
|
||||
'}',
|
||||
`[data-impeccable-variant="2"] > ${tag} { font-weight: 900; }`,
|
||||
`[data-impeccable-variant="2"][data-p-face="serif"] > ${tag} { font-family: ui-serif, serif; }`,
|
||||
`[data-impeccable-variant="2"][data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`,
|
||||
`[data-impeccable-variant="3"] > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`,
|
||||
`[data-impeccable-variant="3"] > ${tag} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`,
|
||||
`[data-impeccable-variant="3"][data-p-italic] > ${tag} { font-style: italic; }`,
|
||||
].join('\n')
|
||||
: [
|
||||
'@scope ([data-impeccable-variant="1"]) {',
|
||||
` :scope > ${tag} {`,
|
||||
' font-weight: 300;',
|
||||
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
|
||||
' }',
|
||||
'}',
|
||||
@@ -181,7 +205,7 @@ export function createFakeAgent() {
|
||||
` :scope[data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`,
|
||||
'}',
|
||||
'@scope ([data-impeccable-variant="3"]) {',
|
||||
` :scope > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`,
|
||||
` :scope > ${tag} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`,
|
||||
` :scope[data-p-italic] > ${tag} { font-style: italic; }`,
|
||||
'}',
|
||||
].join('\n');
|
||||
@@ -256,17 +280,19 @@ function generateInsertFakeVariants(context = {}) {
|
||||
const scopedCss = useAstroGlobalCss
|
||||
? [
|
||||
'[data-impeccable-variant="1"] .inserted-copy {',
|
||||
' font-weight: 300;',
|
||||
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
|
||||
'}',
|
||||
'[data-impeccable-variant="2"] .inserted-copy { font-weight: 900; }',
|
||||
'[data-impeccable-variant="2"][data-p-face="serif"] .inserted-copy { font-family: ui-serif, serif; }',
|
||||
'[data-impeccable-variant="2"][data-p-face="mono"] .inserted-copy { font-family: ui-monospace, monospace; }',
|
||||
'[data-impeccable-variant="3"] .inserted-copy { text-transform: uppercase; letter-spacing: 0.04em; }',
|
||||
'[data-impeccable-variant="3"] .inserted-copy { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }',
|
||||
'[data-impeccable-variant="3"][data-p-italic] .inserted-copy { font-style: italic; }',
|
||||
].join('\n')
|
||||
: [
|
||||
'@scope ([data-impeccable-variant="1"]) {',
|
||||
' :scope .inserted-copy {',
|
||||
' font-weight: 300;',
|
||||
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
|
||||
' }',
|
||||
'}',
|
||||
@@ -276,7 +302,7 @@ function generateInsertFakeVariants(context = {}) {
|
||||
' :scope[data-p-face="mono"] .inserted-copy { font-family: ui-monospace, monospace; }',
|
||||
'}',
|
||||
'@scope ([data-impeccable-variant="3"]) {',
|
||||
' :scope .inserted-copy { text-transform: uppercase; letter-spacing: 0.04em; }',
|
||||
' :scope .inserted-copy { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }',
|
||||
' :scope[data-p-italic] .inserted-copy { font-style: italic; }',
|
||||
'}',
|
||||
].join('\n');
|
||||
@@ -287,6 +313,187 @@ function generateInsertFakeVariants(context = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Svelte component preview (contract v2)
|
||||
//
|
||||
// The scaffolder hands the agent stubs that already carry the selection's
|
||||
// control flow ({#each}, {#if}) and prop references. A variant that re-derives
|
||||
// markup from the live DOM would bake one item's rendered text into the loop
|
||||
// template — exactly the failure the v2 contract exists to prevent. So the fake
|
||||
// agent behaves like a well-behaved real one: it keeps every stub's script,
|
||||
// comment, and markup byte-for-byte and rewrites only the <style> block.
|
||||
//
|
||||
// Each variant gets a distinct computed-style marker on the selection root
|
||||
// (font-weight 300 / 900 / 600) so the E2E suite can prove which variant is
|
||||
// actually mounted, plus the param hooks live.md section 7 describes:
|
||||
// `var(--p-<id>, default)` for range/toggle and `:global([data-p-<id>="…"])`
|
||||
// for steps. Params are declared in componentDir/params.json by the writer.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The computed `font-weight` each fake variant renders with, on every preview
|
||||
* path (HTML/JSX scoped CSS, Astro global-prefixed, Svelte component). The E2E
|
||||
* suite reads these back through getComputedStyle so "variant N is visible" is
|
||||
* a render fact rather than a bar-label claim.
|
||||
*/
|
||||
export const FAKE_VARIANT_FONT_WEIGHTS = { 1: '300', 2: '900', 3: '600' };
|
||||
|
||||
async function generateSvelteComponentFakeVariants(event, context = {}) {
|
||||
const manifest = await readSvelteComponentManifest(context);
|
||||
if (!manifest || manifest.mode === 'insert') return null;
|
||||
|
||||
const shape = svelteSelectionShape(manifest.originalMarkup || '');
|
||||
const count = Math.max(1, Number(event.count) || 3);
|
||||
const variants = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
const variantId = i + 1;
|
||||
variants.push({
|
||||
// Stub-preserving path: the writer ignores innerHtml entirely and keeps
|
||||
// the scaffolded markup. Kept as an empty string so the shared
|
||||
// normalizeVariantOutput pass has a string to walk.
|
||||
innerHtml: '',
|
||||
params: svelteFakeVariantParams(variantId),
|
||||
svelteComponent: { css: svelteFakeVariantCss(variantId, shape) },
|
||||
});
|
||||
}
|
||||
return { scopedCss: '', variants };
|
||||
}
|
||||
|
||||
/**
|
||||
* Selection root + first classed descendant, read off the scaffold's own
|
||||
* `originalMarkup`. Param-conditioned selectors need a descendant: after
|
||||
* accept, `[data-p-*]` is stripped from the selector, and a rule whose whole
|
||||
* selector was the stripped `:global([data-p-x="y"])` would be dropped along
|
||||
* with it. Selections without a classed descendant still declare their params;
|
||||
* they just don't wire CSS to them.
|
||||
*/
|
||||
function svelteSelectionShape(originalMarkup) {
|
||||
const openTags = [...String(originalMarkup || '').matchAll(/<([a-zA-Z][\w:-]*)\b([^>]*)>/g)];
|
||||
const described = openTags.map(([, tag, attrs]) => ({
|
||||
tag: tag.toLowerCase(),
|
||||
className: staticClassToken(attrs),
|
||||
}));
|
||||
const root = described[0] || { tag: 'div', className: '' };
|
||||
const descendant = described.slice(1).find((entry) => entry.className);
|
||||
return {
|
||||
rootSelector: root.className ? `.${root.className}` : root.tag,
|
||||
descendantSelector: descendant ? `.${descendant.className}` : null,
|
||||
};
|
||||
}
|
||||
|
||||
function staticClassToken(attrs) {
|
||||
const match = String(attrs || '').match(/\bclass\s*=\s*(["'])(.*?)\1/);
|
||||
if (!match) return '';
|
||||
return match[2].split(/\s+/).find((token) => token && !token.includes('{')) || '';
|
||||
}
|
||||
|
||||
function svelteFakeVariantParams(variantId) {
|
||||
if (variantId === 1) {
|
||||
return [
|
||||
{ id: 'lightness', kind: 'range', min: 0.3, max: 0.7, step: 0.05, default: 0.5, label: 'Lightness' },
|
||||
];
|
||||
}
|
||||
if (variantId === 2) {
|
||||
return [
|
||||
{ id: 'lead', kind: 'range', min: 1.2, max: 2, step: 0.1, default: 1.4, label: 'Lead' },
|
||||
{
|
||||
id: 'density',
|
||||
kind: 'steps',
|
||||
default: 'airy',
|
||||
label: 'Density',
|
||||
options: [
|
||||
{ value: 'airy', label: 'Airy' },
|
||||
{ value: 'snug', label: 'Snug' },
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
return [{ id: 'italic', kind: 'toggle', default: false, label: 'Italic' }];
|
||||
}
|
||||
|
||||
function svelteFakeVariantCss(variantId, { rootSelector, descendantSelector }) {
|
||||
const lines = [];
|
||||
if (variantId === 1) {
|
||||
lines.push(`${rootSelector} { font-weight: 300; color: oklch(var(--p-lightness, 0.5) 0.25 25); }`);
|
||||
if (descendantSelector) lines.push(`${descendantSelector} { border-radius: 8px; }`);
|
||||
} else if (variantId === 2) {
|
||||
lines.push(`${rootSelector} { font-weight: 900; line-height: var(--p-lead, 1.4); }`);
|
||||
if (descendantSelector) {
|
||||
lines.push(`:global([data-p-density="airy"]) ${descendantSelector} { letter-spacing: 0.14em; }`);
|
||||
lines.push(`:global([data-p-density="snug"]) ${descendantSelector} { letter-spacing: 0.01em; }`);
|
||||
}
|
||||
} else {
|
||||
lines.push(`${rootSelector} { font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }`);
|
||||
if (descendantSelector) {
|
||||
lines.push(`:global([data-p-italic]) ${descendantSelector} { font-style: italic; }`);
|
||||
}
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
async function readSvelteComponentManifest(context = {}) {
|
||||
const { tmp, wrapInfo } = context;
|
||||
if (!tmp || !wrapInfo?.file) return null;
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a variant component's <style> block, keeping everything above it
|
||||
* (script, prop comment, markup) exactly as the scaffolder wrote it.
|
||||
*/
|
||||
export function restyleSvelteComponentSource(source, css) {
|
||||
const text = String(source || '');
|
||||
const styleStart = text.lastIndexOf('<style');
|
||||
const head = (styleStart === -1 ? text : text.slice(0, styleStart)).replace(/\s+$/, '');
|
||||
const body = String(css || '').trim() || ':global(*) {}';
|
||||
const indented = body.split('\n').map((line) => (line.trim() ? ' ' + line : '')).join('\n');
|
||||
return `${head}\n\n<style>\n${indented}\n</style>\n`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-author every variant of a live component session and tell the server the
|
||||
* publish happened, exactly as the poll loop does after a generate. The server
|
||||
* snapshots a fresh `r<N>/` revision dir on the `done` reply, so the browser
|
||||
* imports from a path it has never seen and cannot serve a cached compile of
|
||||
* the previous content.
|
||||
*
|
||||
* @param {object} opts
|
||||
* @param {string} opts.tmp app root (where the manifest path resolves)
|
||||
* @param {string} opts.manifestFile manifest path relative to `tmp`
|
||||
* @param {{port: number, token: string}} opts.live
|
||||
* @param {(variantNum: number, shape: object) => string} opts.css
|
||||
*/
|
||||
export async function republishSvelteComponentVariants({ tmp, manifestFile, live, css }) {
|
||||
const manifestPath = path.join(tmp, manifestFile);
|
||||
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
|
||||
const componentDir = path.join(tmp, manifest.componentDir);
|
||||
const shape = svelteSelectionShape(manifest.originalMarkup || '');
|
||||
const count = Number(manifest.arrivedVariants) || Number(manifest.count) || 1;
|
||||
for (let variantNum = 1; variantNum <= count; variantNum++) {
|
||||
const file = path.join(componentDir, `v${variantNum}.svelte`);
|
||||
let source;
|
||||
try { source = await fs.readFile(file, 'utf-8'); } catch { continue; }
|
||||
await fs.writeFile(file, restyleSvelteComponentSource(source, css(variantNum, shape)), 'utf-8');
|
||||
}
|
||||
const res = await fetch(`http://127.0.0.1:${live.port}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token: live.token,
|
||||
type: 'done',
|
||||
sourceEventType: 'generate',
|
||||
id: manifest.id,
|
||||
file: manifestFile,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) throw new Error(`republish done reply failed: ${res.status} ${await res.text()}`);
|
||||
return { manifest, shape };
|
||||
}
|
||||
|
||||
export function insertTargetFromEvent(event) {
|
||||
const anchor = event?.insert?.anchor || {};
|
||||
const classes = Array.isArray(anchor.classes)
|
||||
@@ -352,6 +559,74 @@ function attrEscape(str, { svelte = false } = {}) {
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* JSX-source counterpart to the Svelte prop contract.
|
||||
*
|
||||
* When the picked element's source content is a bare JSX expression (or text
|
||||
* mixed with expressions), return it verbatim so the variant markup keeps the
|
||||
* binding instead of the rendered snapshot. Returns null for every other
|
||||
* shape, including nested elements, so this stays a narrow substitution rather
|
||||
* than a general source-copy path.
|
||||
*
|
||||
* @param {{ wrapInfo?: object, tmp?: string }} context
|
||||
* @returns {Promise<string|null>}
|
||||
*/
|
||||
async function readJsxExpressionInner(context = {}) {
|
||||
const wrapInfo = context.wrapInfo;
|
||||
if (!wrapInfo) return null;
|
||||
// Svelte previews bind through propContract downstream; leave them alone.
|
||||
if (wrapInfo.previewMode === 'svelte-component') return null;
|
||||
if (wrapInfo.commentSyntax?.open !== '{/*') return null;
|
||||
|
||||
const original = await readOriginalMarkupFromWrap(wrapInfo, context.tmp);
|
||||
const inner = extractInnerSourceMarkup(original);
|
||||
if (inner == null) return null;
|
||||
const trimmed = inner.trim();
|
||||
if (!trimmed || trimmed.includes('<')) return null;
|
||||
if (!/\{[^{}]+\}/.test(trimmed)) return null;
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recover the picked element's source markup from the scaffold. Deferred
|
||||
* writes carry it in `wrapperBlock`; otherwise the wrapper is already in the
|
||||
* file and the same block can be read back from disk.
|
||||
*/
|
||||
async function readOriginalMarkupFromWrap(wrapInfo, tmp) {
|
||||
let text = wrapInfo.wrapperBlock;
|
||||
if (!text && tmp && wrapInfo.file) {
|
||||
try {
|
||||
text = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!text) return null;
|
||||
|
||||
const lines = String(text).split('\n');
|
||||
const startIdx = lines.findIndex((line) => line.includes('data-impeccable-variant="original"'));
|
||||
if (startIdx === -1) return null;
|
||||
const indent = (lines[startIdx].match(/^\s*/) || [''])[0];
|
||||
const closer = `${indent}</div>`;
|
||||
for (let i = startIdx + 1; i < lines.length; i++) {
|
||||
if (lines[i] === closer) return lines.slice(startIdx + 1, i).join('\n');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Content between an element's opening and closing tag, or null. */
|
||||
function extractInnerSourceMarkup(markup) {
|
||||
const src = String(markup || '').trim();
|
||||
if (!src) return null;
|
||||
const open = src.match(/^<([A-Za-z][\w:.-]*)([^>]*)>/);
|
||||
if (!open || open[2].trim().endsWith('/')) return null;
|
||||
const tag = open[1].toLowerCase();
|
||||
const closeIdx = src.toLowerCase().lastIndexOf(`</${tag}`);
|
||||
if (closeIdx < open[0].length) return null;
|
||||
if (!/^<\/[A-Za-z][\w:.-]*\s*>$/.test(src.slice(closeIdx))) return null;
|
||||
return src.slice(open[0].length, closeIdx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate an HTML snippet to JSX. The fake and LLM agents write innerHtml
|
||||
* in HTML form; the orchestrator translates per the target file's syntax.
|
||||
@@ -1402,6 +1677,15 @@ async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writ
|
||||
for (let i = 0; i < output.variants.length; i++) {
|
||||
const variantId = i + 1;
|
||||
const variant = output.variants[i];
|
||||
// Contract-v2 path: keep the scaffolded stub (control flow + prop
|
||||
// references) and swap only its <style> block.
|
||||
if (variant.svelteComponent && !isInsert) {
|
||||
const variantPath = path.join(componentDir, `v${variantId}.svelte`);
|
||||
const stub = await fs.readFile(variantPath, 'utf-8');
|
||||
await fs.writeFile(variantPath, restyleSvelteComponentSource(stub, variant.svelteComponent.css), 'utf-8');
|
||||
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
|
||||
continue;
|
||||
}
|
||||
const tag = firstTagName(variant.innerHtml) || firstTagName(baseMarkup) || 'div';
|
||||
let markup = substituteLiveTextWithProps(variant.innerHtml || '', contract, textValues).trim();
|
||||
if (!isInsert && contract.length > 0 && !propNames.some((name) => markup.includes(`{${name}}`))) {
|
||||
@@ -1573,6 +1857,13 @@ export async function runAgentLoop({
|
||||
steerTarget,
|
||||
}) {
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
// Everything the agent needs to republish a component session without
|
||||
// re-running generate: the scaffold info and the variant set it authored.
|
||||
const publishedComponentSessions = new Map();
|
||||
// A republish that fails to mount for the same reason would loop forever;
|
||||
// repair each (session, variant) at most once and let the user's Retry (or
|
||||
// the mount-error card) own anything beyond that.
|
||||
const repairedMounts = new Set();
|
||||
|
||||
while (!signal.aborted) {
|
||||
let event;
|
||||
@@ -1689,7 +1980,7 @@ export async function runAgentLoop({
|
||||
// the request for the remaining variants completes.
|
||||
trace('agent.generate.start', { id: event.id, count: event.count });
|
||||
let output = normalizeVariantOutput(
|
||||
await agent.generateVariants(event, { wrapTarget, wrapInfo }),
|
||||
await agent.generateVariants(event, { wrapTarget, wrapInfo, tmp }),
|
||||
wrapInfo,
|
||||
);
|
||||
if (atomicDelayMs > 0) {
|
||||
@@ -1706,6 +1997,7 @@ export async function runAgentLoop({
|
||||
trace('agent.write.start', { id: event.id, file: wrapInfo.file });
|
||||
if (wrapInfo.previewMode === 'svelte-component') {
|
||||
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output, writeParams: true });
|
||||
publishedComponentSessions.set(event.id, { wrapInfo, event, output });
|
||||
} else if (wrapInfo.sourceWritten === false) {
|
||||
await writeDeferredWrapperWithVariants({ tmp, wrapInfo, sessionId: event.id, output });
|
||||
} else {
|
||||
@@ -1745,6 +2037,56 @@ export async function runAgentLoop({
|
||||
continue;
|
||||
}
|
||||
|
||||
// The browser could not render something the agent published. Only the
|
||||
// agent can fix that, which is why the server queues it as a first-class
|
||||
// event instead of leaving it in the journal. Rewriting the variant files
|
||||
// and replying `done` makes the server snapshot a fresh revision dir and
|
||||
// rebroadcast, which is what drives the browser's remount.
|
||||
if (event.type === 'variant_mount_failed') {
|
||||
const key = `${event.id}|${event.variant}`;
|
||||
const published = publishedComponentSessions.get(event.id);
|
||||
log(`variant_mount_failed id=${event.id} variant=${event.variant} error=${JSON.stringify(event.error)}`);
|
||||
if (agent.autoRepairMountFailures === false) {
|
||||
log('auto-repair disabled; leaving the mount-error card for the user to retry');
|
||||
continue;
|
||||
}
|
||||
if (!published) {
|
||||
log('no published component session to repair; ignoring');
|
||||
continue;
|
||||
}
|
||||
if (repairedMounts.has(key)) {
|
||||
log('already republished this variant once; not looping');
|
||||
continue;
|
||||
}
|
||||
repairedMounts.add(key);
|
||||
try {
|
||||
await writeSvelteComponentVariants({
|
||||
tmp,
|
||||
wrapInfo: published.wrapInfo,
|
||||
event: published.event,
|
||||
output: published.output,
|
||||
writeParams: true,
|
||||
});
|
||||
await fetch(`${base}/poll`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
token,
|
||||
type: 'done',
|
||||
sourceEventType: 'generate',
|
||||
id: event.id,
|
||||
file: published.wrapInfo.file,
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
log(`republished component variants for ${event.id}`);
|
||||
} catch (err) {
|
||||
if (signal.aborted) return;
|
||||
log('variant_mount_failed repair failed: ' + err.message);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.type === 'manual_edit_apply') {
|
||||
const entryCount = event.batch?.entries?.length || 0;
|
||||
const opCount = (event.batch?.entries || []).reduce((sum, entry) => sum + (entry.ops?.length || 0), 0) || entryCount;
|
||||
|
||||
+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,
|
||||
};
|
||||
|
||||
@@ -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