mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-18 09:06:53 +03:00
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:
co-authored by
Claude Opus 4.7
parent
d29a690797
commit
c8de59d81e
@@ -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 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user