From 61b5fa062370187c6ede41c6f07f4e77e1fafc29 Mon Sep 17 00:00:00 2001 From: CRT Date: Sat, 24 Oct 2026 13:00:48 +0800 Subject: [PATCH] shots: feature rotate, per-language frame fallback, layout-aware capture selectors --- CHANGELOG.md | 3 + cli/lib/config.ts | 17 ++++++ cli/lib/embedded.ts | 2 +- cli/shots/capture-web.ts | 31 ++++++++++ cli/shots/capture.ts | 27 +++++++-- cli/shots/check.ts | 10 ++- cli/shots/devices.ts | 5 ++ cli/shots/feature-bleed.html | 6 ++ cli/shots/fonts.ts | 7 ++- cli/shots/lib.ts | 28 ++++++++- cli/shots/render.ts | 13 ++-- cli/shots/selectors.ts | 79 ++++++++++++++++++++++++ skills/store-pitfalls/SKILL.md | 13 ++++ tests/browser/feature-fit.browser.ts | 40 ++++++++++++ tests/shots-gaps.test.ts | 91 ++++++++++++++++++++++++++++ 15 files changed, 355 insertions(+), 17 deletions(-) create mode 100644 cli/shots/selectors.ts create mode 100644 tests/shots-gaps.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 703ba22..1a36300 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,9 @@ ## Unreleased +- `shots.feature.rotate` (degrees) is honoured by the Play feature graphic like a scene frame's `rotate`: added to the template tilt and the phone is re-fitted (`-90` stands a landscape game frame upright); with `shots.layout: "bleed"` the frame image is turned and still covers the canvas. +- `shots check` / `shots render` without frames for a language that has copy use the default language's frames (an info line, no failure); `shots.perLanguageFrames: true` requires per-language frames. `shots fonts --lang en,tr` limits the report to those languages (the font manifest keeps all of them). +- `shots capture` web shots take `waitSelector` and `click` selector lists per layout (`{ phone, tablet, any }`, data attributes first, `text=` fallback). A miss names the selector and the size with every candidate's match count; `shots capture --dry` prints the candidates per size and `--dry --probe` opens the pages and reports which one matched or MISSed on which size. - Play managed publishing: `storebound console plan play-publish-changes` (Publishing overview > "Changes ready to publish" > the owner's final "Publish N changes" + its confirm dialog; never-press: Discard changes, Stop managed publishing; `--lookup` lists the ready production releases from the API) and `console verify play-publish-changes` (API: passes when no production release is left ready to publish; or the page text). `play status` (and `--json` `readyToPublish`) prints the ready items and the plan hint, `storebound status` names the plan, and the desktop Releases tab shows the same hint with a button that opens Publishing overview. A release counts as ready to publish only when the public store page positively shows an older version; a 404, a failed or stale page read, a same or unparseable version is never ready (verify says "not determined" instead of a mismatch). - `apple upload` / `play upload` `--pre-check ""` (config `upload.preCheck`) runs your gate before any byte is sent; a nonzero exit refuses the upload (ship-step exit 1, `PRE_CHECK_FAILED`). `--evidence ` (`upload.evidence`) writes file, sha256, size, command, exit code, stdout tail, time and version, also on failure, and the result carries it as `preCheck`. - New `storebound shots copy translate --to de,es` fills store/shots/copy.json per language (agent translation or `--translate ""`), machine-translated until `shots copy reviewed`, caption limits respected; `shots copy check`; `check` / `shots render` name this command for a missing language. diff --git a/cli/lib/config.ts b/cli/lib/config.ts index f300e23..e0a52fb 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -167,6 +167,10 @@ export interface ShotsConfig { copy: string; /** Raw frames: ///.png */ frames: string; + /** true: every language needs its own frames (//...). Default false: a language with copy but no frames + * folder uses the default language's frames (play.defaultLanguage, else the first language); `shots check` / `render` + * say so as an info line. */ + perLanguageFrames?: boolean; /** Output: ///NN.png and feature/1024x500.png */ out: string; /** Optional logo image for the hero scene and the feature graphic. */ @@ -183,6 +187,9 @@ export interface ShotsConfig { * bottom edge on purpose (shots check then allows it). `scale` (0.5-1.5, default 1) multiplies the phone's width * (still clamped to the canvas with 'contain'). */ feature?: { frame: string; palette?: string | Palette; fit?: 'contain' | 'bleed'; scale?: number; + /** Degrees added to the phone's tilt (layout "bleed": the frame image is turned), like a scene frame's `rotate`: -90 stands a + * landscape game frame upright in a phone lying on its side. Default 0. */ + rotate?: number; /** layout "bleed": the name on the feature graphic when there is no logo (default app.name). */ title?: string; /** Photo background for the feature graphic (path relative to the config, or { image, overlay }); wins over the @@ -207,7 +214,16 @@ export interface ShotsConfig { } /** One raw frame. Web: `path` (+ query). Simulator / emulator: `url` deep link and / or `args` / `extras`. */ +/** Web capture: a selector, candidates tried in order, or per layout { phone, tablet, any } (own list first). Prefer data + * attributes (`[data-shot="shop-row"]`); `text=Shop` and `role=button[name="Shop"]` work as fallbacks. */ +export type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; any?: string | string[] }; + export interface ShotSpec { + /** Web: wait (up to the waitFor timeout) until one of these matches, before waitFor; a miss names the selector and the size. */ + waitSelector?: ShotSelector; + /** Web: element(s) to click after waitSelector, in order (a list of selectors is ONE click with fallbacks; use + * [{...}, {...}] objects for several clicks). */ + click?: ShotSelector | ShotSelector[]; /** Web: URL path after baseUrl, "{lang}" replaced. */ path?: string; /** ios-sim / android-emu: deep link opened after launch, e.g. "mygame://shot/{shot}?lang={lang}". */ @@ -1259,6 +1275,7 @@ export function validateConfig(c: unknown): string[] { if (s.feature !== undefined) { if (!s.feature || typeof s.feature.frame !== 'string' || !s.feature.frame) e.push('shots.feature.frame: a frame name'); if (s.feature?.fit !== undefined && s.feature.fit !== 'contain' && s.feature.fit !== 'bleed') e.push("shots.feature.fit: 'contain' | 'bleed'"); + if (s.feature?.rotate !== undefined && !(typeof s.feature.rotate === 'number' && Math.abs(s.feature.rotate) <= 360)) e.push('shots.feature.rotate: a number of degrees from -360 to 360'); if (s.feature?.scale !== undefined && !(typeof s.feature.scale === 'number' && s.feature.scale >= 0.5 && s.feature.scale <= 1.5)) e.push('shots.feature.scale: a number from 0.5 to 1.5'); e.push(...featureBackgroundIssues(s.feature?.background)); if (s.feature?.palette !== undefined) e.push(...paletteIssues(s.feature.palette, 'shots.feature.palette')); diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index f392f2f..715eb69 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -39,7 +39,7 @@ export const TEMPLATE_TEXT: Record = { export const SHOTS_PAGES: Record = { "caption.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", "feature-app.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", - "feature-bleed.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", + "feature-bleed.html": "\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n
\r\n\r\n\r\n\r\n", "feature.html": "\n\n\n\n\n\n\n\n\n
\n
\n
\n
\n\n\n\n", "template-app.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", "template-bleed.html": "\n\n\n\n\n\n\n\n\n
\n\n\n\n", diff --git a/cli/shots/capture-web.ts b/cli/shots/capture-web.ts index 516c798..0229eeb 100644 --- a/cli/shots/capture-web.ts +++ b/cli/shots/capture-web.ts @@ -24,6 +24,7 @@ import type { ShotSpec, ShotsCapture } from '../lib/config'; import { log, warn } from '../lib/log'; import { type AuthPlan, expiredHint, landedOnLogin, maskSecrets, safeUrl } from './auth'; import { shotsFor } from './devices'; +import { applySelectors, candidates, probe } from './selectors'; import type { FrameDims } from './lib'; type Device = 'phone' | 'tablet'; @@ -166,6 +167,7 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[] if (!expired.has(safeUrl(s.page.url()))) { expired.add(safeUrl(s.page.url())); warn('shots', `${j.lang}/${j.device}/${j.name}: ${mask(expiredHint(s.page.url()))}`); } throw new Error(`landed on the sign-in page ${safeUrl(s.page.url())}`); } + await applySelectors(s.page, j.spec, { device: j.device, w: o.dims[j.device].w, h: o.dims[j.device].h }, waitTimeout); // waitSelector, click: a miss names the selector and the size if (j.spec.waitFor) await s.page.waitForFunction(j.spec.waitFor, null, { timeout: waitTimeout, polling: 50 }); t.waitFor = Date.now() - t0; t0 = Date.now(); for (const js of j.spec.eval ?? []) await s.page.evaluate(js); @@ -245,3 +247,32 @@ export function writeCaptureState(root: string, report: CaptureReport, cache: Ca writeFileSync(cachePath(root), JSON.stringify(Object.fromEntries(Object.entries(cache).sort()), null, 1) + '\n'); writeFileSync(join(root, '.storebound', 'capture-report.json'), JSON.stringify(report, null, 1) + '\n'); } + +/* eslint-disable @typescript-eslint/no-explicit-any */ +/** `shots capture --dry --probe`: opens each page (no screenshot), runs the shot's waitFor, and reports per size which + * selector candidates match. Returns the log lines; `missed` counts shots where no candidate of a list matched. */ +export async function probeSelectors(browser: any, jobs: WebJob[], o: { dims: Record; auth?: AuthPlan; timeoutMs?: number }): Promise<{ lines: string[]; missed: number }> { + const lines: string[] = []; let missed = 0; + for (const j of jobs) { + const lists = [['waitSelector', candidates(j.spec.waitSelector, j.device)], ...(Array.isArray(j.spec.click) && j.spec.click.some(c => typeof c !== 'string') ? j.spec.click : j.spec.click === undefined ? [] : [j.spec.click]) + .map((c, i) => [`click[${i}]`, candidates(c as any, j.device)] as const)].filter(([, l]) => l.length) as [string, string[]][]; + if (!lists.length) continue; + const d = o.dims[j.device]; + const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: 1, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang, ...(o.auth?.contextOptions ?? {}) }); + try { + if (o.auth) await o.auth.prepare(c); + const page = await c.newPage(); + await page.goto(j.url, { waitUntil: 'domcontentloaded', timeout: 90000 }); + if (j.spec.waitFor) await page.waitForFunction(j.spec.waitFor, null, { timeout: o.timeoutMs ?? 20000, polling: 50 }).catch(() => lines.push(`${j.frame} ${d.w}x${d.h}: waitFor did not become true in ${(o.timeoutMs ?? 20000) / 1000}s`)); + for (const [what, list] of lists) { + await page.waitForTimeout(500); + const res = await probe(page, list); + const hit = res.find(r => r.matches > 0); + if (!hit) missed++; + lines.push(`${j.frame} ${j.device} ${d.w}x${d.h} ${what}: ${hit ? `ok via ${hit.selector}` : 'MISS'} (${res.map(r => `${r.selector} -> ${r.matches < 0 ? 'invalid' : r.matches}`).join('; ')})`); + } + } catch (e) { missed++; lines.push(`${j.frame}: probe failed: ${(e as Error).message.split('\n')[0]!.slice(0, 200)}`); } finally { await c.close(); } + } + return { lines, missed }; +} +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/cli/shots/capture.ts b/cli/shots/capture.ts index 1267e7c..c5b6d24 100644 --- a/cli/shots/capture.ts +++ b/cli/shots/capture.ts @@ -1,6 +1,6 @@ // Raw frames from a running build of your app or game, from any engine. // storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder] -// [--dry] [--keep-booted] [--concurrency N] [--only-missing | --force] +// [--dry [--probe]] [--keep-booted] [--concurrency N] [--only-missing | --force] // Frames go to ///.png: the screen BELOW the status bar (the template draws one). // shots.capture.source (default web): // web headless Chromium (playwright), mobile viewport + touch at phone / tablet size. Each shot: @@ -18,7 +18,9 @@ // folder your own frames (any tool, any engine): only checks that every frame the scenes use exists with the // right aspect ratio and resolution. // A simulator storebound booted itself is shut down at the end (--keep-booted keeps it); one that was already running is -// left running with its status bar restored. --dry prints the simctl / adb commands without running them. +// left running with its status bar restored. Web shots may list waitSelector / click candidates per layout +// ({ phone, tablet, any }, data attributes first, text= fallback); --dry prints them per size, --dry --probe opens the +// pages and reports which candidate matched or MISSed on which size. --dry prints the simctl / adb commands without running them. // Simulator / emulator screenshots are full screen: cropTop rows (the status bar) are cut so the frame fits the template // (default per device class from the frame dims: phone 162 px of a 6.9" iPhone, tablet 48 px of a 13" iPad with apple.ipad). // The app or game needs switches for this: language, scene, fixed seed, no ads / tutorial, freeze. See the store-screenshots skill. @@ -26,7 +28,8 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node import { homedir } from 'node:os'; import { join } from 'node:path'; import { spawnSync } from 'node:child_process'; -import type { ShipConfig, ShotsCapture } from '../lib/config'; +import type { ShipConfig, ShotSelector, ShotsCapture } from '../lib/config'; +import { candidates } from './selectors'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { done, log, step, warn } from '../lib/log'; @@ -36,7 +39,7 @@ import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, a cropArgs, cropTopFor, fill, pickSimulator, shotsFor, simBootArgs, simBootStatusArgs, simInstallArgs, simLaunchArgs, simOpenUrlArgs, simScreenshotArgs, simShutdownArgs, simStatusBarArgs, simStatusBarClearArgs, simTarget, SOURCES, type Source, TOOL_HINTS, validateCapture } from './devices'; import { type CopyFile, copyText, type FrameDims, frameDims } from './lib'; -import { captureWeb, defaultConcurrency, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web'; +import { captureWeb, defaultConcurrency, probeSelectors, hashPaths, hashServed, planWeb, readCache, selectJobs, summaryLines, writeCaptureState } from './capture-web'; import { imageInfo } from './spec'; import { authPlan, safeUrl } from './auth'; import * as rt from '../lib/rt'; @@ -122,7 +125,21 @@ export async function run(ctx: Ctx): Promise { if (source === 'web') { if (dry) { - for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`); + for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only)) { + progress(`${lang}/${device}/${name}.png <- ${cap.baseUrl}${(s.path ?? '/').replaceAll('{lang}', lang)}`); + const sel: [string, string[]][] = [['waitSelector', candidates(s.waitSelector, device)], ...(Array.isArray(s.click) && s.click.some(c => typeof c !== 'string') ? s.click : s.click === undefined ? [] : [s.click]).map((c, k) => [`click[${k}]`, candidates(c as ShotSelector, device)] as [string, string[]])]; + for (const [what, list] of sel) if (list.length) log('shots', ` ${what} on ${device} ${dims[device].w}x${dims[device].h}: ${list.join(' | ')}`); + } + if (a.has('probe')) { // open the pages and test every candidate on each size (needs the app running at baseUrl) + need(cap.baseUrl, 'shots.capture.baseUrl'); + const auth = cap.auth ? authPlan(cap.auth, { root, baseUrl: cap.baseUrl! }) : undefined; + const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build: '', fonts: '' }); + const browser = await launch(root, { headless: true, args: ['--mute-audio'] }); + let r; + try { r = await probeSelectors(browser, jobs, { dims, ...(auth ? { auth } : {}) }); } finally { await closeBrowser(browser); } + for (const l of r.lines) log('shots', l); + if (r.missed) throw new Error(`${r.missed} selector lists matched nothing (see MISS lines above: the size and every candidate with its match count)`); + } if (cap.auth) log('shots', cap.auth.storageState ? `would load the signed-in state from ${cap.auth.storageState} into every context` : `would sign every context in first: ${(cap.auth.request?.method ?? 'POST').toUpperCase()} ${safeUrl(cap.auth.request?.url ?? '')} (values from .storebound/credentials.json / env, never shown)`); return done('shots', `capture planned (dry run): ${total} frames from web`); } diff --git a/cli/shots/check.ts b/cli/shots/check.ts index 7f322ee..2697d8f 100644 --- a/cli/shots/check.ts +++ b/cli/shots/check.ts @@ -15,7 +15,7 @@ import type { ShipConfig } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { log, printJson } from '../lib/log'; import { checkFrames, type FrameIssue } from './devices'; -import { defaultSizes, frameDims, renderDims, SIZES } from './lib'; +import { defaultSizes, frameDims, frameLangs, langHasFrames, renderDims, SIZES } from './lib'; import type { LocalIssue, RendersCheck } from '../lib/validate-rules'; import { allIssues, requiredSets, type RendersScan, resolvedTablets, scanRenders, storeOf } from './report'; import { imageInfo } from './spec'; @@ -125,9 +125,13 @@ export async function run(ctx: Ctx): Promise { const tablets = resolvedTablets(config, resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders')); const issues = [...allIssues(scan), ...localFontIssues(readManifest(fontsDirOf(root))).map(issue => ({ where: '.storebound/fonts/fonts.json', issue })), ...backgroundIssues(config, resolve, tablets)]; - const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, resolve(cap.folder ?? config.shots!.frames), scan.langs, tablets) : []; + // languages with copy but no frames use the default language's (shots.perLanguageFrames: true requires them per language) + const framesRoot = resolve(cap?.folder ?? config.shots!.frames), def = config.play?.defaultLanguage ?? config.languages[0]!.id; + const fl = frameLangs(scan.langs, l => langHasFrames(framesRoot, l), def, config.shots?.perLanguageFrames); + const frameIssues = cap?.source === 'folder' ? checkFrameFolder(config, framesRoot, scan.langs.filter(l => !fl.borrowed.includes(l)), tablets) : []; + if (fl.borrowed.length && !ctx.args.has('json')) console.log(`info frames: no frames for ${fl.borrowed.join(', ')}; they use the ${def} frames (shots.perLanguageFrames: true requires per-language frames)`); if (ctx.args.has('json')) { - printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues }); + printJson({ images: scan.images.length, videos: scan.videos.length, issues, frames: frameIssues, info: fl.borrowed.map(l => ({ lang: l, framesFrom: def })) }); } else { for (const { where, issue } of issues) console.log(`${issue.level === 'error' ? 'ERROR' : 'warn '} ${where}: ${issue.msg}`); for (const f of frameIssues) console.log(`ERROR frames/${f.file}: ${f.msg}`); diff --git a/cli/shots/devices.ts b/cli/shots/devices.ts index fecf941..1fa9f20 100644 --- a/cli/shots/devices.ts +++ b/cli/shots/devices.ts @@ -6,6 +6,7 @@ // android-emu adb: demo-mode status bar, per-app language (Android 13+), am start with deep link / extras, // exec-out screencap. Emulator or a USB device. // folder bring your own frames: ///.png, checked for size. +import { validateSelector } from './selectors'; import type { ShotsCapture, ShotSpec, VideoRecord } from '../lib/config'; import { validateAuth } from './auth'; @@ -37,6 +38,10 @@ export function validateCapture(c: ShotsCapture | undefined): string[] { if (src !== 'folder' && (!c.shots || !Object.keys(c.shots).length)) e.push('shots.capture.shots: at least one shot'); for (const [name, s] of Object.entries(c.shots ?? {})) { if (!/^[A-Za-z0-9._-]+$/.test(name)) e.push(`shots.capture.shots.${name}: name must be a file name (letters, digits, . _ -)`); + if (src === 'web') { + if (s.waitSelector !== undefined) e.push(...validateSelector(`shots.capture.shots.${name}.waitSelector`, s.waitSelector)); + if (s.click !== undefined) for (const [i, c] of (Array.isArray(s.click) && s.click.some(x => typeof x !== 'string') ? s.click : [s.click]).entries()) e.push(...validateSelector(`shots.capture.shots.${name}.click[${i}]`, c)); + } else if (s.waitSelector !== undefined || s.click !== undefined) e.push(`shots.capture.shots.${name}: waitSelector / click only apply to source web`); if (s.navigate && !['spa', 'reload'].includes(s.navigate)) e.push(`shots.capture.shots.${name}.navigate: spa | reload`); if (s.device && !['phone', 'tablet', 'both'].includes(s.device)) e.push(`shots.capture.shots.${name}.device: phone | tablet | both`); if (src === 'web' && s.path === undefined) e.push(`shots.capture.shots.${name}.path: URL path (source web)`); diff --git a/cli/shots/feature-bleed.html b/cli/shots/feature-bleed.html index 848715d..aeebd9c 100644 --- a/cli/shots/feature-bleed.html +++ b/cli/shots/feature-bleed.html @@ -37,6 +37,12 @@ await document.fonts.ready; await Promise.all([...document.images].map(i => i.decode().catch(() => {}))); const overflow = []; const img = c.querySelector('img.frame'); +// shots.feature.rotate: turn the frame like a scene frame's rotate; a quarter turn swaps the box so the image still covers the canvas +const rot = Number(job.rotate) || 0; +if (rot) { + const quarter = Math.abs(rot % 180) === 90, W = quarter ? 500 : 1024, H = quarter ? 1024 : 500; + Object.assign(img.style, { inset: 'auto', left: (512 - W / 2) + 'px', top: (250 - H / 2) + 'px', width: W + 'px', height: H + 'px', transform: `rotate(${rot}deg)` }); +} if (!img.naturalWidth) overflow.push(`feature frame ${job.frame} did not load`); else if (img.naturalWidth < 1024) overflow.push(`feature frame ${img.naturalWidth}x${img.naturalHeight} is upscaled: capture it at least 1024 px wide`); const t = c.querySelector('.name, .logo').getBoundingClientRect(); diff --git a/cli/shots/fonts.ts b/cli/shots/fonts.ts index 2f24be8..09ea404 100644 --- a/cli/shots/fonts.ts +++ b/cli/shots/fonts.ts @@ -6,7 +6,7 @@ // sublines, badge labels; plus the Noto face of a non-Latin script). A character the stack lacks gets an automatic // Noto fallback (logged; `shots check` errors on renders that used it unless shots.fonts.fallback lists it); a // character no fallback covers fails with the language and the characters. -// storebound shots fonts [--json] (re-run after editing the copy or the fonts) +// storebound shots fonts [--lang en,tr] [--json] (--lang limits the report to those languages; the manifest keeps all) (re-run after editing the copy or the fonts) // Out: /.storebound/fonts/*.woff2 + fonts.css + fonts.json (faces and the per-language stacks, read by render). // The Google fonts are SIL Open Font License 1.1. A local font file in shots.fonts (local-fonts.ts) gets the same cmap // check, is read in place (never copied) and recorded with its path, sha256 and license note. @@ -107,7 +107,10 @@ export async function run(ctx: Ctx): Promise { } for (const f of Object.values(local)) if (!f.license) warn('shots', `local font ${f.path} has no license note: add license ('OFL-1.1', 'proprietary', ...) to its shots.fonts entry`); let auto = 0; - for (const [lang, l] of Object.entries(manifest.langs)) for (const f of l.fallbacks) { + const only = ctx.args.list('lang'); + const unknown = only.filter(l => !(l in manifest.langs)); + if (unknown.length) throw new Error(`--lang: no copy or config language ${unknown.join(', ')} (known: ${Object.keys(manifest.langs).join(', ')})`); + for (const [lang, l] of Object.entries(manifest.langs)) for (const f of only.length && !only.includes(lang) ? [] : l.fallbacks) { const ok = manifest.faces[f.face]?.chosen; if (!ok) auto++; const base = f.role === 'title' ? title : body; diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index 5b882ab..f20964c 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -1,4 +1,6 @@ // Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data. +import { readdirSync } from 'node:fs'; +import { join } from 'node:path'; import { specFamily } from './local-fonts'; import { bgIssue } from './palette'; import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config'; @@ -280,8 +282,9 @@ export interface FeatureLayout { left: number; top: number; width: number; heigh * side, centred vertically; `scale` (default 1) multiplies the design width before that clamp. fit 'bleed': the design * width x scale at the old fixed top, cropped by the canvas edge on purpose. */ export function featureLayout(style: 'game' | 'app', dev: { w: number; h: number; bar: number }, - o: { fit?: 'contain' | 'bleed'; scale?: number } = {}): FeatureLayout { - const d = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain'; + o: { fit?: 'contain' | 'bleed'; scale?: number; rotate?: number } = {}): FeatureLayout { + const base = FEATURE_DEVICE[style], C = FEATURE_CANVAS, fit = o.fit ?? 'contain'; + const d = { ...base, angle: base.angle + (o.rotate ?? 0) }; // shots.feature.rotate adds to the template's tilt, like a scene frame's rotate const k = (dev.h + dev.bar) / dev.w; let width = d.width * (o.scale ?? 1); if (fit === 'contain') { @@ -329,3 +332,24 @@ export function framesForRender(scenes: { frames: { src: string }[] }[], sizes: } return [...out].sort(); } + +/** Which language's frames each language loads (shots.perLanguageFrames false, the default): its own when + * / holds any file, else the default language's. `hasFrames` is injected so tests need no disk. */ +export function frameLangs(langs: string[], hasFrames: (lang: string) => boolean, def: string, perLanguage = false): { map: Record; borrowed: string[] } { + const map: Record = {}, borrowed: string[] = []; + for (const l of langs) { + if (perLanguage || l === def || hasFrames(l)) map[l] = l; + else if (hasFrames(def)) { map[l] = def; borrowed.push(l); } + else map[l] = l; // nothing anywhere: the normal missing-frames error names it + } + return { map, borrowed }; +} + +/** true when / holds at least one file (any depth up to 3). */ +export function langHasFrames(dir: string, lang: string): boolean { + const walk = (d: string, depth: number): boolean => { + let ents; try { ents = readdirSync(d, { withFileTypes: true }); } catch { return false; } + return ents.some(e => e.isFile() || (depth < 3 && e.isDirectory() && walk(join(d, e.name), depth + 1))); + }; + return walk(join(dir, lang), 0); +} diff --git a/cli/shots/render.ts b/cli/shots/render.ts index 2ff3ef1..8895ecf 100644 --- a/cli/shots/render.ts +++ b/cli/shots/render.ts @@ -28,7 +28,7 @@ import { done, log, warn } from '../lib/log'; import { closeBrowser, launch } from './browser'; import { defaultConcurrency, pool, progressLine } from './capture-web'; import { shotsStyle } from '../lib/config'; -import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, framesForRender, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib'; +import { type CopyFile, type DeviceBox, type TextBox, SIZES, renderDims, sheetTimeoutMs, featureLayout, copyText, defaultSizes, frameDims, framesForRender, frameLangs, langHasFrames, mergeCopy, palette, renderDir, scriptFont, sheetLayout, validateCopy, variantScenes } from './lib'; import { bgCss, bgTone, type ContrastSample, contrastIssues, parseColour, photoBackground, photoCss, type RGB, worstContrast } from './palette'; import { type WeightSample, weightIssues } from './weights'; import type { Palette } from '../lib/config'; @@ -74,9 +74,11 @@ export async function run(ctx: Ctx): Promise { } const framesDir = resolve(shots.frames), outDir = resolve(shots.out); // every frame the scenes load must exist: a missing one renders a store image with no app or game in it + const fl = frameLangs(langs, l => langHasFrames(framesDir, l), config.play?.defaultLanguage ?? config.languages[0]!.id, shots.perLanguageFrames); + if (fl.borrowed.length) log('shots', `info: no frames for ${fl.borrowed.join(', ')}; using the ${config.play?.defaultLanguage ?? config.languages[0]!.id} frames (shots.perLanguageFrames: true requires them per language)`); const gone = [...jobs.values()].flatMap(job => { const sel = a.list('scenes').length ? job.scenes.filter(s => a.list('scenes').includes(s.id)) : job.scenes; - return framesForRender(sel, sizes, langs, shots); + return framesForRender(sel, sizes, langs.map(l => fl.map[l]!), shots); }).filter((f, i, all) => all.indexOf(f) === i && !existsSync(join(framesDir, f))); if (gone.length) throw new Error(`shots render: ${gone.length} frame file${gone.length > 1 ? 's' : ''} missing under ${relative(root, framesDir) || '.'} (run \`storebound shots capture\` first, or restore the frames folder; it is often git-ignored):\n ${gone.slice(0, 20).join('\n ')}${gone.length > 20 ? `\n ... ${gone.length - 20} more` : ''}`); if (a.has('dry')) { @@ -164,7 +166,7 @@ export async function run(ctx: Ctx): Promise { fonts: { ...fonts, script: st.script, titleStack: st.title, bodyStack: st.body } }; if (url.searchParams.has('feature')) return Response.json({ ...base, frame: shots.feature?.frame ?? scenes[0]!.frames[0]!.src, featureTitle: shots.feature?.title, - device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale }), + device: featureLayout(style, phone, { fit: shots.feature?.fit, scale: shots.feature?.scale, rotate: shots.feature?.rotate }), rotate: shots.feature?.rotate ?? 0, palette: look(palette(shots.feature?.palette ?? scenes[0]!.palette, style), style === 'app' ? 135 : 160, style === 'app' ? 55 : 50, shots.feature?.background) }); const sid = url.searchParams.get('s') ?? ''; const scene = scenes.find(s => s.id === sid)!; @@ -174,7 +176,10 @@ export async function run(ctx: Ctx): Promise { let file = ''; if (p === '/logo' && shots.logo) file = resolve(shots.logo); else if (p.startsWith('/bgimg/')) file = photos[Number(p.slice(7))] ?? ''; - else if (p.startsWith('/frames/')) file = join(framesDir, p.slice(8)); + else if (p.startsWith('/frames/')) { + const [fl0 = '', ...rest] = p.slice(8).split('/'); + file = join(framesDir, [fl.map[fl0] ?? fl0, ...rest].join('/')); + } else if (p.startsWith('/fonts/')) file = fontsRoute(p.slice(7), fontsDir, root, manifest) ?? ''; else if (p.startsWith('/out/')) file = join(outDir, p.slice(5)); else if (/^\/(template|feature)(-app|-bleed)?\.html$/.test(p)) return new Response(SHOTS_PAGES[p.slice(1)]!, { headers: { 'content-type': TYPES.html! } }); diff --git a/cli/shots/selectors.ts b/cli/shots/selectors.ts new file mode 100644 index 0000000..1efb778 --- /dev/null +++ b/cli/shots/selectors.ts @@ -0,0 +1,79 @@ +// 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 }); + } +} diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 1e06369..15a874c 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -1441,3 +1441,16 @@ that fails without the fix (see CONTRIBUTING.md). --platform macos` refuses an unsigned .pkg or one not signed with an installer certificate before any network call (`tests/mas-build.test.ts`). The `com.apple.pkg` file type of the API build upload is taken from Apple's schema and has not been exercised against the live API yet; `--via altool` (`--type macos`) is the fallback. + +## 120. Play feature graphic shows a landscape game sideways; a language without frames fails the whole check + +- **Symptom:** the 1024x500 feature graphic showed a landscape game frame sideways inside the portrait phone (the + feature path ignored `rotate`); `shots check` without `--lang` failed on missing frames of languages that only have + copy; a capture click on a shop row matched on the phone layout and silently missed at tablet / desktop sizes. +- **Do:** set `shots.feature.rotate` (degrees, added to the template tilt; `-90` stands a landscape frame upright in a + phone lying on its side; with layout "bleed" the frame image itself is turned). Frames of a language that has none + come from the default language (info line; `shots.perLanguageFrames: true` requires them per language). In web + capture list `waitSelector` / `click` candidates per layout (`{ phone: [...], tablet: [...], any: [...] }`, data + attributes first, `text=` last); `shots capture --dry` prints them per size, `--dry --probe` opens the pages and + names the MISS with the size and every candidate's match count. +- **Guard:** `tests/shots-gaps.test.ts`, `tests/browser/feature-fit.browser.ts` (rotate), `cli/shots/selectors.ts`. diff --git a/tests/browser/feature-fit.browser.ts b/tests/browser/feature-fit.browser.ts index 82aea79..a546755 100644 --- a/tests/browser/feature-fit.browser.ts +++ b/tests/browser/feature-fit.browser.ts @@ -63,6 +63,8 @@ describe('shots.feature config', () => { expect(errs({ frame: 'a.png' })).toEqual([]); expect(errs({ frame: 'a.png', fit: 'bleed', scale: 1.2 })).toEqual([]); expect(errs({ frame: 'a.png', fit: 'cover' })).toEqual(["shots.feature.fit: 'contain' | 'bleed'"]); + expect(errs({ frame: 'a.png', rotate: -90 })).toEqual([]); + expect(errs({ frame: 'a.png', rotate: 'left' })).toEqual(['shots.feature.rotate: a number of degrees from -360 to 360']); expect(errs({ frame: 'a.png', scale: 3 })).toEqual(['shots.feature.scale: a number from 0.5 to 1.5']); }); }); @@ -106,3 +108,41 @@ describe.skipIf(!browser)('feature graphic templates at 1024 x 500 (DOM layout b } finally { await page.close(); } }, scaled(30_000)); }); + +describe.skipIf(!browser)('shots.feature.rotate', () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const route = (page: any, html: string, name: string, job: unknown) => page.route('http://t.test/**', (r: { request(): { url(): string }; fulfill(o: unknown): Promise }) => { + const u = new URL(r.request().url()); + if (u.pathname === `/${name}`) return r.fulfill({ contentType: 'text/html', body: html }); + if (u.pathname === '/job.json') return r.fulfill({ contentType: 'application/json', body: JSON.stringify(job) }); + if (u.pathname.startsWith('/frames/')) return r.fulfill({ contentType: 'image/png', body: PNG }); + return r.fulfill({ contentType: 'text/css', body: '' }); + }); + const pal = { bg: ['#123456', '#345678', '#567890'], glow: '#ffffff', ink: '#000000', shadow: 'rgba(0,0,0,.4)' }; + test('game template: a -90 phone lies on its side and still fits', async () => { + const page = await browser.newPage({ viewport: { width: 1024, height: 500 } }); + try { + const device = featureLayout('game', DEFAULT_PHONE, { rotate: -90 }); + expect(device.angle).toBe(-83); + await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature.html'), 'utf8'), 'feature.html', + { style: 'game', phone: DEFAULT_PHONE, logo: false, appName: 'Demo', fonts: { title: 'X' }, frame: 'a.png', device, palette: pal }); + await page.goto('http://t.test/feature.html?lang=en'); + await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 }); + const d = (JSON.parse(await page.evaluate('document.body.dataset.devices')) as { x: number; y: number; w: number; h: number }[])[0]!; + expect(d.w).toBeGreaterThan(d.h); // landscape outline + expect(boxOutside(d, 1024, 500)).toEqual([]); + } finally { await page.close(); } + }, scaled(30_000)); + test('bleed template: the frame image is turned and still covers the canvas', async () => { + const page = await browser.newPage({ viewport: { width: 1024, height: 500 } }); + try { + await route(page, readFileSync(join(import.meta.dir, '..', '..', 'cli', 'shots', 'feature-bleed.html'), 'utf8'), 'feature-bleed.html', + { fonts: { title: 'X' }, frame: 'a.png', appName: 'Demo', rotate: -90 }); + await page.goto('http://t.test/feature-bleed.html?lang=en'); + await page.waitForFunction("document.body.dataset.ready === '1'", null, { timeout: 15000 }); + const r = await page.evaluate("(() => { const b = document.querySelector('img.frame').getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height }; })()") as { x: number; y: number; w: number; h: number }; + expect(Math.round(r.w)).toBe(1024); expect(Math.round(r.h)).toBe(500); expect(Math.round(r.x)).toBe(0); expect(Math.round(r.y)).toBe(0); + expect(await page.evaluate("document.querySelector('img.frame').style.transform")).toBe('rotate(-90deg)'); + } finally { await page.close(); } + }, scaled(30_000)); +}); diff --git a/tests/shots-gaps.test.ts b/tests/shots-gaps.test.ts new file mode 100644 index 0000000..3bc8045 --- /dev/null +++ b/tests/shots-gaps.test.ts @@ -0,0 +1,91 @@ +// Real-use gaps in `shots`: feature rotate, check/render frame fallback per language, layout-aware capture selectors. +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DEFAULT_PHONE, featureLayout, frameLangs, langHasFrames } from '../cli/shots/lib'; +import { applySelectors, candidates, missMessage, probe, validateSelector, type SelPage } from '../cli/shots/selectors'; +import { validateCapture } from '../cli/shots/devices'; + +describe('feature rotate', () => { + test('adds to the template tilt and still fits', () => { + const a = featureLayout('game', DEFAULT_PHONE, {}), b = featureLayout('game', DEFAULT_PHONE, { rotate: -90 }); + expect(a.angle).toBe(7); expect(b.angle).toBe(-83); + expect(b.box.w).toBeGreaterThan(b.box.h); + expect(b.box.x).toBeGreaterThanOrEqual(23.99); expect(b.box.x + b.box.w).toBeLessThanOrEqual(1024 - 23.99); + expect(b.box.y).toBeGreaterThanOrEqual(23.99); expect(b.box.y + b.box.h).toBeLessThanOrEqual(500 - 23.99); + }); +}); + +describe('frame languages', () => { + const has = (set: string[]) => (l: string) => set.includes(l); + test('a language without frames borrows the default language', () => { + expect(frameLangs(['en', 'tr', 'de'], has(['en', 'de']), 'en')).toEqual({ map: { en: 'en', tr: 'en', de: 'de' }, borrowed: ['tr'] }); + }); + test('perLanguage requires its own', () => { + expect(frameLangs(['en', 'tr'], has(['en']), 'en', true)).toEqual({ map: { en: 'en', tr: 'tr' }, borrowed: [] }); + }); + test('no frames anywhere: nothing borrowed, the missing error names the language', () => { + expect(frameLangs(['en', 'tr'], has([]), 'en').borrowed).toEqual([]); + }); + test('langHasFrames reads the folder', () => { + const d = mkdtempSync(join(tmpdir(), 'sb-fl-')); + try { + mkdirSync(join(d, 'en', 'phone'), { recursive: true }); writeFileSync(join(d, 'en', 'phone', 'a.png'), 'x'); + mkdirSync(join(d, 'tr'), { recursive: true }); + expect(langHasFrames(d, 'en')).toBe(true); + expect(langHasFrames(d, 'tr')).toBe(false); + expect(langHasFrames(d, 'de')).toBe(false); + } finally { rmSync(d, { recursive: true, force: true }); } + }); +}); + +/** Fake page: a DOM of selector -> count; text= selectors are just keys. */ +function fake(dom: Record, clicks: string[] = []): SelPage { + return { + locator: (s: string) => ({ count: async () => { if (s.startsWith('!')) throw new Error('bad'); return dom[s] ?? 0; }, first: () => ({ click: async () => { clicks.push(s); } }) }), + waitForTimeout: async () => {}, + }; +} + +describe('capture selectors', () => { + test('per-device list first, then shared, no repeats', () => { + const s = { phone: ['[data-shot="row"]'], tablet: ['[data-shot="tile"]', 'text=Shop'], any: ['text=Shop'] }; + expect(candidates(s, 'tablet')).toEqual(['[data-shot="tile"]', 'text=Shop']); + expect(candidates(s, 'phone')).toEqual(['[data-shot="row"]', 'text=Shop']); + expect(candidates('a', 'phone')).toEqual(['a']); + expect(candidates(undefined, 'phone')).toEqual([]); + }); + test('validation', () => { + expect(validateSelector('x', 'a')).toEqual([]); + expect(validateSelector('x', { tablet: ['a'] })).toEqual([]); + expect(validateSelector('x', { desktop: 'a' })[0]).toContain('unknown key desktop'); + expect(validateSelector('x', '')).not.toEqual([]); + expect(validateSelector('x', {})).not.toEqual([]); + }); + test('the click lands on the first candidate that matches', async () => { + const clicks: string[] = []; + await applySelectors(fake({ 'text=Shop': 1 }, clicks), { waitSelector: ['[data-shot="row"]', 'text=Shop'], click: { tablet: ['[data-shot="tile"]', 'text=Shop'] } }, { device: 'tablet', w: 1180, h: 820 }, 50); + expect(clicks).toEqual(['text=Shop']); + }); + test('several clicks in order', async () => { + const clicks: string[] = []; + await applySelectors(fake({ a: 1, b: 1 }, clicks), { click: [{ any: 'a' }, 'b'] }, { device: 'phone', w: 440, h: 902 }, 50); + expect(clicks).toEqual(['a', 'b']); + }); + test('a miss names the selector, the size and every match count', async () => { + const err = await applySelectors(fake({ '[data-shot="row"]': 3 }), { click: { tablet: ['[data-shot="tile"]', 'text=Shop'] } }, { device: 'tablet', w: 1180, h: 820 }, 30).then(() => '', e => (e as Error).message); + expect(err).toContain('click selector missed on tablet 1180x820'); + expect(err).toContain('[data-shot="tile"] matched 0'); + expect(err).toContain('text=Shop matched 0'); + expect(missMessage('click', { device: 'phone', w: 1, h: 2 }, [{ selector: '!x', matches: -1 }])).toContain('is not a valid selector'); + }); + test('probe counts every candidate', async () => { + expect(await probe(fake({ a: 2 }), ['a', 'b'])).toEqual([{ selector: 'a', matches: 2 }, { selector: 'b', matches: 0 }]); + }); + test('validateCapture accepts and rejects', () => { + const cap = (shot: object) => ({ source: 'web', baseUrl: 'http://127.0.0.1:1', shots: { shop: { path: '/', ...shot } } }) as never; + expect(validateCapture(cap({ waitSelector: { tablet: 'a' }, click: ['a', 'b'] }))).toEqual([]); + expect(validateCapture(cap({ click: { desktop: 'a' } }))[0]).toContain('unknown key desktop'); + }); +});