shots: feature rotate, per-language frame fallback, layout-aware capture selectors
This commit is contained in:
parent
2e1c7cdc3c
commit
61b5fa0623
15 changed files with 355 additions and 17 deletions
79
cli/shots/selectors.ts
Normal file
79
cli/shots/selectors.ts
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// 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';
|
||||
|
||||
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<string, unknown>, 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<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 });
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue