storebound/cli/shots/selectors.ts

79 lines
4.5 KiB
TypeScript

// Layout-aware element selectors for web capture shots (shots.capture.shots.<name>.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';
import type { CaptureDevice as Device } from './devices';
/** 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<string, unknown>, bad = Object.keys(o).filter(k => !['phone', 'tablet', 'landscape', 'any'].includes(k));
if (bad.length) return [`${where}: unknown key ${bad.join(', ')} (phone | tablet | landscape | any)`];
if (!Object.keys(o).length) return [`${where}: at least one of phone | tablet | landscape | 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, landscape, any }`];
}
/** Minimal Playwright surface used here, so tests can fake a page. */
export interface SelPage {
locator(sel: string): { count(): Promise<number>; first(): { click(o?: { timeout?: number }): Promise<void> } };
waitForTimeout(ms: number): Promise<void>;
}
export interface SelResult { selector: string; matches: number }
/** Match counts of every candidate. */
export async function probe(page: SelPage, list: string[]): Promise<SelResult[]> {
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<string> {
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<void> {
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 });
}
}