79 lines
4.5 KiB
TypeScript
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 });
|
|
}
|
|
}
|