mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
The overlay could sit in its generating shader over a DOM that already held all three variants, and only a page refresh cleared it (#719). The server's generation preflight runs live-wrap with --defer-source-write, so the wrapper and every variant reach the DOM in a single HMR batch. The deferred-wrapper scout is constructed at init and the variant MutationObserver at Go; observer callbacks run in construction order, so on that batch the scout resumes first and resumeSession, not the observer, is the transition into CYCLING. It set the state and the bar but never called hideShaderOverlay(), so the frozen capture of the original stayed painted over the variants. It also reported browser_resumed, which does not count as publication progress, and then disconnected and re-created the observer, dropping the records that observer had already queued for the same batch, so variants_ready never fired at all. resumeSession now finishes the same transition the observer does (shader down, inline edit off, insert session finalized, params panel rebuilt) and reports variants_ready when it already holds every variant. The deferred scout names itself in the journal as browser_resumed_deferred_wrapper, so the two resume paths are no longer indistinguishable. Wrapper resolution goes through findVariantsWrapper, which prefers a wrapper that actually holds non-original variants. A target inside a .map() renders one wrapper per item, and an agent that relocates the wrapper out of the shared primitive live-wrap scaffolded leaves an empty one behind; first match could pin either and strand the session at 0/N. With zero or one match this is the querySelector it replaces. Tests: waitForCycling now asserts the generating shader is gone once the bar cycles, across every runtime fixture (it failed on vite8-react-plain before this change and passes after), marked no-retry so the reload recovery cannot hide it. Source-shape tests pin the transition, the variants_ready report, and the wrapper preference. Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vau2X53xGTjjTCXWMVBoNY
143 lines
5.5 KiB
JavaScript
143 lines
5.5 KiB
JavaScript
/**
|
|
* Pre-pick page setup for live-mode E2E (modals, tabs, routes).
|
|
*
|
|
* Live mode's picker intercepts page clicks while pickActive is true, so these
|
|
* actions temporarily disarm pick mode — same as a user toggling Pick off to
|
|
* open a modal, then back on to select an element.
|
|
*/
|
|
|
|
import { installLiveQueryHelpers, waitForCycling } from './ui.mjs';
|
|
|
|
const PICK_TOGGLE = '#impeccable-live-pick-toggle';
|
|
|
|
/**
|
|
* @param {import('playwright').Page} page
|
|
* @param {Array<{ type: string, selector?: string, path?: string }>} actions
|
|
*/
|
|
export async function runPreActions(page, actions) {
|
|
if (!actions?.length) return;
|
|
|
|
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 {
|
|
for (let i = 0; i < actions.length; i++) {
|
|
const a = actions[i];
|
|
if (a.type === 'click') {
|
|
const next = actions[i + 1];
|
|
if (next?.type === 'wait') {
|
|
const alreadyVisible = await page.locator(next.selector).first().isVisible().catch(() => false);
|
|
if (alreadyVisible) continue;
|
|
}
|
|
const loc = page.locator(a.selector);
|
|
await loc.first().waitFor({ state: 'visible', timeout: 5_000 });
|
|
await loc.first().click();
|
|
continue;
|
|
}
|
|
if (a.type === 'goto') {
|
|
const target = new URL(a.path, page.url()).href;
|
|
await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 10_000 });
|
|
continue;
|
|
}
|
|
if (a.type === 'wait') {
|
|
await page.waitForSelector(a.selector, { timeout: 5_000 });
|
|
continue;
|
|
}
|
|
throw new Error(`unknown preAction type: ${a.type}`);
|
|
}
|
|
} finally {
|
|
if (wasActive) {
|
|
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 = window.__impeccableLiveQuery(sel);
|
|
if (!btn) return false;
|
|
btn.click();
|
|
return true;
|
|
}, selector);
|
|
if (!clicked) throw err;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Wait for CYCLING with the same recovery paths live mode expects:
|
|
* retrace preActions when conditional UI closed, reload when LLM + HMR lag.
|
|
*
|
|
* @param {import('playwright').Page} page
|
|
* @param {number} expectedCount
|
|
* @param {{ agentMode?: string, preActions?: object[], log?: (msg: string) => void }} opts
|
|
*/
|
|
export async function waitForCyclingRobust(page, expectedCount, opts = {}) {
|
|
const agentMode = opts.agentMode || 'fake';
|
|
const preActions = opts.preActions;
|
|
const log = opts.log || (() => {});
|
|
const firstPassTimeoutMs = agentMode === 'llm' ? 90_000 : 5_000;
|
|
const finalTimeoutMs = agentMode === 'llm' ? 90_000 : 30_000;
|
|
|
|
if (preActions?.length) {
|
|
try {
|
|
await waitForCycling(page, expectedCount, { timeout: firstPassTimeoutMs });
|
|
return;
|
|
} catch (preErr) {
|
|
if (preErr?.impeccableNoRetry) throw preErr;
|
|
log(`Cycling not reached in ${firstPassTimeoutMs}ms — retracing preActions`);
|
|
await runPreActions(page, preActions);
|
|
}
|
|
}
|
|
|
|
try {
|
|
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' || firstErr?.impeccableNoRetry) 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 }));
|
|
}
|