mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-11 21:57:14 +03:00
tests/live-e2e/agents/llm-agent.mjs: a Claude-backed VariantAgent that
implements the same one-method interface as the fake agent
(generateVariants(event, context) → { scopedCss, variants[] }). Default
model claude-haiku-4-5; override via IMPECCABLE_E2E_LLM_MODEL.
Prompt caching is on — the system prompt (instructions + the live-mode
spec from reference/live.md) is the cacheable prefix. First call writes
~10K tokens to cache; subsequent fixtures pay only the cache-read rate.
JSON output is validated for shape (scopedCss, variants[N].innerHtml),
with light error messages on parse failure.
tests/live-e2e.test.mjs: read IMPECCABLE_E2E_AGENT (fake|llm). When 'llm',
construct the LLM agent and skip the case cleanly if ANTHROPIC_API_KEY is
unset. Param-manifest assertions are gated to fake mode (LLM may emit
zero-param "fixed point" variants per the live.md spec). The accepted-h1
class assertion now allows hero-title as one of multiple classes so an
LLM agent that adds classes alongside the original still passes.
Test timeouts widen for LLM mode: 25s first-pass on conditional-render
fixtures (vs 5s for fake), 60s on direct waits (vs 30s). Without these,
the LLM's 3-8s generate latency races the orchestration's state-loss
recovery window.
tests/live-e2e/ui.mjs: clickGo retries up to 3× on stability failures.
Required because conditional-render fixtures (modal/tabs) animate the bar
mid-transition when preActions trigger framework HMR; a single click can
land during a re-render and Playwright's stability gate times out.
Pass rate on a typical sweep: 18/19 in LLM mode, 19/19 in fake mode.
The modal fixture's intrinsic state-loss flake (Fast Refresh resetting
useState(open) when source changes) is amplified by LLM latency and may
need a re-run; documented in CLAUDE.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
451 lines
18 KiB
JavaScript
451 lines
18 KiB
JavaScript
/**
|
|
* End-to-end live-mode tests — full click-to-accept cycle.
|
|
*
|
|
* For every framework fixture with a `runtime` block in fixture.json, this
|
|
* runner exercises the entire user-visible chain:
|
|
*
|
|
* 1. Stage → install → start live-server + dev server → inject script tag
|
|
* 2. Open Playwright Chromium, assert the live handshake fires
|
|
* 3. Spawn a deterministic fake-agent polling loop in this same process
|
|
* 4. Drive the bar UI: pick element → Go → wait CYCLING → cycle → Accept
|
|
* 5. Assert source rewrite (variants block, then accepted-only after accept)
|
|
* 6. Assert DOM reflects the accepted variant via getComputedStyle
|
|
* 7. Tear down (browser, dev server, agent loop, live-server, tmp)
|
|
*
|
|
* The fake agent is pluggable — see tests/live-e2e/agent.mjs. A future
|
|
* LLM-backed agent slots in by implementing the same VariantAgent interface.
|
|
*
|
|
* Run with: bun run test:live-e2e
|
|
*/
|
|
|
|
import { describe, it, before, after } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import { createFakeAgent } from './live-e2e/agent.mjs';
|
|
import { createLlmAgent } from './live-e2e/agents/llm-agent.mjs';
|
|
import { bootFixtureSession, FIXTURES_DIR } from './live-e2e/session.mjs';
|
|
import {
|
|
clickAccept,
|
|
clickGo,
|
|
clickNext,
|
|
getVisibleVariant,
|
|
pickElement,
|
|
waitForCycling,
|
|
waitForHandshake,
|
|
} from './live-e2e/ui.mjs';
|
|
|
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
|
// Discover fixtures that opt into the runtime E2E pass.
|
|
function listRuntimeFixtures() {
|
|
const names = readdirSync(FIXTURES_DIR, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory())
|
|
.map((e) => e.name);
|
|
|
|
const out = [];
|
|
for (const name of names) {
|
|
const fixturePath = join(FIXTURES_DIR, name, 'fixture.json');
|
|
if (!existsSync(fixturePath)) continue;
|
|
const fixture = JSON.parse(readFileSync(fixturePath, 'utf-8'));
|
|
if (fixture.runtime) out.push({ name, fixture });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
const allFixtures = listRuntimeFixtures();
|
|
|
|
// During development of the full-cycle test, a single fixture is much faster
|
|
// to iterate on. Set IMPECCABLE_E2E_ONLY=<name> to scope the run.
|
|
const onlyName = process.env.IMPECCABLE_E2E_ONLY;
|
|
const fixtures = onlyName
|
|
? allFixtures.filter((f) => f.name === onlyName)
|
|
: allFixtures;
|
|
|
|
if (fixtures.length === 0) {
|
|
describe('live-e2e (no runtime fixtures registered)', () => {
|
|
it('is a no-op', () => assert.ok(true));
|
|
});
|
|
}
|
|
|
|
let playwright;
|
|
let browser;
|
|
|
|
before(async () => {
|
|
if (fixtures.length === 0) return;
|
|
try {
|
|
playwright = await import('playwright');
|
|
} catch (err) {
|
|
throw new Error(
|
|
`Playwright is required for live-e2e tests (${err.message}). Run: npx playwright install chromium`,
|
|
);
|
|
}
|
|
try {
|
|
browser = await playwright.chromium.launch({ headless: true });
|
|
} catch (err) {
|
|
throw new Error(`Failed to launch Chromium (${err.message}). Run: npx playwright install chromium`);
|
|
}
|
|
});
|
|
|
|
after(async () => {
|
|
if (browser) await browser.close();
|
|
});
|
|
|
|
for (const { name, fixture } of fixtures) {
|
|
describe(`live-e2e · ${name} (${fixture.runtime.styling || 'unknown-styling'})`, () => {
|
|
it('drives the full click → Go → cycle → accept cycle', async (t) => {
|
|
// Fixtures may declare `runtime.knownLimitation` to flag a scenario
|
|
// that exposes a genuine live-mode gap rather than a test bug. The
|
|
// test still attempts the full chain but does not fail the suite when
|
|
// the documented failure mode appears — it surfaces the diagnostic so
|
|
// the limitation is visible in the run output.
|
|
const knownLimitation = fixture.runtime.knownLimitation;
|
|
|
|
// Pick the agent. `IMPECCABLE_E2E_AGENT=llm` opts into the real Claude
|
|
// API; everything else uses the deterministic fake. Skip rather than
|
|
// fail when LLM is requested but no API key is set so default suite
|
|
// runs in unauthenticated environments still pass.
|
|
const agentMode = process.env.IMPECCABLE_E2E_AGENT || 'fake';
|
|
let agent;
|
|
if (agentMode === 'llm') {
|
|
agent = await createLlmAgent({
|
|
model: process.env.IMPECCABLE_E2E_LLM_MODEL,
|
|
log: (m) => t.diagnostic('[llm] ' + m),
|
|
});
|
|
if (!agent) {
|
|
t.skip('IMPECCABLE_E2E_AGENT=llm requires ANTHROPIC_API_KEY');
|
|
return;
|
|
}
|
|
t.diagnostic(`Using LLM agent (model=${process.env.IMPECCABLE_E2E_LLM_MODEL || 'claude-haiku-4-5'})`);
|
|
} else {
|
|
agent = createFakeAgent();
|
|
}
|
|
|
|
t.diagnostic(`Booting fixture ${name}`);
|
|
const session = await bootFixtureSession({
|
|
name,
|
|
fixture,
|
|
browser,
|
|
agent,
|
|
log: (m) => t.diagnostic(m),
|
|
});
|
|
|
|
const { page, tmp, consoleErrors, teardown } = session;
|
|
const expectedCount = 3;
|
|
const pickSelector = fixture.runtime.pickSelector || 'h1.hero-title';
|
|
|
|
try {
|
|
// 1. Handshake
|
|
t.diagnostic('Waiting for live handshake');
|
|
await waitForHandshake(page);
|
|
|
|
// 2. preActions — fixtures with hidden/conditional content (modals,
|
|
// tabs, routes) drive the page into the right state before pick.
|
|
if (fixture.runtime.preActions) {
|
|
t.diagnostic(`Running ${fixture.runtime.preActions.length} preAction(s)`);
|
|
await runPreActions(page, fixture.runtime.preActions);
|
|
}
|
|
|
|
// 3. Pick the target element
|
|
t.diagnostic(`Picking ${pickSelector}`);
|
|
await pickElement(page, pickSelector);
|
|
|
|
if (process.env.IMPECCABLE_E2E_DEBUG) {
|
|
const barText = await page.evaluate(() => {
|
|
const bar = document.querySelector('#impeccable-live-bar');
|
|
return bar ? { display: bar.style.display, text: bar.textContent || '', html: bar.innerHTML.slice(0, 500) } : null;
|
|
});
|
|
t.diagnostic(`Bar after pick: ${JSON.stringify(barText)}`);
|
|
}
|
|
|
|
// 3. Click Go (default action 'impeccable', default count 3 — fixture-stable)
|
|
t.diagnostic('Clicking Go');
|
|
await clickGo(page);
|
|
|
|
// 4. Wait for the agent's variants to land (HMR + MutationObserver).
|
|
// For fixtures whose picked element lives inside a conditional
|
|
// render (modal, tab, route), HMR can remount the parent and lose
|
|
// the open/active state — the wrapper exists in source but isn't
|
|
// in the DOM, so MutationObserver never sees it. Live mode now
|
|
// surfaces a toast asking the user to retrace the path; we mirror
|
|
// that here by re-running preActions on the first short timeout.
|
|
//
|
|
// The first-pass timeout has to be long enough to cover the agent's
|
|
// generate latency before declaring "state was lost, retrace." A
|
|
// fake agent finishes in <100ms; an LLM agent typically lands in
|
|
// 3-8s. Scale the gate accordingly.
|
|
t.diagnostic(`Waiting for CYCLING state with ${expectedCount} variants`);
|
|
const firstPassTimeoutMs = agentMode === 'llm' ? 25_000 : 5_000;
|
|
let cyclingReached = false;
|
|
if (fixture.runtime.preActions) {
|
|
try {
|
|
await waitForCycling(page, expectedCount, { timeout: firstPassTimeoutMs });
|
|
cyclingReached = true;
|
|
} catch {
|
|
t.diagnostic(`Cycling not reached in ${firstPassTimeoutMs}ms — retracing preActions`);
|
|
await runPreActions(page, fixture.runtime.preActions);
|
|
}
|
|
}
|
|
try {
|
|
if (!cyclingReached) {
|
|
// Default 30s; LLM mode bumps to 60s to absorb API latency on
|
|
// top of HMR settle time.
|
|
const finalTimeoutMs = agentMode === 'llm' ? 60_000 : 30_000;
|
|
await waitForCycling(page, expectedCount, { timeout: finalTimeoutMs });
|
|
}
|
|
} catch (err) {
|
|
if (process.env.IMPECCABLE_E2E_DEBUG) {
|
|
const variantCount = await page.evaluate(() =>
|
|
document.querySelectorAll('[data-impeccable-variant]').length,
|
|
);
|
|
const barInfo = await page.evaluate(() => {
|
|
const bars = document.querySelectorAll('#impeccable-live-bar');
|
|
return {
|
|
count: bars.length,
|
|
bars: [...bars].map((bar) => ({
|
|
display: bar.style.display,
|
|
opacity: bar.style.opacity,
|
|
text: bar.textContent || '',
|
|
innerHtml: bar.innerHTML.slice(0, 600),
|
|
})),
|
|
__init: window.__IMPECCABLE_LIVE_INIT__,
|
|
};
|
|
});
|
|
t.diagnostic(`waitForCycling failed; variants in DOM: ${variantCount}`);
|
|
t.diagnostic(`Bar state: ${JSON.stringify(barInfo)}`);
|
|
t.diagnostic(`--- dev server tail ---\n${session.dev.log()}`);
|
|
}
|
|
throw err;
|
|
}
|
|
|
|
// 5. Source-side check: wrapper + style + variants are present
|
|
const sourceFile = await locateSessionFile(tmp);
|
|
const after = readFileSync(sourceFile, 'utf-8');
|
|
assert.match(after, /data-impeccable-variants="/, 'wrapper inserted');
|
|
assert.match(after, /<style data-impeccable-css="/, 'colocated <style> block present');
|
|
assert.match(after, /@scope \(\[data-impeccable-variant="1"\]\)/, 'scoped CSS for variant 1');
|
|
assert.match(after, /@scope \(\[data-impeccable-variant="2"\]\)/, 'scoped CSS for variant 2');
|
|
assert.match(after, /@scope \(\[data-impeccable-variant="3"\]\)/, 'scoped CSS for variant 3');
|
|
// Param manifest assertions are scoped to fake-agent mode. The fake
|
|
// agent deterministically emits one param per variant covering all
|
|
// three kinds; the LLM agent is non-deterministic and may legitimately
|
|
// emit no params per the live.md spec ("variants are fixed points").
|
|
if (agentMode === 'fake') {
|
|
assert.match(after, /data-impeccable-params=/, 'data-impeccable-params manifest emitted');
|
|
for (const kind of ['range', 'steps', 'toggle']) {
|
|
assert.match(after, new RegExp(`"kind"\\s*:\\s*"${kind}"`), `param kind ${kind} present`);
|
|
}
|
|
}
|
|
|
|
// 6. Cycle to variant 2 (the bold one in the fake agent)
|
|
t.diagnostic('Cycling to variant 2');
|
|
await clickNext(page);
|
|
const visible = await getVisibleVariant(page);
|
|
assert.equal(visible, 2, 'variant 2 visible after one Next');
|
|
|
|
// 7. Accept variant 2
|
|
t.diagnostic('Accepting variant 2');
|
|
await clickAccept(page);
|
|
|
|
// 8. Wait for live-accept + the agent's carbonize cleanup to land.
|
|
// File-side: wrapper, all variants, and carbonize markers gone;
|
|
// only the accepted inner element survives.
|
|
t.diagnostic('Waiting for accept + carbonize cleanup to land');
|
|
const final = await waitForSourceClean(sourceFile, 20_000);
|
|
assert.doesNotMatch(final, /data-impeccable-variants="/, 'variants wrapper removed');
|
|
assert.doesNotMatch(final, /impeccable-variants-start/, 'variants-start marker removed');
|
|
assert.doesNotMatch(final, /impeccable-carbonize-start/, 'carbonize-start marker removed');
|
|
assert.doesNotMatch(final, /impeccable-carbonize-end/, 'carbonize-end marker removed');
|
|
assert.doesNotMatch(final, /data-impeccable-variant="/, 'no leftover variant scaffolding');
|
|
// Accept the original class as a substring of the className value so
|
|
// an LLM agent that adds classes around the original (e.g.
|
|
// class="hero-title bold red") still passes — only the literal
|
|
// class="hero-title" form would otherwise match.
|
|
assert.match(
|
|
final,
|
|
/<h1[^>]*(class|className)="[^"]*\bhero-title\b[^"]*"/,
|
|
'accepted h1 survives with hero-title class',
|
|
);
|
|
|
|
// 9. DOM-side: at least one matching element, none inside any wrapper.
|
|
await page.waitForFunction(
|
|
(sel) => {
|
|
const all = document.querySelectorAll(sel);
|
|
if (all.length < 1) return false;
|
|
for (const el of all) {
|
|
if (el.closest('[data-impeccable-variants],[data-impeccable-variant]')) return false;
|
|
}
|
|
return true;
|
|
},
|
|
pickSelector,
|
|
{ timeout: 20_000 },
|
|
);
|
|
|
|
// 9b. reloadProbe — fixtures with conditional render assert that the
|
|
// accepted variant survives a full page reload. The picked element
|
|
// may be hidden by default (closed modal, non-default tab); the
|
|
// probe re-runs preActions to bring it back into the DOM.
|
|
if (fixture.runtime.reloadProbe) {
|
|
t.diagnostic('Running reloadProbe (reload + reach + assert)');
|
|
await page.reload({ waitUntil: 'domcontentloaded' });
|
|
if (fixture.runtime.reloadProbe.preActions) {
|
|
await runPreActions(page, fixture.runtime.reloadProbe.preActions);
|
|
}
|
|
const expectSelector = fixture.runtime.reloadProbe.expectSelector || pickSelector;
|
|
await page.waitForSelector(expectSelector, { timeout: 10_000 });
|
|
}
|
|
|
|
// 10. Console hygiene — no errors during the whole flow.
|
|
if (fixture.runtime.probe?.expectConsoleClean) {
|
|
const realErrors = consoleErrors.filter((e) =>
|
|
!/(Download the React DevTools|StrictMode|Failed to load resource: the server responded with a status of 404)/i.test(e),
|
|
);
|
|
if (realErrors.length > 0) {
|
|
t.diagnostic('--- console errors ---');
|
|
for (const e of realErrors) t.diagnostic(e);
|
|
t.diagnostic('--- final source ---');
|
|
t.diagnostic(readFileSync(sourceFile, 'utf-8'));
|
|
}
|
|
assert.equal(
|
|
realErrors.length,
|
|
0,
|
|
`expected clean console, got:\n${realErrors.join('\n')}`,
|
|
);
|
|
}
|
|
} catch (err) {
|
|
if (knownLimitation) {
|
|
t.diagnostic(`KNOWN LIMITATION: ${knownLimitation}`);
|
|
t.diagnostic(`Failure: ${err.message?.split('\n')[0] || err}`);
|
|
t.skip(`known limitation: ${knownLimitation}`);
|
|
return;
|
|
}
|
|
throw err;
|
|
} finally {
|
|
await teardown();
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Drive a list of pre-pick / reload-probe actions. Used to set up tricky
|
|
* scenarios: open a modal, switch tabs, navigate routes.
|
|
*
|
|
* Live mode's element picker intercepts every page click in capture phase
|
|
* while `pickActive === true`, so any action that depends on the page's own
|
|
* click handler (open a modal, switch a tab) gets swallowed. We bracket the
|
|
* action sequence with two clicks of the global bar's pick toggle and leave
|
|
* the picker in its original state once preActions complete.
|
|
*
|
|
* Supported action shapes:
|
|
* { "type": "click", "selector": "..." }
|
|
* { "type": "goto", "path": "/about" }
|
|
* { "type": "wait", "selector": "..." }
|
|
*/
|
|
async function runPreActions(page, actions) {
|
|
const PICK_TOGGLE = '#impeccable-live-pick-toggle';
|
|
const pickerToggle = await page.$(PICK_TOGGLE);
|
|
const wasActive = pickerToggle
|
|
? await pickerToggle.evaluate((el) => el.dataset.active === 'true')
|
|
: false;
|
|
if (wasActive) await page.locator(PICK_TOGGLE).click();
|
|
|
|
try {
|
|
for (const a of actions) {
|
|
if (a.type === 'click') {
|
|
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) {
|
|
// Re-arm the picker. If the page navigated mid-action the toggle may
|
|
// belong to a freshly mounted bar — best-effort, no throw.
|
|
const after = await page.$(PICK_TOGGLE);
|
|
if (after) {
|
|
const isActive = await after.evaluate((el) => el.dataset.active === 'true');
|
|
if (!isActive) await page.locator(PICK_TOGGLE).click();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Poll the file until carbonize cleanup has landed: no variants wrapper, no
|
|
* carbonize markers, no leftover variant divs. Returns the final contents.
|
|
*/
|
|
async function waitForSourceClean(filePath, timeoutMs) {
|
|
const start = Date.now();
|
|
let last = '';
|
|
while (Date.now() - start < timeoutMs) {
|
|
last = readFileSync(filePath, 'utf-8');
|
|
const dirty =
|
|
last.includes('data-impeccable-variants=') ||
|
|
last.includes('impeccable-variants-start') ||
|
|
last.includes('impeccable-carbonize-start') ||
|
|
last.includes('data-impeccable-variant=');
|
|
if (!dirty) return last;
|
|
await new Promise((r) => setTimeout(r, 100));
|
|
}
|
|
throw new Error(`source not clean after ${timeoutMs}ms — last contents:\n${last}`);
|
|
}
|
|
|
|
/**
|
|
* Find the source file that received the wrapper. We look for any tracked
|
|
* file containing the variants marker — the agent always writes to exactly
|
|
* one file per session.
|
|
*/
|
|
async function locateSessionFile(tmp) {
|
|
const candidates = walkSources(tmp);
|
|
for (const f of candidates) {
|
|
const body = readFileSync(f, 'utf-8');
|
|
if (
|
|
body.includes('data-impeccable-variants=') ||
|
|
body.includes('impeccable-carbonize-start') ||
|
|
body.includes('impeccable-variants-start')
|
|
) {
|
|
return f;
|
|
}
|
|
}
|
|
throw new Error('Could not locate session source file under ' + tmp);
|
|
}
|
|
|
|
function walkSources(root) {
|
|
const results = [];
|
|
const stack = [root];
|
|
const SKIP = new Set(['node_modules', '.git', '.svelte-kit', 'dist', '.vite', 'build', '.next']);
|
|
const EXTS = ['.html', '.jsx', '.tsx', '.svelte', '.astro', '.vue'];
|
|
while (stack.length) {
|
|
const dir = stack.pop();
|
|
let entries;
|
|
try { entries = readdirSync(dir, { withFileTypes: true }); } catch { continue; }
|
|
for (const e of entries) {
|
|
const full = join(dir, e.name);
|
|
if (e.isDirectory()) {
|
|
if (!SKIP.has(e.name)) stack.push(full);
|
|
continue;
|
|
}
|
|
if (EXTS.some((x) => e.name.endsWith(x))) results.push(full);
|
|
}
|
|
}
|
|
return results;
|
|
}
|