test(live): add full-cycle E2E framework-fixture suite with pluggable agent

19 fixtures (11 styling/build variants + 4 conditional-render scenarios + 4
meta-frameworks) drive the entire user flow end-to-end: handshake, pick,
configure, Go, cycle, accept, carbonize cleanup. Each fixture installs real
deps, boots the framework dev server, and runs Playwright Chromium against a
deterministic fake agent that produces realistic variants (colocated style
with @scope rules, full data-impeccable-params manifests covering range +
steps + toggle, JSX/HTML/Svelte syntax-aware rendering).

The agent is pluggable via a one-method interface — generateVariants(event) —
so a future LLM-backed agent slots in by implementing the same shape. The
orchestrator handles wrap, file write, accept, and carbonize cleanup
deterministically regardless of which agent is plugged in.

Schema extensions (tests/framework-fixtures/README.md): runtime block adds
preActions / reloadProbe / pickSelector / scheme / ignoreHTTPSErrors so
fixtures can drive conditional UI (modal, tab, route) before pick and verify
the carbonized variant survives a reload.

Static fixture suite filtered to skip dirs without fixture.json so empty
scaffold dirs no longer break discovery. Total: 178 static checks, 19 E2E
full cycles, ~107s wall clock for the E2E suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-24 23:37:04 -07:00
co-authored by Claude Opus 4.7
parent d29a690797
commit c8de59d81e
163 changed files with 3392 additions and 1 deletions
+175
View File
@@ -0,0 +1,175 @@
/**
* Playwright helpers that drive the live-mode bar UI exactly the way a user
* would: pick an element, configure, Go, cycle, accept.
*
* Selector strategy: live-browser.js uses deterministic ids (`impeccable-live-*`)
* for the global bar, per-element bar, action picker, and params panel. Buttons
* inside the per-element bar are matched by visible text or unicode glyph
* (`Go →`, `← / →`, `✓ Accept`, `✕`). All selectors below come from
* source/skills/impeccable/scripts/live-browser.js — keep this file in sync if
* the bar's text content changes.
*/
const BAR_ID = '#impeccable-live-bar';
const GLOBAL_BAR_ID = '#impeccable-live-global-bar';
const PICKER_ID = '#impeccable-live-picker';
/**
* Wait for the live handshake to complete:
* - window.__IMPECCABLE_LIVE_INIT__ set
* - global bar mounted
* - SSE connection established (state transitioned to PICKING)
*
* Times out generously since some frameworks delay first render.
*/
export async function waitForHandshake(page, { timeout = 20_000 } = {}) {
await page.waitForFunction(
() => window.__IMPECCABLE_LIVE_INIT__ === true,
{ timeout },
);
await page.waitForSelector(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
// even if the toggle hasn't visibly shifted.
await page.waitForTimeout(250);
}
/**
* Click an in-page element to select it. live-browser.js's picker only acts
* when state === 'PICKING' AND pickActive is true; pickActive starts true on
* connect. The handler reads the hovered element from `mousemove`, so we
* dispatch a hover before the click.
*/
export async function pickElement(page, selector) {
const el = await page.waitForSelector(selector, { timeout: 5_000 });
await el.hover();
// Tiny settle: live-browser updates `hoveredElement` on mousemove, and the
// click handler reads from it.
await page.waitForTimeout(50);
await el.click();
// Per-element bar mounts on click → wait for it.
await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 5_000 });
// Wait specifically for the Configure-row Go button to be in the bar.
// pickElement returning before that race-conditions with clickGo on
// fixtures whose framework re-renders right after pick (modal open, tab
// switch). Anchoring the wait on the Go button's text is robust: the bar
// can be visible-but-empty (state=PICKING) before showBar('configure')
// populates the row.
await page.waitForFunction(
(barSel) => {
const bar = document.querySelector(barSel);
if (!bar) return false;
const btns = [...bar.querySelectorAll('button')];
return btns.some((b) => /Go\b/.test(b.textContent || ''));
},
BAR_ID,
{ timeout: 5_000 },
);
}
/**
* Set the variant count by clicking the count button (cycles 2 → 3 → 4 → 2).
* Default is 3. If the desired count is already showing, this is a no-op.
*/
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);
if (!bar) return null;
const btns = [...bar.querySelectorAll('button')];
const btn = btns.find((b) => /^×\d+$/.test((b.textContent || '').trim()));
if (!btn) return null;
return parseInt((btn.textContent || '').trim().slice(1), 10);
}, BAR_ID);
if (current === count) return;
await page.locator(`${BAR_ID} button`, { hasText: /^×\d+$/ }).click();
}
throw new Error(`could not cycle count to ${count}`);
}
/**
* Click Go. Browser POSTs the generate event; the agent picks it up.
*/
export async function clickGo(page) {
await page.locator(`${BAR_ID} button`, { hasText: /Go\b/ }).click();
}
/**
* Wait for the bar to enter CYCLING state — happens after the agent's
* variants land in the DOM via HMR and the MutationObserver counts them.
*
* The cycling row has the visible counter `N/M` in monospaced font; we
* detect it by content. The bar can also auto-reload if HMR was slow, so
* we give it a generous window.
*/
export async function waitForCycling(page, expectedCount, { timeout = 30_000 } = {}) {
await page.waitForFunction(
({ barSel, expected }) => {
const bar = document.querySelector(barSel);
if (!bar) return false;
const text = bar.textContent || '';
// Counter format: "1/3", "2/3" etc. Look for any "i/N" with N matching.
const m = text.match(/(\d+)\s*\/\s*(\d+)/);
if (!m) return false;
return parseInt(m[2], 10) === expected;
},
{ barSel: BAR_ID, expected: expectedCount },
{ timeout },
);
}
/**
* Click the next variant button (right arrow).
*/
export async function clickNext(page) {
await page.locator(`${BAR_ID} button`, { hasText: '→' }).click();
}
export async function clickPrev(page) {
await page.locator(`${BAR_ID} button`, { hasText: '←' }).click();
}
/**
* Read the currently visible variant index (the "i" in "i/N").
*/
export async function getVisibleVariant(page) {
return page.evaluate((barSel) => {
const bar = document.querySelector(barSel);
if (!bar) return null;
const m = (bar.textContent || '').match(/(\d+)\s*\/\s*(\d+)/);
return m ? parseInt(m[1], 10) : null;
}, BAR_ID);
}
/**
* Click Accept — sends accept event with current variantId + paramValues.
* The bar transitions to a "Saving..." spinner, then a green confirmed row.
*/
export async function clickAccept(page) {
await page.locator(`${BAR_ID} button`, { hasText: /Accept/ }).click();
}
/**
* Click Discard — sends discard event. live-accept.mjs unwinds the wrapper
* and restores the original.
*/
export async function clickDiscard(page) {
// The discard button has just a "✕" glyph as text content.
await page.locator(`${BAR_ID} button`, { hasText: '✕' }).click();
}
/**
* Wait for the bar to go away (after accept/discard the bar hides on confirm).
*/
export async function waitForBarHidden(page, { timeout = 10_000 } = {}) {
await page.waitForFunction(
(barSel) => {
const bar = document.querySelector(barSel);
return !bar || bar.style.display === 'none';
},
BAR_ID,
{ timeout },
);
}