// Layout-aware element selectors for web capture shots (shots.capture.shots..waitSelector / .click): // a list of candidates tried in order, with a per-device list first ("phone", "tablet"). Data attributes are preferred // (`[data-shot="shop-row"]`), then CSS, then Playwright text (`text=Shop`, `role=button[name="Shop"]`). A shop row that is // a list on the phone and a grid at 1180x820 needs different selectors; the miss message names the selector and the size. import type { ShotSelector } from '../lib/config'; type Device = 'phone' | 'tablet'; /** Candidates for one device: its own list first, then the shared ones, without repeats. */ export function candidates(sel: ShotSelector | undefined, device: Device): string[] { if (sel === undefined) return []; const list = (v: string | string[] | undefined): string[] => (v === undefined ? [] : Array.isArray(v) ? v : [v]); const all = typeof sel === 'string' || Array.isArray(sel) ? list(sel) : [...list(sel[device]), ...list(sel.any)]; return [...new Set(all)]; } export function validateSelector(where: string, sel: unknown): string[] { const ok = (v: unknown): boolean => typeof v === 'string' && v.trim() !== ''; const okList = (v: unknown): boolean => ok(v) || (Array.isArray(v) && v.length > 0 && v.every(ok)); if (okList(sel)) return []; if (sel && typeof sel === 'object' && !Array.isArray(sel)) { const o = sel as Record, bad = Object.keys(o).filter(k => !['phone', 'tablet', 'any'].includes(k)); if (bad.length) return [`${where}: unknown key ${bad.join(', ')} (phone | tablet | any)`]; if (!Object.keys(o).length) return [`${where}: at least one of phone | tablet | any`]; return Object.entries(o).filter(([, v]) => !okList(v)).map(([k]) => `${where}.${k}: a selector or a list of selectors`); } return [`${where}: a selector, a list of selectors, or { phone, tablet, any }`]; } /** Minimal Playwright surface used here, so tests can fake a page. */ export interface SelPage { locator(sel: string): { count(): Promise; first(): { click(o?: { timeout?: number }): Promise } }; waitForTimeout(ms: number): Promise; } export interface SelResult { selector: string; matches: number } /** Match counts of every candidate. */ export async function probe(page: SelPage, list: string[]): Promise { const out: SelResult[] = []; for (const selector of list) { let matches = 0; try { matches = await page.locator(selector).count(); } catch { matches = -1; } // -1: not a valid selector out.push({ selector, matches }); } return out; } /** The miss message: which selector, on which size, what each candidate matched. */ export function missMessage(what: string, where: { device: Device; w: number; h: number }, res: SelResult[]): string { const rows = res.map(r => `${r.selector} ${r.matches < 0 ? 'is not a valid selector' : `matched ${r.matches}`}`).join('; '); return `${what} selector missed on ${where.device} ${where.w}x${where.h}: ${rows}. Add a candidate for this layout (waitSelector/click: { ${where.device}: [...] }), data attributes first`; } /** Waits (polling) until one candidate matches; returns it. Throws the miss message on timeout. */ export async function firstMatch(page: SelPage, list: string[], what: string, where: { device: Device; w: number; h: number }, timeoutMs: number): Promise { const end = Date.now() + timeoutMs; let res: SelResult[] = []; for (;;) { res = await probe(page, list); const hit = res.find(r => r.matches > 0); if (hit) return hit.selector; if (Date.now() >= end) throw new Error(missMessage(what, where, res)); await page.waitForTimeout(100); } } /** waitSelector, then click, for one shot on one device. No-ops without either. */ export async function applySelectors(page: SelPage, spec: { waitSelector?: ShotSelector; click?: ShotSelector | ShotSelector[] }, where: { device: Device; w: number; h: number }, timeoutMs: number): Promise { const w = candidates(spec.waitSelector, where.device); if (w.length) await firstMatch(page, w, 'waitSelector', where, timeoutMs); const clicks = spec.click === undefined ? [] : Array.isArray(spec.click) && spec.click.some(c => typeof c !== 'string') ? spec.click : [spec.click as ShotSelector]; for (const c of clicks) { const list = candidates(c, where.device); if (!list.length) continue; const hit = await firstMatch(page, list, 'click', where, timeoutMs); await page.locator(hit).first().click({ timeout: timeoutMs }); } }