mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-22 02:56:52 +03:00
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:
+227
-21
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function htmlAttrEscape(str) {
|
||||
return htmlEscape(str).replace(/"/g, '"');
|
||||
}
|
||||
|
||||
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, '&').replace(/'/g, ''');
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 }));
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user