Files
pbakaus_impeccable/tests/live-e2e/ui.mjs
T
Abdul WahabandGitHub 84135db0e6 Add DeepSeek live E2E adapter (#163)
* Add DeepSeek live E2E adapter

* Fix DeepSeek live E2E review issues

* Harden live-e2e helpers against silent failures

- htmlToJsx: match multi-line inline style attributes ([\s\S]*?)
- readCliOption: throw when --flag value is missing or another --flag
- llm-agent: echo parsed payload (first 500 chars) in schema-error throws

* Bind hoisted inline styles to their owning tag

normalizeVariantOutput previously hoisted every stripped style attribute
onto a selector derived from the variant's first tag, so a style on a
nested <span> landed on <h1>. Now walks each opening tag and emits one
rule per styled element with a descendant combinator so nested-element
styles target the correct node. Also fixes the duplicated multi-line
style regex bug (.*?) -> ([\s\S]*?) that survived the previous round.

Extracts parseVariantResponse from llm-agent for direct schema-throw
testing, and lifts readCliOption into its own module so its new
missing-value throws can be unit-tested.

Adds tests for:
- multi-line style hoisting
- nested-element tag binding and per-tag rule emission
- astro-global-prefixed selector shape
- no-op identity-return path
- opts.config short-circuit in createLlmAgent
- all four parseVariantResponse schema previews + JSON-parse failure
- readCliOption value/throw matrix

* Hoist inline styles via data attribute, not tag name

Two bugs in normalizeVariantOutput that Bugbot flagged:

1. Hoisted rules like `:scope span` matched every same-tag descendant of
   the variant wrap, so a style on one of several <span>s leaked onto its
   siblings.
2. The opening-tag scan used `[^>]*` for attributes, so a literal `>`
   inside a quoted attribute value (e.g. `aria-label="x > y"`) terminated
   the match early and the trailing `style="..."` was never seen.

stripInlineStylesPerElement now walks each opening tag character by
character respecting quoted attribute values, and tags every styled
element with `data-impeccable-hoist-id="N"`. Rules select on the
attribute so they bind to exactly the one element they came from.
The attribute is stripped during carbonize cleanup so it does not
survive into the final source.

* Harden live E2E variant CSS normalization

* Fix Radix tests

* Harden live E2E pick clicks
2026-05-22 09:28:36 -07:00

298 lines
11 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
* skill/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';
const PICK_TOGGLE_ID = '#impeccable-live-pick-toggle';
/**
* 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 });
for (let attempt = 0; attempt < 2; attempt++) {
await ensurePickerActive(page);
await el.hover();
// Tiny settle: live-browser updates `hoveredElement` on mousemove, and the
// click handler reads from it.
await page.waitForTimeout(50);
await clickPickTarget(page, el);
// Per-element bar mounts on click → wait for it. Dialog fixtures can
// briefly hide the global live chrome while preActions open a portal, so
// retry once after explicitly re-arming picker mode.
const visible = await page
.waitForSelector(BAR_ID, { state: 'visible', timeout: 5_000 })
.then(() => true, () => false);
if (visible) break;
if (attempt === 1) {
await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 1 });
}
}
// 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 },
);
}
async function clickPickTarget(page, el) {
const box = await el.boundingBox();
if (box) {
await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
return;
}
await el.evaluate((node) => node.click());
}
async function ensurePickerActive(page) {
await page.waitForSelector(GLOBAL_BAR_ID, { timeout: 5_000 });
const active = await page
.locator(PICK_TOGGLE_ID)
.evaluate((el) => el.dataset.active === 'true')
.catch(() => false);
if (active) return;
const clicked = await page.evaluate((sel) => {
const btn = document.querySelector(sel);
if (!btn) return false;
btn.click();
return true;
}, PICK_TOGGLE_ID);
if (!clicked) {
await page.locator(PICK_TOGGLE_ID).click({ timeout: 5_000 });
}
await page.waitForFunction(
(sel) => document.querySelector(sel)?.dataset.active === 'true',
PICK_TOGGLE_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.
*
* On fixtures whose preActions triggered a layout shift (modal/tab opening)
* the bar's open animation can still be running when we click, and
* Playwright's stability gate occasionally times out on the first attempt.
* Retry up to three times with a settle in between so a single race doesn't
* fail the test.
*/
export async function clickGo(page) {
await clickBarButton(page, /Go\b/);
}
/**
* 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 clickBarButton(page, '→');
}
export async function clickPrev(page) {
await clickBarButton(page, '←');
}
async function clickBarButton(page, label) {
const button = page.locator(`${BAR_ID} button`, { hasText: label });
const textMatch = label instanceof RegExp
? { kind: 'regex', source: label.source, flags: label.flags }
: { kind: 'text', value: String(label) };
let lastErr;
for (let attempt = 0; attempt < 3; attempt++) {
try {
await button.click({ timeout: 5_000 });
return;
} catch (err) {
lastErr = err;
await page.waitForTimeout(500);
}
}
// Real-LLM fixtures can leave Vite/Tailwind HMR settling for longer than a
// human-visible click target stays Playwright-stable. Dispatch the click on
// the current button if normal user-like clicks lost the remount race.
const clicked = await page.evaluate(findAndClickBarButton, { barSel: BAR_ID, textMatch });
if (!clicked) throw lastErr;
}
async function dispatchBarButton(page, label) {
const textMatch = label instanceof RegExp
? { kind: 'regex', source: label.source, flags: label.flags }
: { kind: 'text', value: String(label) };
return page.evaluate(findAndClickBarButton, { barSel: BAR_ID, textMatch });
}
function findAndClickBarButton({ barSel, textMatch }) {
const bar = document.querySelector(barSel);
if (!bar) return false;
const btn = [...bar.querySelectorAll('button')]
.find((candidate) => {
const text = candidate.textContent || '';
if (textMatch.kind === 'regex') return new RegExp(textMatch.source, textMatch.flags).test(text);
return text.includes(textMatch.value);
});
if (!btn) return false;
btn.click();
return true;
}
/**
* Read the currently visible variant index (the "i" in "i/N").
*/
export async function getVisibleVariant(page) {
return page.evaluate((barSel) => {
const wrapper = document.querySelector('[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);
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, { expectedVariant } = {}) {
if (expectedVariant != null) {
await ensureVisibleVariant(page, expectedVariant);
}
if (await dispatchBarButton(page, /Accept/)) return;
await clickBarButton(page, /Accept/);
}
async function ensureVisibleVariant(page, expectedVariant) {
for (let attempt = 0; attempt < 5; attempt++) {
const current = await getVisibleVariant(page);
if (current === expectedVariant) return;
if (current == null) {
await page.waitForTimeout(300);
continue;
}
await clickBarButton(page, current < expectedVariant ? '→' : '←');
await page.waitForTimeout(300);
}
const current = await getVisibleVariant(page);
if (current !== expectedVariant) {
throw new Error(`expected visible variant ${expectedVariant} before accept, got ${current}`);
}
}
/**
* 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 },
);
}