ui.mjs

  1/**
  2 * Playwright helpers that drive the live-mode bar UI exactly the way a user
  3 * would: pick an element, configure, Go, cycle, accept.
  4 *
  5 * Selector strategy: live-browser.js uses deterministic ids (`impeccable-live-*`)
  6 * for the global bar, per-element bar, action picker, and params panel. Buttons
  7 * inside the per-element bar are matched by visible text or unicode glyph
  8 * (`Go →`, `← / →`, `✓ Accept`, `✕`). All selectors below come from
  9 * skill/scripts/live-browser.js — keep this file in sync if
 10 * the bar's text content changes.
 11 */
 12
 13const BAR_ID = '#impeccable-live-bar';
 14const GLOBAL_BAR_ID = '#impeccable-live-global-bar';
 15const PICKER_ID = '#impeccable-live-picker';
 16
 17/**
 18 * Wait for the live handshake to complete:
 19 *   - window.__IMPECCABLE_LIVE_INIT__ set
 20 *   - global bar mounted
 21 *   - SSE connection established (state transitioned to PICKING)
 22 *
 23 * Times out generously since some frameworks delay first render.
 24 */
 25export async function waitForHandshake(page, { timeout = 20_000 } = {}) {
 26  await page.waitForFunction(
 27    () => window.__IMPECCABLE_LIVE_INIT__ === true,
 28    { timeout },
 29  );
 30  await page.waitForSelector(GLOBAL_BAR_ID, { timeout });
 31  // Wait for the picker mode to be active (live.js flips state PICKING after
 32  // SSE 'connected' arrives). We can detect it via the global bar's pick
 33  // toggle being in its ready state. Soft wait — fall through after a beat
 34  // even if the toggle hasn't visibly shifted.
 35  await page.waitForTimeout(250);
 36}
 37
 38/**
 39 * Click an in-page element to select it. live-browser.js's picker only acts
 40 * when state === 'PICKING' AND pickActive is true; pickActive starts true on
 41 * connect. The handler reads the hovered element from `mousemove`, so we
 42 * dispatch a hover before the click.
 43 */
 44export async function pickElement(page, selector) {
 45  const el = await page.waitForSelector(selector, { timeout: 5_000 });
 46  await el.hover();
 47  // Tiny settle: live-browser updates `hoveredElement` on mousemove, and the
 48  // click handler reads from it.
 49  await page.waitForTimeout(50);
 50  await el.click();
 51  // Per-element bar mounts on click → wait for it.
 52  await page.waitForSelector(BAR_ID, { state: 'visible', timeout: 5_000 });
 53  // Wait specifically for the Configure-row Go button to be in the bar.
 54  // pickElement returning before that race-conditions with clickGo on
 55  // fixtures whose framework re-renders right after pick (modal open, tab
 56  // switch). Anchoring the wait on the Go button's text is robust: the bar
 57  // can be visible-but-empty (state=PICKING) before showBar('configure')
 58  // populates the row.
 59  await page.waitForFunction(
 60    (barSel) => {
 61      const bar = document.querySelector(barSel);
 62      if (!bar) return false;
 63      const btns = [...bar.querySelectorAll('button')];
 64      return btns.some((b) => /Go\b/.test(b.textContent || ''));
 65    },
 66    BAR_ID,
 67    { timeout: 5_000 },
 68  );
 69}
 70
 71/**
 72 * Set the variant count by clicking the count button (cycles 2 → 3 → 4 → 2).
 73 * Default is 3. If the desired count is already showing, this is a no-op.
 74 */
 75export async function setCount(page, count) {
 76  if (count < 2 || count > 4) throw new Error('count must be 2..4');
 77  for (let i = 0; i < 4; i++) {
 78    const current = await page.evaluate((barSel) => {
 79      const bar = document.querySelector(barSel);
 80      if (!bar) return null;
 81      const btns = [...bar.querySelectorAll('button')];
 82      const btn = btns.find((b) => /^×\d+$/.test((b.textContent || '').trim()));
 83      if (!btn) return null;
 84      return parseInt((btn.textContent || '').trim().slice(1), 10);
 85    }, BAR_ID);
 86    if (current === count) return;
 87    await page.locator(`${BAR_ID} button`, { hasText: /^×\d+$/ }).click();
 88  }
 89  throw new Error(`could not cycle count to ${count}`);
 90}
 91
 92/**
 93 * Click Go. Browser POSTs the generate event; the agent picks it up.
 94 *
 95 * On fixtures whose preActions triggered a layout shift (modal/tab opening)
 96 * the bar's open animation can still be running when we click, and
 97 * Playwright's stability gate occasionally times out on the first attempt.
 98 * Retry up to three times with a settle in between so a single race doesn't
 99 * fail the test.
100 */
101export async function clickGo(page) {
102  await clickBarButton(page, /Go\b/);
103}
104
105/**
106 * Wait for the bar to enter CYCLING state — happens after the agent's
107 * variants land in the DOM via HMR and the MutationObserver counts them.
108 *
109 * The cycling row has the visible counter `N/M` in monospaced font; we
110 * detect it by content. The bar can also auto-reload if HMR was slow, so
111 * we give it a generous window.
112 */
113export async function waitForCycling(page, expectedCount, { timeout = 30_000 } = {}) {
114  await page.waitForFunction(
115    ({ barSel, expected }) => {
116      const bar = document.querySelector(barSel);
117      if (!bar) return false;
118      const text = bar.textContent || '';
119      // Counter format: "1/3", "2/3" etc. Look for any "i/N" with N matching.
120      const m = text.match(/(\d+)\s*\/\s*(\d+)/);
121      if (!m) return false;
122      return parseInt(m[2], 10) === expected;
123    },
124    { barSel: BAR_ID, expected: expectedCount },
125    { timeout },
126  );
127}
128
129/**
130 * Click the next variant button (right arrow).
131 */
132export async function clickNext(page) {
133  await clickBarButton(page, '→');
134}
135
136export async function clickPrev(page) {
137  await clickBarButton(page, '←');
138}
139
140async function clickBarButton(page, label) {
141  const button = page.locator(`${BAR_ID} button`, { hasText: label });
142  const textMatch = label instanceof RegExp
143    ? { kind: 'regex', source: label.source, flags: label.flags }
144    : { kind: 'text', value: String(label) };
145  let lastErr;
146  for (let attempt = 0; attempt < 3; attempt++) {
147    try {
148      await button.click({ timeout: 5_000 });
149      return;
150    } catch (err) {
151      lastErr = err;
152      await page.waitForTimeout(500);
153    }
154  }
155  // Real-LLM fixtures can leave Vite/Tailwind HMR settling for longer than a
156  // human-visible click target stays Playwright-stable. Dispatch the click on
157  // the current button if normal user-like clicks lost the remount race.
158  const clicked = await page.evaluate(findAndClickBarButton, { barSel: BAR_ID, textMatch });
159  if (!clicked) throw lastErr;
160}
161
162async function dispatchBarButton(page, label) {
163  const textMatch = label instanceof RegExp
164    ? { kind: 'regex', source: label.source, flags: label.flags }
165    : { kind: 'text', value: String(label) };
166  return page.evaluate(findAndClickBarButton, { barSel: BAR_ID, textMatch });
167}
168
169function findAndClickBarButton({ barSel, textMatch }) {
170  const bar = document.querySelector(barSel);
171  if (!bar) return false;
172  const btn = [...bar.querySelectorAll('button')]
173    .find((candidate) => {
174      const text = candidate.textContent || '';
175      if (textMatch.kind === 'regex') return new RegExp(textMatch.source, textMatch.flags).test(text);
176      return text.includes(textMatch.value);
177    });
178  if (!btn) return false;
179  btn.click();
180  return true;
181}
182
183/**
184 * Read the currently visible variant index (the "i" in "i/N").
185 */
186export async function getVisibleVariant(page) {
187  return page.evaluate((barSel) => {
188    const wrapper = document.querySelector('[data-impeccable-variants]');
189    if (wrapper) {
190      const variants = [...wrapper.querySelectorAll('[data-impeccable-variant]:not([data-impeccable-variant="original"])')];
191      const visible = variants.find((variant) => variant.style.display !== 'none');
192      const idx = visible ? parseInt(visible.dataset.impeccableVariant || '0', 10) : 0;
193      if (idx > 0) return idx;
194    }
195    const bar = document.querySelector(barSel);
196    if (!bar) return null;
197    const m = (bar.textContent || '').match(/(\d+)\s*\/\s*(\d+)/);
198    return m ? parseInt(m[1], 10) : null;
199  }, BAR_ID);
200}
201
202/**
203 * Click Accept — sends accept event with current variantId + paramValues.
204 * The bar transitions to a "Saving..." spinner, then a green confirmed row.
205 */
206export async function clickAccept(page, { expectedVariant } = {}) {
207  if (expectedVariant != null) {
208    await ensureVisibleVariant(page, expectedVariant);
209  }
210  if (await dispatchBarButton(page, /Accept/)) return;
211  await clickBarButton(page, /Accept/);
212}
213
214async function ensureVisibleVariant(page, expectedVariant) {
215  for (let attempt = 0; attempt < 5; attempt++) {
216    const current = await getVisibleVariant(page);
217    if (current === expectedVariant) return;
218    if (current == null) {
219      await page.waitForTimeout(300);
220      continue;
221    }
222    await clickBarButton(page, current < expectedVariant ? '→' : '←');
223    await page.waitForTimeout(300);
224  }
225  const current = await getVisibleVariant(page);
226  if (current !== expectedVariant) {
227    throw new Error(`expected visible variant ${expectedVariant} before accept, got ${current}`);
228  }
229}
230
231/**
232 * Click Discard — sends discard event. live-accept.mjs unwinds the wrapper
233 * and restores the original.
234 */
235export async function clickDiscard(page) {
236  // The discard button has just a "✕" glyph as text content.
237  await page.locator(`${BAR_ID} button`, { hasText: '✕' }).click();
238}
239
240/**
241 * Wait for the bar to go away (after accept/discard the bar hides on confirm).
242 */
243export async function waitForBarHidden(page, { timeout = 10_000 } = {}) {
244  await page.waitForFunction(
245    (barSel) => {
246      const bar = document.querySelector(barSel);
247      return !bar || bar.style.display === 'none';
248    },
249    BAR_ID,
250    { timeout },
251  );
252}