Add Svelte-native live mode adapter (#179)

* Fix live preview state for framework components

* Complete stateful live preview coverage

* Record Svelte manual validation

* Fix Svelte live mode adapter

* Fix live Steer apply flow

* Fix Svelte live variant refresh recovery

* Fix live exit bar teardown

* Consolidate Svelte live DeepSeek sweep

* Reconcile Svelte live browser after main rebase

* Fix live accept review regressions

* Fix carbonize column-zero indentation

* Fix live poll lease expiry flake

* Fix Svelte shader preview capture
This commit is contained in:
Abdul Wahab
2026-06-02 00:08:57 -07:00
committed by GitHub
parent 69b5f3af49
commit 6163ca0529
212 changed files with 51520 additions and 4992 deletions
+227 -21
View File
@@ -26,6 +26,7 @@ import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { completionTypeForAcceptResult } from '../../skill/scripts/live-completion.mjs';
const execFileP = promisify(execFile);
@@ -95,13 +96,21 @@ export function createFakeAgent() {
if (event.mode === 'insert') {
return generateInsertFakeVariants(context);
}
const text = extractText(event.element?.outerHTML) || 'Title';
const cls = 'hero-title';
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'])
.filter((name) => !/^svelte-[\w-]+$/.test(name))
.join(' ')
|| 'hero-title';
const preservedAttrs = buildPreservedVariantAttrs(event.element || {}, cls);
const elementOpen = `<${tag}${preservedAttrs}>`;
const elementClose = `</${tag}>`;
const variantHtml = `${elementOpen}${htmlEscape(text)}${elementClose}`;
const useAstroGlobalCss = context.wrapInfo?.styleMode === 'astro-global-prefixed';
// Variant 1 — red color, with a `range` param tuning hue lightness.
const variant1 = {
innerHtml: `<h1 class="${cls}">${text}</h1>`,
innerHtml: variantHtml,
params: [
{
id: 'lightness',
@@ -117,7 +126,7 @@ export function createFakeAgent() {
// Variant 2 — bold weight, with a `steps` param for serif/sans/mono.
const variant2 = {
innerHtml: `<h1 class="${cls}">${text}</h1>`,
innerHtml: variantHtml,
params: [
{
id: 'face',
@@ -135,7 +144,7 @@ export function createFakeAgent() {
// Variant 3 — uppercase, with a `toggle` param for italic.
const variant3 = {
innerHtml: `<h1 class="${cls}">${text}</h1>`,
innerHtml: variantHtml,
params: [
{
id: 'italic',
@@ -151,29 +160,29 @@ export function createFakeAgent() {
// tag plus explicit variant prefixes instead of raw @scope rules.
const scopedCss = useAstroGlobalCss
? [
'[data-impeccable-variant="1"] > h1 {',
`[data-impeccable-variant="1"] > ${tag} {`,
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
'}',
'[data-impeccable-variant="2"] > h1 { font-weight: 900; }',
'[data-impeccable-variant="2"][data-p-face="serif"] > h1 { font-family: ui-serif, serif; }',
'[data-impeccable-variant="2"][data-p-face="mono"] > h1 { font-family: ui-monospace, monospace; }',
'[data-impeccable-variant="3"] > h1 { text-transform: uppercase; letter-spacing: 0.04em; }',
'[data-impeccable-variant="3"][data-p-italic] > h1 { font-style: italic; }',
`[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"][data-p-italic] > ${tag} { font-style: italic; }`,
].join('\n')
: [
'@scope ([data-impeccable-variant="1"]) {',
' :scope > h1 {',
` :scope > ${tag} {`,
' color: oklch(var(--p-lightness, 0.5) 0.25 25);',
' }',
'}',
'@scope ([data-impeccable-variant="2"]) {',
' :scope > h1 { font-weight: 900; }',
' :scope[data-p-face="serif"] > h1 { font-family: ui-serif, serif; }',
' :scope[data-p-face="mono"] > h1 { font-family: ui-monospace, monospace; }',
` :scope > ${tag} { font-weight: 900; }`,
` :scope[data-p-face="serif"] > ${tag} { font-family: ui-serif, serif; }`,
` :scope[data-p-face="mono"] > ${tag} { font-family: ui-monospace, monospace; }`,
'}',
'@scope ([data-impeccable-variant="3"]) {',
' :scope > h1 { text-transform: uppercase; letter-spacing: 0.04em; }',
' :scope[data-p-italic] > h1 { font-style: italic; }',
` :scope > ${tag} { text-transform: uppercase; letter-spacing: 0.04em; }`,
` :scope[data-p-italic] > ${tag} { font-style: italic; }`,
'}',
].join('\n');
@@ -305,6 +314,32 @@ function extractText(outerHTML) {
return m ? m[1].trim() : null;
}
function htmlEscape(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function htmlAttrEscape(str) {
return htmlEscape(str).replace(/"/g, '&quot;');
}
function buildPreservedVariantAttrs(element, className) {
const attrs = [];
if (className) attrs.push(['class', className]);
if (element.id) attrs.push(['id', element.id]);
const testId = readAttrFromOuterHtml(element.outerHTML, 'data-testid');
if (testId) attrs.push(['data-testid', testId]);
return attrs.map(([name, value]) => ` ${name}="${htmlAttrEscape(value)}"`).join('');
}
function readAttrFromOuterHtml(outerHTML, attr) {
if (!outerHTML) return null;
const match = String(outerHTML).match(new RegExp("\\s" + attr.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + "\\s*=\\s*([\"'])(.*?)\\1"));
return match ? match[2] : null;
}
function attrEscape(str, { svelte = false } = {}) {
let s = String(str).replace(/&/g, '&amp;').replace(/'/g, '&apos;');
if (svelte) {
@@ -1298,6 +1333,156 @@ async function spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId, output }) {
await fs.writeFile(filePath, next.join('\n'), 'utf-8');
}
async function writeSvelteComponentVariants({ tmp, wrapInfo, event, output }) {
const manifestPath = path.join(tmp, wrapInfo.file);
const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
const componentDir = path.join(tmp, manifest.componentDir);
const isInsert = manifest.mode === 'insert';
const contract = Array.isArray(manifest.propContract) ? manifest.propContract : [];
const propNames = contract.map((entry) => entry.prop);
const baseMarkup = isInsert ? '' : substituteSvelteExprsWithProps(manifest.originalMarkup || '', contract).trim();
const textValues = isInsert ? [] : extractTextPieces(event.element?.outerHTML || event.element?.textContent || '');
const paramsByVariant = {};
for (let i = 0; i < output.variants.length; i++) {
const variantId = i + 1;
const variant = output.variants[i];
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}}`))) {
markup = mergeTopLevelAttrs(baseMarkup, variant.innerHtml || '') || baseMarkup;
}
if (isInsert && !variantMarkupHasVisibleContent(markup)) {
throw new Error(`Svelte insert variant ${variantId} has no visible content`);
}
if (isInsert && /\bdata-impeccable-[\w-]*\s*=/.test(markup)) {
throw new Error(`Svelte insert variant ${variantId} contains preview-only data-impeccable attributes`);
}
const css = svelteCssForVariant(output.scopedCss || '', variantId, tag);
const component = [
buildSveltePropsScript(contract),
'',
markup || baseMarkup || '<div></div>',
'',
'<style>',
css || ' :global(*) {}',
'</style>',
'',
].join('\n');
await fs.writeFile(path.join(componentDir, `v${variantId}.svelte`), component, 'utf-8');
paramsByVariant[String(variantId)] = Array.isArray(variant.params) ? variant.params : [];
}
await fs.writeFile(path.join(componentDir, 'params.json'), JSON.stringify(paramsByVariant, null, 2) + '\n', 'utf-8');
}
function variantMarkupHasVisibleContent(markup) {
const text = String(markup || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
if (text.length > 0) return true;
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(markup || '');
}
function buildSveltePropsScript(contract) {
if (!contract.length) return '<script>\n let {} = $props();\n</script>';
return `<script>\n let { ${contract.map((entry) => entry.prop).join(', ')} } = $props();\n</script>`;
}
function substituteSvelteExprsWithProps(markup, contract) {
let out = String(markup || '');
for (const entry of contract) {
out = out.split(`{${entry.expr}}`).join(`{${entry.prop}}`);
}
return out;
}
function substituteLiveTextWithProps(markup, contract, textValues) {
let out = String(markup || '');
for (let i = 0; i < contract.length; i++) {
const value = textValues[i];
if (!value) continue;
out = out.split(htmlEscape(value)).join(`{${contract[i].prop}}`);
out = out.split(value).join(`{${contract[i].prop}}`);
}
return out;
}
function extractTextPieces(html) {
return String(html || '')
.replace(/<script[\s\S]*?<\/script>/gi, '')
.replace(/<style[\s\S]*?<\/style>/gi, '')
.split(/<[^>]+>/)
.map((text) => text.replace(/\s+/g, ' ').trim())
.filter(Boolean);
}
function firstTagName(markup) {
const match = String(markup || '').match(/<([A-Za-z][\w:-]*)\b/);
return match ? match[1].toLowerCase() : null;
}
function mergeTopLevelAttrs(baseMarkup, variantMarkup) {
const base = String(baseMarkup || '');
const variant = String(variantMarkup || '');
const baseOpen = base.match(/^(\s*<)([A-Za-z][\w:-]*)([^>]*)(>)/);
const variantOpen = variant.match(/^\s*<([A-Za-z][\w:-]*)([^>]*)(>)/);
if (!baseOpen || !variantOpen || baseOpen[2].toLowerCase() !== variantOpen[1].toLowerCase()) return base;
return base.replace(baseOpen[0], `${baseOpen[1]}${baseOpen[2]}${variantOpen[2]}${baseOpen[4]}`);
}
function svelteCssForVariant(scopedCss, variantId, tag) {
const css = String(scopedCss || '');
const chunks = extractVariantCssChunks(css, variantId);
const rewritten = chunks
.join('\n')
.replace(new RegExp(String.raw`\\[data-impeccable-variant=["']${variantId}["']\\]\\s*>\\s*`, 'g'), '')
.replace(new RegExp(String.raw`\\[data-impeccable-variant=["']${variantId}["']\\][^{]*>\\s*`, 'g'), '')
.replace(/:scope(?:\[[^\]]+\])?\s*>\s*/g, '')
.replace(/:scope(?:\[[^\]]+\])?/g, tag)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim())
.join('\n')
.trim();
return rewritten || `${tag} {}`;
}
function extractVariantCssChunks(css, variantId) {
const lines = String(css || '').split('\n');
const chunks = [];
let collecting = false;
let depth = 0;
for (const line of lines) {
if (line.includes(`[data-impeccable-variant="${variantId}"]`) || line.includes(`[data-impeccable-variant='${variantId}']`)) {
collecting = true;
depth = 0;
if (!line.trim().startsWith('@scope')) chunks.push(line);
depth += braceDelta(line);
if (depth <= 0) collecting = false;
continue;
}
if (!collecting) continue;
const before = depth;
depth += braceDelta(line);
if (before === 1 && depth === 0 && line.trim() === '}') {
collecting = false;
continue;
}
chunks.push(line);
if (depth <= 0) collecting = false;
}
return chunks;
}
function braceDelta(line) {
return (line.match(/\{/g) || []).length - (line.match(/\}/g) || []).length;
}
// ---------------------------------------------------------------------------
// Poll loop — the "agent" runs this until aborted
// ---------------------------------------------------------------------------
@@ -1371,6 +1556,7 @@ export async function runAgentLoop({
type: 'steer_done',
id: event.id,
message: toast,
file: steerContext.targetFile,
}),
signal,
});
@@ -1430,8 +1616,12 @@ export async function runAgentLoop({
log(`warning: agent returned ${output.variants.length} variants, expected ${event.count}`);
}
// 3. Splice variants block into the wrapper (deterministic fs)
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
// 3. Write variants into the deterministic preview target.
if (wrapInfo.previewMode === 'svelte-component') {
await writeSvelteComponentVariants({ tmp, wrapInfo, event, output });
} else {
await spliceVariantsIntoWrapper({ tmp, wrapInfo, sessionId: event.id, output });
}
if (process.env.IMPECCABLE_E2E_DEBUG) {
const post = await fs.readFile(path.join(tmp, wrapInfo.file), 'utf-8');
log(`--- post-splice (variants written) ---\n${post}`);
@@ -1543,10 +1733,18 @@ export async function runAgentLoop({
log(`carbonize cleanup done on ${acceptResult.file}`);
}
const completionType = completionTypeForAcceptResult('accept', acceptResult);
await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, type: 'accept', id: event.id, data: { _acceptResult: acceptResult } }),
body: JSON.stringify({
token,
type: completionType,
id: event.id,
file: acceptResult.file,
message: acceptResult.error,
data: acceptResult.carbonize === true ? { carbonize: true, _acceptResult: acceptResult } : { _acceptResult: acceptResult },
}),
signal,
});
} catch (err) {
@@ -1560,10 +1758,18 @@ export async function runAgentLoop({
log(`discard id=${event.id}`);
try {
const discardResult = await runAccept({ tmp, scriptsDir, id: event.id, discard: true, pageUrl: event.pageUrl });
const completionType = completionTypeForAcceptResult('discard', discardResult);
await fetch(`${base}/poll`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, type: 'discard', id: event.id, data: { _acceptResult: discardResult } }),
body: JSON.stringify({
token,
type: completionType,
id: event.id,
file: discardResult.file,
message: discardResult.error,
data: { _acceptResult: discardResult },
}),
signal,
});
} catch (err) {
+179 -47
View File
@@ -55,7 +55,7 @@ export const VARIANT_SYSTEM_INSTRUCTIONS = [
' "scopedCss": "string — contents of the preview CSS block, authored according to wrapInfo.cssAuthoring",',
' "variants": [',
' {',
' "innerHtml": "string — single top-level HTML element matching the picked element\'s tag, e.g. <h1 class=\\"hero-title\\">Title</h1>",',
' "innerHtml": "string — single top-level HTML element; replace mode matches the picked element tag, insert mode is net-new content",',
' "params": [/* optional 0-4 ParamSpec entries */]',
' }',
' ]',
@@ -67,13 +67,17 @@ export const VARIANT_SYSTEM_INSTRUCTIONS = [
' { "id": "string", "kind": "toggle", "default": boolean, "label": "string" }',
'',
'REQUIREMENTS',
'- Each variant.innerHtml must be a single top-level HTML element. Use the EXACT same tag as the picked element.',
'- The single top-level element is the replacement root itself. If the picked element is <section class="hero-copy">...</section>, emit <section class="hero-copy">...</section> with edited children directly; do not wrap a duplicate <section class="hero-copy"> inside another root.',
'- PRESERVE the original element\'s className verbatim. If the picked element\'s outerHTML contains class="hero-title", every variant\'s innerHtml MUST contain exactly class="hero-title"; do not add, remove, or rename classes. This is a hard requirement — mapped-list fixtures depend on the class string staying stable across the variant set.',
'- PRESERVE all existing visible copy exactly. GO variants change presentation, hierarchy, and styling; they must not rewrite titles, paragraphs, button labels, or user-applied manual copy edits.',
'- For bare text elements, keep the full visible copy in one editable text node. If you add child markup for styling, wrap the entire copy; never split the copy across sibling text nodes.',
'- PRESERVE existing class-bearing descendant elements in place. If the picked element contains <h1 class="hero-title"> and <p class="hero-hook">, keep those elements/classes as direct descendants of the replacement root; do not wrap them in a new structural div such as <div class="hero-inner">.',
'- Do not return source-identical variants. For a bare text element, preserve the root tag/class/copy but add a small child span or styling hook so Accept persists a real source change.',
'- Replace mode: each variant.innerHtml must be a single top-level HTML element using the EXACT same tag as the picked element.',
'- Insert mode (`event.mode === "insert"`): each variant.innerHtml must be net-new content that honors event.freeformPrompt. It does NOT replace the anchor and does NOT need to use the anchor tag or preserve anchor copy.',
'- Insert mode variants must contain visible inserted content. Do not return empty roots, placeholder-only roots, inline style= attributes, or test hooks like <div data-impeccable-e2e-variant="1"></div>.',
'- Replace mode: the single top-level element is the replacement root itself. If the picked element is <section class="hero-copy">...</section>, emit <section class="hero-copy">...</section> with edited children directly; do not wrap a duplicate <section class="hero-copy"> inside another root.',
'- Replace mode: PRESERVE the original element\'s className verbatim. If the picked element\'s outerHTML contains class="hero-title", every variant\'s innerHtml MUST contain exactly class="hero-title"; do not add, remove, or rename classes. This is a hard requirement — mapped-list fixtures depend on the class string staying stable across the variant set.',
'- Replace mode: PRESERVE all existing visible copy exactly. GO variants change presentation, hierarchy, and styling; they must not rewrite titles, paragraphs, button labels, or user-applied manual copy edits.',
'- Replace mode: use the visible literal copy from the picked element. Do not emit framework template expressions or placeholders such as {name}, {amount}, ${value}, or {{value}} in innerHtml.',
'- Replace mode: for bare text elements, keep the full visible copy in one editable text node. If you add child markup for styling, wrap the entire copy; never split the copy across sibling text nodes.',
'- Replace mode: PRESERVE existing class-bearing descendant elements in place. If the picked element contains <h1 class="hero-title"> and <p class="hero-hook">, keep those elements/classes as direct descendants of the replacement root; do not wrap them in a new structural div such as <div class="hero-inner">.',
'- Replace mode: Do not return source-identical variants. For a bare text element, preserve the root tag/class/copy but add a small child span or styling hook so Accept persists a real source change.',
'- Replace mode: for non-bare elements where the existing children must stay in place, add a harmless root attribute such as data-impeccable-e2e-variant="1" or another non-copy styling hook so the markup is materially changed without changing visible text.',
'- Generate exactly event.count variants — no more, no fewer.',
'- Mix the param kinds across the variant set: include at least one range, one steps, and one toggle when count >= 3.',
'- The scopedCss must follow wrapInfo.cssAuthoring exactly: use its selector strategy, rulePattern, requirements, and forbidden patterns.',
@@ -175,6 +179,7 @@ const STEER_SYSTEM_INSTRUCTIONS = [
'- Use exact find strings copied from context.sourceExcerpt or context.tagLine. Do not guess whitespace.',
'- Prefer a single edit on the hero opening tag (h1 with the hero class). Preserve all existing classes and inner content.',
'- file must match context.targetFile unless the excerpt clearly shows a different path is wrong.',
'- Never edit temporary preview or scratch paths such as node_modules/.impeccable-live; Steer edits must land in the real app source file.',
'- edits must be non-empty; find must match exactly once in the file.',
'',
'CONTEXT — live-mode skill spec follows for steer semantics (Handle steer section).',
@@ -240,30 +245,12 @@ export async function createLlmAgent(opts = {}) {
return {
async generateVariants(event, context = {}) {
const isInsert = event.mode === 'insert';
const baseUserMessage = [
'Produce variants for the following pick. Reply with the JSON object only — no prose.',
`Produce variants for the following ${isInsert ? 'insert request' : 'pick'}. Reply with the JSON object only — no prose.`,
'',
'```json',
JSON.stringify(
{
id: event.id,
action: event.action,
count: event.count,
element: {
outerHTML: event.element?.outerHTML,
tagName: event.element?.tagName,
className: event.element?.className,
textContent: event.element?.textContent?.slice(0, 200),
},
wrapInfo: {
styleMode: context.wrapInfo?.styleMode,
styleTag: context.wrapInfo?.styleTag,
cssAuthoring: context.wrapInfo?.cssAuthoring,
},
},
null,
2,
),
JSON.stringify(buildVariantRequestPayload(event, context), null, 2),
'```',
].join('\n');
@@ -345,25 +332,41 @@ export async function createLlmAgent(opts = {}) {
continue;
}
const materialError = validateVariantMaterialChange(parsed, event.element);
const copyError = validateVariantVisibleCopy(parsed, event.element);
const validationError = copyError || materialError;
const validationError = isInsert
? validateInsertVariantOutput(parsed, event)
: (validateVariantVisibleCopy(parsed, event.element) || validateVariantMaterialChange(parsed, event.element));
if (!validationError) return parsed;
if (attempt === 1) throw new Error(`LLM agent: ${validationError}`);
const expectedText = normalizeVisibleText(
elementVisibleText(event.element),
);
log(`variant validation failed; retrying: ${validationError}`);
userMessage = [
baseUserMessage,
'',
'VALIDATION ERROR',
validationError,
`Every variant must preserve this exact normalized visible text: "${expectedText}"`,
'Every variant must also be materially different from the picked element source. For bare text, keep the full copy in one text node; wrap the entire text in one child span or add a real styling hook.',
'Return corrected JSON only.',
].join('\n');
if (isInsert) {
userMessage = [
baseUserMessage,
'',
'VALIDATION ERROR',
validationError,
`The inserted content must visibly satisfy this prompt: "${event.freeformPrompt || ''}"`,
'Do not preserve or copy the anchor text unless the prompt asks for it. This is net-new content inserted near the anchor.',
'Do not use data-impeccable-* attributes or empty test-hook-only roots.',
'Do not use inline style= attributes; put all visual rules in scopedCss.',
'Return corrected JSON only.',
].join('\n');
} else {
const expectedText = normalizeVisibleText(
elementVisibleText(event.element),
);
userMessage = [
baseUserMessage,
'',
'VALIDATION ERROR',
validationError,
`Every variant must preserve this exact normalized visible text: "${expectedText}"`,
'Use literal visible text in innerHtml, not framework placeholders like {name}, {amount}, ${value}, or {{value}}.',
'Every variant must also be materially different from the picked element source. For bare text, keep the full copy in one text node; wrap the entire text in one child span or add a real styling hook.',
'For non-bare markup, keep the existing visible descendants in place and add a harmless root data attribute or styling hook so the source is not identical.',
'Return corrected JSON only.',
].join('\n');
}
}
throw new Error('LLM agent: variant generation failed');
@@ -661,6 +664,33 @@ export function validateManualEditPlanningCoverage(parsed, batch) {
return null;
}
export function buildVariantRequestPayload(event, context = {}) {
const isInsert = event?.mode === 'insert';
return {
id: event?.id,
mode: event?.mode || 'replace',
action: event?.action,
freeformPrompt: event?.freeformPrompt,
count: event?.count,
element: isInsert ? null : {
outerHTML: event?.element?.outerHTML,
tagName: event?.element?.tagName,
className: event?.element?.className,
textContent: event?.element?.textContent?.slice(0, 200),
},
insert: isInsert ? {
position: event?.insert?.position,
anchor: event?.insert?.anchor,
} : undefined,
placeholder: isInsert ? event?.placeholder : undefined,
wrapInfo: {
styleMode: context.wrapInfo?.styleMode,
styleTag: context.wrapInfo?.styleTag,
cssAuthoring: context.wrapInfo?.cssAuthoring,
},
};
}
/**
* Parse and validate a model response into the variant-output schema. Throws
* with a `Parsed (first 500 chars): ...` echo on every schema failure so the
@@ -776,8 +806,10 @@ function validateScopedCss(css) {
function validateVariantInnerHtml(html) {
if (/<!--[\s\S]*?-->/.test(html)) return 'must not include HTML comments';
if (/<\/?script\b/i.test(html)) return 'must not include a <script> tag';
if (/<\/?style\b/i.test(html)) return 'must not include a <style> tag';
if (/\bclassName\s*=/.test(html)) return 'must use HTML class= attributes, not JSX className=';
if (/\bstyle\s*=\s*\{\{/.test(html)) return 'must use HTML style="..." syntax, not JSX style={{...}}';
if (/\{[^}]+\}/.test(html)) return 'must use literal visible copy, not framework template expressions such as {name}';
if (/\bdata-impeccable-variants?\s*=/.test(html)) return 'must not include Impeccable wrapper attributes';
if (/<\/?>/.test(html)) return 'must not use JSX fragments';
return null;
@@ -797,6 +829,27 @@ export function validateVariantVisibleCopy(parsed, element) {
return null;
}
export function validateInsertVariantOutput(parsed, event = {}) {
for (const [i, variant] of parsed.variants.entries()) {
const html = variant.innerHtml || '';
if (/\bdata-impeccable-[\w-]*\s*=/.test(html)) {
return `insert variant ${i} contains preview-only data-impeccable attributes`;
}
if (/\sstyle\s*=/.test(html)) {
return `insert variant ${i} uses inline style attributes; put CSS in scopedCss`;
}
if (!hasSingleTopLevelElement(html)) {
return `insert variant ${i} must have a single top-level root element`;
}
const text = normalizeVisibleText(extractVisibleTextFromHtml(html));
if (!text && !htmlHasNonTextVisualContent(html)) {
return `insert variant ${i} has no visible inserted content`;
}
}
if (event.freeformPrompt && parsed.variants.length > 0) return null;
return null;
}
export function validateVariantMaterialChange(parsed, element) {
const originalHtml = normalizeVariantHtml(element?.outerHTML || '');
if (!originalHtml) return null;
@@ -815,6 +868,36 @@ export function validateVariantMaterialChange(parsed, element) {
return null;
}
function htmlHasNonTextVisualContent(html) {
return /<(img|svg|canvas|video|audio|picture|input|button|select|textarea)\b/i.test(html || '');
}
function hasSingleTopLevelElement(html) {
const trimmed = String(html || '').trim();
const voidTags = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
const tagRe = /<\/?([A-Za-z][\w:-]*)(?:\s[^>]*)?\/?>/g;
let depth = 0;
let roots = 0;
let match;
while ((match = tagRe.exec(trimmed)) !== null) {
const full = match[0];
const name = match[1].toLowerCase();
const closing = full.startsWith('</');
const selfClosing = full.endsWith('/>') || voidTags.has(name);
if (closing) {
if (depth <= 0) return false;
depth -= 1;
continue;
}
if (depth === 0) {
roots += 1;
if (roots > 1) return false;
}
if (!selfClosing) depth += 1;
}
return roots === 1 && depth === 0;
}
function bareTextElementText(html) {
const inner = rootInnerHtml(html);
if (!inner || /<[^>]+>/.test(inner)) return '';
@@ -1406,7 +1489,56 @@ function decodeBasicHtmlEntities(text) {
* Strip a single optional fence, leave anything else alone.
*/
function stripCodeFence(s) {
return s
.replace(/^```(?:json)?\s*\n/, '')
.replace(/\n```\s*$/, '');
const text = String(s).trim();
const exactFence = text.match(/^```(?:json)?\s*\n([\s\S]*?)\n```\s*$/);
const candidate = exactFence ? exactFence[1].trim() : text;
return extractFirstJsonValue(candidate) || candidate;
}
function extractFirstJsonValue(text) {
for (let i = 0; i < text.length; i += 1) {
const ch = text[i];
if (ch !== '{' && ch !== '[') continue;
const end = findJsonValueEnd(text, i);
if (end !== -1) return text.slice(i, end + 1);
}
return null;
}
function findJsonValueEnd(text, start) {
const stack = [];
let inString = false;
let escaped = false;
for (let i = start; i < text.length; i += 1) {
const ch = text[i];
if (inString) {
if (escaped) {
escaped = false;
} else if (ch === '\\') {
escaped = true;
} else if (ch === '"') {
inString = false;
}
continue;
}
if (ch === '"') {
inString = true;
continue;
}
if (ch === '{' || ch === '[') {
stack.push(ch);
continue;
}
if (ch === '}' || ch === ']') {
const expected = ch === '}' ? '{' : '[';
if (stack.pop() !== expected) return -1;
if (stack.length === 0) return i;
}
}
return -1;
}
+39 -11
View File
@@ -6,7 +6,7 @@
* open a modal, then back on to select an element.
*/
import { waitForCycling } from './ui.mjs';
import { installLiveQueryHelpers, waitForCycling } from './ui.mjs';
const PICK_TOGGLE = '#impeccable-live-pick-toggle';
@@ -17,10 +17,10 @@ const PICK_TOGGLE = '#impeccable-live-pick-toggle';
export async function runPreActions(page, actions) {
if (!actions?.length) return;
const pickerToggle = await page.$(PICK_TOGGLE);
const wasActive = pickerToggle
? await pickerToggle.evaluate((el) => el.dataset.active === 'true')
: false;
await installLiveQueryHelpers(page);
const wasActive = await page.evaluate((sel) =>
window.__impeccableLiveQuery?.(sel)?.dataset.active === 'true',
PICK_TOGGLE).catch(() => false);
if (wasActive) await clickPickToggle(page, PICK_TOGGLE);
try {
@@ -50,22 +50,22 @@ export async function runPreActions(page, actions) {
}
} finally {
if (wasActive) {
const after = await page.$(PICK_TOGGLE);
if (after) {
const isActive = await after.evaluate((el) => el.dataset.active === 'true');
if (!isActive) await clickPickToggle(page, PICK_TOGGLE);
}
const isActive = await page.evaluate((sel) =>
window.__impeccableLiveQuery?.(sel)?.dataset.active === 'true',
PICK_TOGGLE).catch(() => false);
if (!isActive) await clickPickToggle(page, PICK_TOGGLE);
}
}
}
async function clickPickToggle(page, selector) {
await installLiveQueryHelpers(page);
try {
await page.locator(selector).click({ timeout: 5_000 });
return;
} catch (err) {
const clicked = await page.evaluate((sel) => {
const btn = document.querySelector(sel);
const btn = window.__impeccableLiveQuery(sel);
if (!btn) return false;
btn.click();
return true;
@@ -103,11 +103,39 @@ export async function waitForCyclingRobust(page, expectedCount, opts = {}) {
await waitForCycling(page, expectedCount, { timeout: finalTimeoutMs });
return;
} catch (firstErr) {
if (process.env.IMPECCABLE_E2E_DEBUG) {
firstErr.message += '\n\n--- live UI snapshot ---\n' + JSON.stringify(await liveUiSnapshot(page), null, 2);
}
if (agentMode !== 'llm') throw firstErr;
}
log('Cycling not reached after LLM generate — reloading to pick up HMR');
await page.reload({ waitUntil: 'domcontentloaded', timeout: 30_000 });
await installLiveQueryHelpers(page);
if (preActions?.length) await runPreActions(page, preActions);
await waitForCycling(page, expectedCount, { timeout: 60_000 });
}
async function liveUiSnapshot(page) {
return page.evaluate(() => {
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.() || window.__IMPECCABLE_LIVE_UI_ROOT__ || null;
const bar = query('#impeccable-live-bar');
const toast = query('#impeccable-live-toast');
const wrapper = document.querySelector('[data-impeccable-variants]');
return {
href: location.href,
liveInit: window.__IMPECCABLE_LIVE_INIT__,
adapter: window.__IMPECCABLE_LIVE_ADAPTER__,
hasShadowRoot: Boolean(document.getElementById('impeccable-live-root')?.shadowRoot),
rootText: root?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 500) || null,
bar: bar ? { display: bar.style.display, text: bar.textContent } : null,
toast: toast ? toast.textContent : null,
wrapper: wrapper ? { preview: wrapper.dataset.impeccablePreview, count: wrapper.dataset.impeccableVariantCount, html: wrapper.outerHTML.slice(0, 800) } : null,
debugState: window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null,
storage: localStorage.getItem('impeccable-live-session'),
scripts: document.querySelectorAll('script[data-impeccable-live-script]').length,
consoleHint: 'See page console errors captured by the test session.',
};
}).catch((err) => ({ error: err.message }));
}
+7
View File
@@ -189,6 +189,12 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
try { rmSync(tmp, { recursive: true, force: true }); } catch {}
};
const stopLiveForDeferredWork = () => {
if (!live) return;
stopLiveServer(tmp);
live = null;
};
try {
log(`installing deps`);
runInstall(tmp, runtime.install);
@@ -245,6 +251,7 @@ export async function bootFixtureSession({ name, fixture, browser, agent, wrapTa
dev,
live,
consoleErrors,
stopLiveServer: stopLiveForDeferredWork,
teardown,
};
} catch (err) {
+6 -2
View File
@@ -43,6 +43,8 @@ export async function runSteerSmoke(page, tmp, fixture, log = () => {}, opts = {
const expectSelector = steerCfg.expectSelector || `h1.hero-title[${STEER_MARKER_ATTR}]`;
const sourceNeedle = steerCfg.expectSourceContains || STEER_MARKER_ATTR;
const revealActions = steerCfg.preActions ?? fixture.runtime?.preActions;
const sourceFile = resolveSteerSourceFile(tmp, fixture);
const sourceBefore = readFileSync(sourceFile, 'utf-8');
log(`Steer: submitting ${JSON.stringify(message)}`);
await submitSteer(page, message);
@@ -51,7 +53,6 @@ export async function runSteerSmoke(page, tmp, fixture, log = () => {}, opts = {
await waitForSteerUnlocked(page, { timeout: unlockTimeoutMs });
log('Steer: unlocked');
const sourceFile = resolveSteerSourceFile(tmp, fixture);
const waitForSource = async () => {
const deadline = Date.now() + selectorTimeoutMs;
while (Date.now() < deadline) {
@@ -63,7 +64,10 @@ export async function runSteerSmoke(page, tmp, fixture, log = () => {}, opts = {
`steer marker missing from ${sourceFile} after ${selectorTimeoutMs}ms (expected ${JSON.stringify(sourceNeedle)})`,
);
};
await waitForSource();
const sourceAfter = await waitForSource();
if (!sourceBefore.includes(sourceNeedle) && sourceAfter === sourceBefore) {
throw new Error(`steer source did not change in ${sourceFile}`);
}
log('Steer: source marker present');
if (steerCfg.expectDom === false) {
+455 -63
View File
@@ -25,8 +25,16 @@ const PICK_TOGGLE = '#impeccable-live-pick-toggle';
// continue to resolve to the same selector as the older PICK_TOGGLE name.
const PICK_TOGGLE_ID = PICK_TOGGLE;
const INSERT_TOGGLE = '#impeccable-live-insert-toggle';
const DETECT_TOGGLE = '#impeccable-live-detect-toggle';
const DETECT_BADGE = '#impeccable-live-detect-badge';
const DESIGN_TOGGLE = '#impeccable-live-design-toggle';
const DESIGN_HOST = '#impeccable-live-design-host';
const EXIT_BUTTON = '#impeccable-live-exit';
const INSERT_INPUT_ID = '#impeccable-live-insert-input';
const INSERT_CREATE_ID = '#impeccable-live-insert-create';
const ANNOTATION_ID = '#impeccable-live-annot';
const ANNOTATION_PINS_ID = '#impeccable-live-annot-pins';
const ANNOTATION_CLEAR_ID = '#impeccable-live-annot-clear';
/**
* Wait for the live handshake to complete:
@@ -41,7 +49,12 @@ export async function waitForHandshake(page, { timeout = 20_000 } = {}) {
() => window.__IMPECCABLE_LIVE_INIT__ === true,
{ timeout },
);
await page.waitForSelector(GLOBAL_BAR_ID, { timeout });
await installLiveQueryHelpers(page);
await page.waitForFunction(
(sel) => Boolean(window.__impeccableLiveQuery?.(sel)),
GLOBAL_BAR_ID,
{ timeout },
);
// Wait for the picker mode to be active (live.js flips state PICKING after
// SSE 'connected' arrives). We can detect it via the global bar's pick
// toggle being in its ready state. Soft wait — fall through after a beat
@@ -49,6 +62,318 @@ export async function waitForHandshake(page, { timeout = 20_000 } = {}) {
await page.waitForTimeout(250);
}
export async function assertBottomBarIdle(page, { timeout = 5_000 } = {}) {
await installLiveQueryHelpers(page);
await page.waitForFunction(
({ ids }) => ids.every((sel) => Boolean(window.__impeccableLiveQuery(sel))),
{
ids: [
GLOBAL_BAR_ID,
PICK_TOGGLE,
INSERT_TOGGLE,
DETECT_TOGGLE,
DESIGN_TOGGLE,
STEER_CHAT_ID,
STEER_INPUT_ID,
'#impeccable-live-page-chat-voice',
EXIT_BUTTON,
],
},
{ timeout },
);
const snapshot = await page.evaluate(({ pickSel, insertSel, detectSel, designSel }) => {
const q = window.__impeccableLiveQuery;
return {
pick: controlSnapshot(q(pickSel)),
insert: controlSnapshot(q(insertSel)),
detect: controlSnapshot(q(detectSel)),
design: controlSnapshot(q(designSel)),
};
function controlSnapshot(el) {
return {
exists: !!el,
text: (el?.textContent || '').replace(/\s+/g, ' ').trim(),
ariaLabel: el?.getAttribute('aria-label') || '',
active: el?.dataset?.active || null,
disabled: !!el?.disabled,
};
}
}, {
pickSel: PICK_TOGGLE,
insertSel: INSERT_TOGGLE,
detectSel: DETECT_TOGGLE,
designSel: DESIGN_TOGGLE,
});
for (const [name, value] of Object.entries(snapshot)) {
if (!value.exists) throw new Error(`bottom bar ${name} control is missing`);
if (value.disabled) throw new Error(`bottom bar ${name} control unexpectedly disabled`);
}
}
export async function runLiveChromeBottomBarSmoke(page, {
expectDetectMinCount = 1,
designTitle = '',
designRawText = '',
} = {}) {
await assertBottomBarIdle(page);
await runPickInsertToggleSmoke(page);
await runDetectSmoke(page, { expectMinCount: expectDetectMinCount });
await runDesignPanelSmoke(page, { title: designTitle, rawText: designRawText });
}
function installLiveQueryHelpersInPage() {
window.__impeccableLiveQuery = (selector) => {
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| null;
return root?.querySelector?.(selector) || document.querySelector(selector);
};
window.__impeccableLiveQueryAll = (selector) => {
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.()
|| window.__IMPECCABLE_LIVE_UI_ROOT__
|| null;
const fromRoot = root?.querySelectorAll ? [...root.querySelectorAll(selector)] : [];
const fromDoc = [...document.querySelectorAll(selector)];
return [...new Set([...fromRoot, ...fromDoc])];
};
}
export async function installLiveQueryHelpers(page) {
await page.addInitScript(installLiveQueryHelpersInPage).catch(() => {});
await page.evaluate(installLiveQueryHelpersInPage);
}
async function clickLiveControl(page, selector) {
await installLiveQueryHelpers(page);
const clicked = await page.evaluate((sel) => {
const el = window.__impeccableLiveQuery(sel);
if (!el || el.disabled) return false;
el.click();
return true;
}, selector);
if (clicked) return;
await page.locator(selector).click({ timeout: 5_000 });
}
async function readControlActive(page, selector) {
await installLiveQueryHelpers(page);
return page.evaluate((sel) => window.__impeccableLiveQuery(sel)?.dataset.active === 'true', selector);
}
async function ensureLiveControlActive(page, selector, active) {
if (await readControlActive(page, selector) === active) return;
await clickLiveControl(page, selector);
await page.waitForFunction(
({ sel, expected }) => window.__impeccableLiveQuery(sel)?.dataset.active === (expected ? 'true' : 'false'),
{ sel: selector, expected: active },
{ timeout: 5_000 },
);
}
export async function runPickInsertToggleSmoke(page) {
await ensureLiveControlActive(page, PICK_TOGGLE, true);
await page.waitForFunction(
({ pickSel, insertSel }) =>
window.__impeccableLiveQuery(pickSel)?.dataset.active === 'true'
&& window.__impeccableLiveQuery(insertSel)?.dataset.active === 'false',
{ pickSel: PICK_TOGGLE, insertSel: INSERT_TOGGLE },
{ timeout: 5_000 },
);
await ensureLiveControlActive(page, INSERT_TOGGLE, true);
await page.waitForFunction(
({ pickSel, insertSel }) =>
window.__impeccableLiveQuery(pickSel)?.dataset.active === 'false'
&& window.__impeccableLiveQuery(insertSel)?.dataset.active === 'true',
{ pickSel: PICK_TOGGLE, insertSel: INSERT_TOGGLE },
{ timeout: 5_000 },
);
await ensureLiveControlActive(page, INSERT_TOGGLE, false);
await ensureLiveControlActive(page, PICK_TOGGLE, false);
}
export async function runDetectSmoke(page, { expectMinCount = 1 } = {}) {
await ensureLiveControlActive(page, DETECT_TOGGLE, true);
await page.waitForFunction(
({ badgeSel, expectMin }) => {
const badge = window.__impeccableLiveQuery(badgeSel);
const count = parseInt(badge?.textContent || '0', 10);
const overlays = document.querySelectorAll('.impeccable-overlay').length;
return count >= expectMin && overlays >= expectMin && badge?.style.display !== 'none';
},
{ badgeSel: DETECT_BADGE, expectMin: expectMinCount },
{ timeout: 15_000 },
);
await ensureLiveControlActive(page, PICK_TOGGLE, true);
await page.waitForFunction(
() => [...document.querySelectorAll('.impeccable-overlay')]
.every((overlay) => overlay.style.pointerEvents === 'none'),
{ timeout: 5_000 },
);
await ensureLiveControlActive(page, PICK_TOGGLE, false);
await ensureLiveControlActive(page, DETECT_TOGGLE, false);
await page.waitForFunction(
({ badgeSel }) => {
const badge = window.__impeccableLiveQuery(badgeSel);
return document.querySelectorAll('.impeccable-overlay').length === 0
&& (!badge || badge.style.display === 'none' || (badge.textContent || '') === '0');
},
{ badgeSel: DETECT_BADGE },
{ timeout: 5_000 },
);
}
export async function runDesignPanelSmoke(page, { title = '', rawText = '' } = {}) {
await ensureLiveControlActive(page, DESIGN_TOGGLE, true);
await page.waitForFunction(
({ hostSel, titleText }) => {
const host = window.__impeccableLiveQuery(hostSel);
const root = host?.shadowRoot;
const panel = root?.querySelector('.panel');
const bodyText = root?.querySelector('#panel-body')?.textContent || '';
return panel?.getAttribute('data-open') === 'true'
&& bodyText.trim().length > 0
&& !bodyText.includes('Loading design system')
&& !bodyText.includes('No DESIGN.md yet')
&& !bodyText.includes('Failed to load design system')
&& (!titleText || bodyText.includes(titleText));
},
{ hostSel: DESIGN_HOST, titleText: title },
{ timeout: 15_000 },
);
await page.evaluate((hostSel) => {
const root = window.__impeccableLiveQuery(hostSel)?.shadowRoot;
const raw = [...(root?.querySelectorAll('.tab') || [])].find((btn) => /Raw/i.test(btn.textContent || ''));
raw?.click();
}, DESIGN_HOST);
await page.waitForFunction(
({ hostSel, expected }) => {
const text = window.__impeccableLiveQuery(hostSel)?.shadowRoot?.textContent || '';
return !expected || text.includes(expected);
},
{ hostSel: DESIGN_HOST, expected: rawText },
{ timeout: 10_000 },
);
await page.evaluate((hostSel) => {
const root = window.__impeccableLiveQuery(hostSel)?.shadowRoot;
root?.querySelector('.panel-close')?.click();
}, DESIGN_HOST);
await page.waitForFunction(
({ hostSel, toggleSel }) => {
const panel = window.__impeccableLiveQuery(hostSel)?.shadowRoot?.querySelector('.panel');
const toggle = window.__impeccableLiveQuery(toggleSel);
return panel?.getAttribute('data-open') === 'false' && toggle?.dataset.active === 'false';
},
{ hostSel: DESIGN_HOST, toggleSel: DESIGN_TOGGLE },
{ timeout: 5_000 },
);
}
export async function clickExitLiveMode(page) {
await clickLiveControl(page, EXIT_BUTTON);
await page.waitForFunction(
({ barSel }) => {
const bar = window.__impeccableLiveQuery?.(barSel);
return window.__IMPECCABLE_LIVE_INIT__ === false && (!bar || !bar.isConnected);
},
{ barSel: GLOBAL_BAR_ID },
{ timeout: 5_000 },
);
}
export async function drawAnnotationPinAndStroke(page, {
comment = 'Make this area easier to scan',
} = {}) {
await installLiveQueryHelpers(page);
const rect = await waitForAnnotationRect(page);
const pinPoint = {
x: rect.left + Math.min(28, Math.max(12, rect.width * 0.2)),
y: rect.top + Math.min(28, Math.max(12, rect.height * 0.35)),
};
await page.mouse.click(pinPoint.x, pinPoint.y);
await page.waitForFunction(
(pinsSel) => Boolean(window.__impeccableLiveQuery(pinsSel)?.querySelector('input')),
ANNOTATION_PINS_ID,
{ timeout: 5_000 },
);
await page.evaluate(({ pinsSel, value }) => {
const input = window.__impeccableLiveQuery(pinsSel)?.querySelector('input');
if (!input) return false;
input.value = value;
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
return true;
}, { pinsSel: ANNOTATION_PINS_ID, value: comment });
await page.waitForFunction(
({ pinsSel, expected }) => {
const pins = window.__impeccableLiveQuery(pinsSel);
return pins && pins.textContent.includes(expected);
},
{ pinsSel: ANNOTATION_PINS_ID, expected: comment },
{ timeout: 5_000 },
);
const strokeStart = { x: rect.left + rect.width * 0.58, y: rect.top + rect.height * 0.28 };
const strokeEnd = { x: rect.left + rect.width * 0.86, y: rect.top + rect.height * 0.72 };
await page.mouse.move(strokeStart.x, strokeStart.y);
await page.mouse.down();
await page.mouse.move((strokeStart.x + strokeEnd.x) / 2, (strokeStart.y + strokeEnd.y) / 2, { steps: 4 });
await page.mouse.move(strokeEnd.x, strokeEnd.y, { steps: 4 });
await page.mouse.up();
await page.waitForFunction(
({ annotSel, clearSel }) => {
const annot = window.__impeccableLiveQuery(annotSel);
const clear = window.__impeccableLiveQuery(clearSel);
const stroke = annot?.querySelector('[data-annot-stroke]');
return Boolean(stroke) && clear?.style.display !== 'none';
},
{ annotSel: ANNOTATION_ID, clearSel: ANNOTATION_CLEAR_ID },
{ timeout: 5_000 },
);
}
export async function assertAnnotationUploadEvent(event) {
if (!event) throw new Error('expected recorded generate event');
if (!Array.isArray(event.comments) || event.comments.length < 1) {
throw new Error('expected generate event to include annotation comments');
}
if (!Array.isArray(event.strokes) || event.strokes.length < 1) {
throw new Error('expected generate event to include annotation strokes');
}
if (!event.screenshotPath || typeof event.screenshotPath !== 'string') {
throw new Error('expected generate event to include screenshotPath');
}
}
async function waitForAnnotationRect(page) {
await page.waitForFunction(
(sel) => {
const el = window.__impeccableLiveQuery(sel);
if (!el || el.style.display === 'none') return false;
const rect = el.getBoundingClientRect();
return rect.width > 20 && rect.height > 20;
},
ANNOTATION_ID,
{ timeout: 5_000 },
);
return page.evaluate((sel) => {
const rect = window.__impeccableLiveQuery(sel).getBoundingClientRect();
return {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
};
}, ANNOTATION_ID);
}
/**
* Click an in-page element to select it. live-browser.js's picker only acts
* when state === 'PICKING' AND pickActive is true. Both interaction toggles
@@ -94,7 +419,7 @@ export async function pickElement(page, selector, opts = {}) {
// populates the row.
await page.waitForFunction(
(barSel) => {
const bar = document.querySelector(barSel);
const bar = window.__impeccableLiveQuery(barSel);
if (!bar) return false;
const btns = [...bar.querySelectorAll('button')];
return btns.some((b) => /Go\b/.test(b.textContent || ''));
@@ -106,7 +431,7 @@ export async function pickElement(page, selector, opts = {}) {
async function hideAnnotationOverlay(page) {
await page.evaluate(() => {
const annot = document.querySelector('#impeccable-live-annot');
const annot = window.__impeccableLiveQuery('#impeccable-live-annot');
if (annot) annot.style.display = 'none';
}).catch(() => {});
}
@@ -131,7 +456,7 @@ async function ensurePickerActive(page) {
if (active) return;
const clicked = await page.evaluate((sel) => {
const btn = document.querySelector(sel);
const btn = window.__impeccableLiveQuery(sel);
if (!btn) return false;
btn.click();
return true;
@@ -140,7 +465,7 @@ async function ensurePickerActive(page) {
await page.locator(PICK_TOGGLE_ID).click({ timeout: 5_000 });
}
await page.waitForFunction(
(sel) => document.querySelector(sel)?.dataset.active === 'true',
(sel) => window.__impeccableLiveQuery(sel)?.dataset.active === 'true',
PICK_TOGGLE_ID,
{ timeout: 5_000 },
);
@@ -152,14 +477,14 @@ async function resetPickMode(page) {
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(100);
await page.evaluate((sel) => {
const btn = document.querySelector(sel);
const btn = window.__impeccableLiveQuery(sel);
if (!btn) return;
const active = btn.dataset.active === 'true';
if (active) btn.click();
btn.click();
}, PICK_TOGGLE_ID).catch(() => {});
await page.waitForFunction(
(sel) => document.querySelector(sel)?.dataset.active === 'true',
(sel) => window.__impeccableLiveQuery(sel)?.dataset.active === 'true',
PICK_TOGGLE_ID,
{ timeout: 5_000 },
).catch(() => {});
@@ -173,7 +498,7 @@ export async function setCount(page, count) {
if (count < 2 || count > 4) throw new Error('count must be 2..4');
for (let i = 0; i < 4; i++) {
const current = await page.evaluate((barSel) => {
const bar = document.querySelector(barSel);
const bar = window.__impeccableLiveQuery(barSel);
if (!bar) return null;
const btns = [...bar.querySelectorAll('button')];
const btn = btns.find((b) => /^×\d+$/.test((b.textContent || '').trim()));
@@ -198,7 +523,7 @@ export async function clickGo(page) {
await clickBarButton(page, /Go\b/);
const advanced = await page.waitForFunction(
(barSel) => {
const bar = document.querySelector(barSel);
const bar = window.__impeccableLiveQuery(barSel);
if (!bar) return false;
const text = bar.textContent || '';
if (/Generating\b/.test(text)) return true;
@@ -226,9 +551,11 @@ export async function clickGo(page) {
* we give it a generous window.
*/
export async function waitForCycling(page, expectedCount, { timeout = 30_000 } = {}) {
await page.waitForFunction(
await installLiveQueryHelpers(page);
try {
await page.waitForFunction(
({ barSel, expected }) => {
const bar = document.querySelector(barSel);
const bar = window.__impeccableLiveQuery(barSel);
if (!bar) return false;
const text = bar.textContent || '';
// Counter format: "1/3", "2/3" etc. Look for any "i/N" with N matching.
@@ -238,7 +565,32 @@ export async function waitForCycling(page, expectedCount, { timeout = 30_000 } =
},
{ barSel: BAR_ID, expected: expectedCount },
{ timeout },
);
);
} catch (err) {
if (process.env.IMPECCABLE_E2E_DEBUG) {
const snapshot = await page.evaluate((barSel) => {
const query = window.__impeccableLiveQuery || ((sel) => document.querySelector(sel));
const root = window.__IMPECCABLE_LIVE_CHROME_CORE__?.root?.() || window.__IMPECCABLE_LIVE_UI_ROOT__ || null;
const bar = query(barSel);
const toast = query('#impeccable-live-toast');
const wrapper = document.querySelector('[data-impeccable-variants]');
return {
liveInit: window.__IMPECCABLE_LIVE_INIT__,
adapter: window.__IMPECCABLE_LIVE_ADAPTER__,
rootText: root?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 600) || null,
bar: bar ? { display: bar.style.display, text: bar.textContent } : null,
toast: toast?.textContent || null,
wrapper: wrapper ? { preview: wrapper.dataset.impeccablePreview, count: wrapper.dataset.impeccableVariantCount, html: wrapper.outerHTML.slice(0, 600) } : null,
debugState: window.__IMPECCABLE_LIVE_CHROME_CORE__?.debugState?.() || null,
storage: localStorage.getItem('impeccable-live-session'),
scripts: document.querySelectorAll('script[data-impeccable-live-script]').length,
bodyText: document.body.textContent.replace(/\s+/g, ' ').trim().slice(0, 600),
};
}, BAR_ID).catch((snapErr) => ({ error: snapErr.message }));
console.error('--- waitForCycling snapshot ---\n' + JSON.stringify(snapshot, null, 2));
}
throw err;
}
}
/**
@@ -253,6 +605,7 @@ export async function clickPrev(page) {
}
async function clickBarButton(page, label) {
await installLiveQueryHelpers(page);
const button = page.locator(`${BAR_ID} button`, { hasText: label });
const textMatch = label instanceof RegExp
? { kind: 'regex', source: label.source, flags: label.flags }
@@ -284,6 +637,7 @@ async function clickBarButton(page, label) {
}
async function dispatchBarButton(page, label) {
await installLiveQueryHelpers(page);
const textMatch = label instanceof RegExp
? { kind: 'regex', source: label.source, flags: label.flags }
: { kind: 'text', value: String(label) };
@@ -291,7 +645,7 @@ async function dispatchBarButton(page, label) {
}
function findAndClickBarButton({ barSel, textMatch }) {
const bar = document.querySelector(barSel);
const bar = window.__impeccableLiveQuery(barSel);
if (!bar) return false;
const btn = [...bar.querySelectorAll('button')]
.find((candidate) => {
@@ -308,15 +662,16 @@ function findAndClickBarButton({ barSel, textMatch }) {
* Read the currently visible variant index (the "i" in "i/N").
*/
export async function getVisibleVariant(page) {
await installLiveQueryHelpers(page);
return page.evaluate((barSel) => {
const wrapper = document.querySelector('[data-impeccable-variants]');
const wrapper = window.__impeccableLiveQuery('[data-impeccable-variants]');
if (wrapper) {
const variants = [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')];
const visible = variants.find((variant) => variant.style.display !== 'none');
const idx = visible ? parseInt(visible.dataset.impeccableVariant || '0', 10) : 0;
if (idx > 0) return idx;
}
const bar = document.querySelector(barSel);
const bar = window.__impeccableLiveQuery(barSel);
if (!bar) return null;
const m = (bar.textContent || '').match(/(\d+)\s*\/\s*(\d+)/);
return m ? parseInt(m[1], 10) : null;
@@ -364,7 +719,7 @@ export async function clickDiscard(page) {
export async function clickEditCopy(page) {
await clickEditBadgeButton(page, 'Edit copy');
await page.waitForFunction(
() => document.querySelector('[data-impeccable-editable="true"]')?.isContentEditable === true,
() => window.__impeccableLiveQuery('[data-impeccable-editable="true"]')?.isContentEditable === true,
{ timeout: 5_000 },
);
}
@@ -388,19 +743,31 @@ async function resolveEditableLeaf(page, leafSelector) {
export async function clickSaveEdit(page) {
await clickEditBadgeButton(page, 'Save');
await page.waitForFunction(
() => !document.querySelector('[data-impeccable-editable="true"]'),
() => !window.__impeccableLiveQuery('[data-impeccable-editable="true"]'),
{ timeout: 5_000 },
);
}
async function clickEditBadgeButton(page, label) {
const proxyRect = await page.evaluate((text) => {
const proxies = [...document.querySelectorAll('[data-impeccable-edit-badge-proxy="true"]')];
const proxy = proxies.find((candidate) => (candidate.title || '').includes(text));
if (!proxy) return null;
const rect = proxy.getBoundingClientRect();
if (rect.width < 1 || rect.height < 1) return null;
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 };
}, label).catch(() => null);
if (proxyRect) {
await page.mouse.click(proxyRect.x, proxyRect.y);
return;
}
const button = page.locator(`${EDIT_BADGE_ID} button`, { hasText: label });
try {
await button.click({ timeout: 5_000 });
return;
} catch (err) {
const clicked = await page.evaluate(({ badgeSel, text }) => {
const badge = document.querySelector(badgeSel);
const badge = window.__impeccableLiveQuery(badgeSel);
const btn = [...(badge?.querySelectorAll('button') || [])].find((candidate) =>
(candidate.textContent || '').includes(text)
);
@@ -415,7 +782,7 @@ async function clickEditBadgeButton(page, label) {
export async function assertApplyDockVisible(page, expectedCount, { timeout = 5_000 } = {}) {
await page.waitForFunction(
({ dockSel, expected }) => {
const dock = document.querySelector(dockSel);
const dock = window.__impeccableLiveQuery(dockSel);
if (!dock || dock.style.display === 'none') return false;
const pill = [...dock.querySelectorAll('button')].find((btn) =>
/Apply copy edit/.test(btn.textContent || '')
@@ -432,7 +799,7 @@ export async function assertApplyDockVisible(page, expectedCount, { timeout = 5_
export async function waitForApplyDockHidden(page, { timeout = 10_000 } = {}) {
await page.waitForFunction(
(dockSel) => {
const dock = document.querySelector(dockSel);
const dock = window.__impeccableLiveQuery(dockSel);
if (!dock || dock.style.display === 'none') return true;
const pill = [...dock.querySelectorAll('button')].find((btn) =>
/Apply copy edit/.test(btn.textContent || '')
@@ -447,7 +814,7 @@ export async function waitForApplyDockHidden(page, { timeout = 10_000 } = {}) {
export async function assertApplyDockLoading(page, { timeout = 5_000 } = {}) {
await page.waitForFunction(
(dockSel) => {
const dock = document.querySelector(dockSel);
const dock = window.__impeccableLiveQuery(dockSel);
if (!dock || dock.style.display === 'none') return false;
const pill = [...dock.querySelectorAll('button')].find((btn) =>
/Apply copy edit|Applying|Verifying|Fixing apply issue/.test(btn.textContent || '')
@@ -486,9 +853,10 @@ export function assertSourceApplied(tmp, file, originalText, newText) {
* Wait for the bar to go away (after accept/discard the bar hides on confirm).
*/
export async function waitForBarHidden(page, { timeout = 10_000 } = {}) {
await installLiveQueryHelpers(page);
await page.waitForFunction(
(barSel) => {
const bar = document.querySelector(barSel);
const bar = window.__impeccableLiveQuery(barSel);
return !bar || bar.style.display === 'none';
},
BAR_ID,
@@ -502,30 +870,33 @@ export async function waitForBarHidden(page, { timeout = 10_000 } = {}) {
*/
export async function preparePageForBarInteraction(page) {
await page.evaluate(() => {
for (const el of document.querySelectorAll('astro-dev-toolbar')) {
for (const el of window.__impeccableLiveQueryAll('astro-dev-toolbar')) {
el.style.setProperty('display', 'none', 'important');
el.style.setProperty('pointer-events', 'none', 'important');
}
});
}
async function focusSteerInput(page) {
return page.evaluate(({ chatSel, inputSel }) => {
const chat = document.querySelector(chatSel);
const input = document.querySelector(inputSel);
if (!chat || !input) return false;
chat.dataset.expanded = 'true';
chat.style.width = 'min(280px, 38vw)';
chat.style.cursor = 'text';
input.disabled = false;
input.style.pointerEvents = 'auto';
input.style.opacity = '1';
input.style.width = 'auto';
input.style.padding = '0 6px';
try { window.focus(); } catch { /* embed may block */ }
try { input.focus({ preventScroll: true }); } catch { input.focus(); }
return document.activeElement === input;
}, { chatSel: STEER_CHAT_ID, inputSel: STEER_INPUT_ID });
export async function waitForSteerInputFocused(page, { timeout = 5_000 } = {}) {
await page.waitForFunction(
(inputSel) => {
const input = window.__impeccableLiveQuery(inputSel);
const active = window.__IMPECCABLE_LIVE_CHROME_CORE__?.activeElementDeep?.()
|| input?.getRootNode?.()?.activeElement
|| document.activeElement;
return Boolean(input && active === input && input.style.pointerEvents !== 'none' && input.style.opacity !== '0');
},
STEER_INPUT_ID,
{ timeout },
);
}
export async function waitForSteerInputValue(page, value, { timeout = 5_000 } = {}) {
await page.waitForFunction(
({ inputSel, value: expected }) => window.__impeccableLiveQuery(inputSel)?.value === expected,
{ inputSel: STEER_INPUT_ID, value },
{ timeout },
);
}
/**
@@ -534,21 +905,21 @@ async function focusSteerInput(page) {
* (e.g. Astro dev toolbar) intercept pointer events — same outcome as keyboard focus.
*/
export async function submitSteer(page, message) {
await installLiveQueryHelpers(page);
await preparePageForBarInteraction(page);
await page.locator(STEER_CHAT_ID).waitFor({ state: 'visible', timeout: 5_000 });
const chat = page.locator(STEER_CHAT_ID);
await chat.waitFor({ state: 'visible', timeout: 5_000 });
try {
await page.locator(STEER_CHAT_ID).click({ timeout: 2_500 });
await chat.click({ timeout: 2_500 });
} catch {
await focusSteerInput(page);
}
if (!(await focusSteerInput(page))) {
await page.locator(STEER_CHAT_ID).click({ force: true, timeout: 2_500 }).catch(() => {});
await focusSteerInput(page);
await chat.click({ force: true, timeout: 2_500 });
}
await waitForSteerInputFocused(page);
const input = page.locator(STEER_INPUT_ID);
await input.fill(message, { timeout: 5_000 });
await input.type(message, { timeout: 5_000 });
await waitForSteerInputValue(page, message);
await input.press('Enter');
}
@@ -566,7 +937,7 @@ export async function waitForSteerDomMarker(page, selector, { timeout = 20_000 }
*/
export async function waitForSteerLocked(page, { timeout = 5_000 } = {}) {
await page.waitForFunction(
(sel) => document.querySelector(sel)?.dataset.processing === 'true',
(sel) => window.__impeccableLiveQuery(sel)?.dataset.processing === 'true',
STEER_CHAT_ID,
{ timeout },
);
@@ -578,8 +949,8 @@ export async function waitForSteerLocked(page, { timeout = 5_000 } = {}) {
export async function waitForSteerUnlocked(page, { timeout = 15_000 } = {}) {
await page.waitForFunction(
(sel) => {
const chat = document.querySelector(sel);
const input = document.querySelector('#impeccable-live-page-chat-input');
const chat = window.__impeccableLiveQuery(sel);
const input = window.__impeccableLiveQuery('#impeccable-live-page-chat-input');
return chat?.dataset.processing !== 'true' && input && !input.disabled;
},
STEER_CHAT_ID,
@@ -588,11 +959,12 @@ export async function waitForSteerUnlocked(page, { timeout = 15_000 } = {}) {
}
async function ensureToggleActive(page, selector, shouldBeActive) {
await installLiveQueryHelpers(page);
const isActive = await page.locator(selector).evaluate((el) => el?.dataset.active === 'true');
if (isActive === shouldBeActive) return;
await page.locator(selector).click({ timeout: 5_000 });
await page.waitForFunction(
({ sel, active }) => document.querySelector(sel)?.dataset.active === (active ? 'true' : 'false'),
({ sel, active }) => window.__impeccableLiveQuery(sel)?.dataset.active === (active ? 'true' : 'false'),
{ sel: selector, active: shouldBeActive },
{ timeout: 5_000 },
);
@@ -626,31 +998,51 @@ export async function runInsertFlow(page, {
const y = position === 'before' ? box.y + 4 : box.y + box.height - 4;
await page.mouse.move(x, y);
await page.waitForFunction(() => {
const line = document.getElementById('impeccable-live-insert-line');
const line = window.__impeccableLiveQuery('#impeccable-live-insert-line');
return line && line.style.display !== 'none';
}, { timeout: 5_000 });
await page.mouse.click(x, y);
await page.waitForSelector(INSERT_INPUT_ID, { state: 'visible', timeout: 5_000 });
await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 5_000 });
await installLiveQueryHelpers(page);
await page.waitForFunction(
({ inputSel, barSel }) => {
const input = window.__impeccableLiveQuery(inputSel);
const bar = window.__impeccableLiveQuery(barSel);
if (!input || !bar) return false;
const rect = input.getBoundingClientRect();
return rect.width > 0 && rect.height > 0 && bar.style.display !== 'none';
},
{ inputSel: INSERT_INPUT_ID, barSel: BAR_ID },
{ timeout: 5_000 },
);
await page.evaluate(({ sel, value }) => {
const el = document.querySelector(sel);
if (!el) return;
el.value = value;
el.dispatchEvent(new Event('input', { bubbles: true }));
}, { sel: INSERT_INPUT_ID, value: prompt });
const focused = await page.evaluate((sel) => {
const el = window.__impeccableLiveQuery(sel);
if (!el) return false;
try { el.focus({ preventScroll: true }); } catch { el.focus(); }
let active = document.activeElement;
while (active?.shadowRoot?.activeElement) active = active.shadowRoot.activeElement;
return active === el;
}, INSERT_INPUT_ID);
if (!focused) throw new Error('Insert prompt input did not receive focus');
await page.keyboard.type(prompt);
await page.waitForFunction(
({ sel, value }) => window.__impeccableLiveQuery(sel)?.value === value,
{ sel: INSERT_INPUT_ID, value: prompt },
{ timeout: 5_000 },
);
await page.waitForFunction(
(sel) => {
const btn = document.querySelector(sel);
const btn = window.__impeccableLiveQuery(sel);
return btn && !btn.disabled;
},
INSERT_CREATE_ID,
{ timeout: 5_000 },
);
const clicked = await page.evaluate((sel) => {
const btn = document.querySelector(sel);
const btn = window.__impeccableLiveQuery(sel);
if (!btn || btn.disabled) return false;
btn.click();
return true;