From d0f568ebc2fc0ab9ee4b7b9beec33cc23d393b4c Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 3 Nov 2026 23:14:34 +0700 Subject: [PATCH] validate: removed-from-sale IAPs are no blockers; shots capture --device landscape (Mac / Chromebook frames from a web build); playwright lookup says why each place failed --- CHANGELOG.md | 4 ++ cli/data/flags.json | 2 +- cli/lib/config.ts | 11 +++- cli/lib/validate-rules.ts | 8 ++- cli/shots/browser.ts | 14 +++-- cli/shots/capture-web.ts | 32 +++++----- cli/shots/capture.ts | 44 ++++++++----- cli/shots/devices.ts | 19 ++++-- cli/shots/lib.ts | 15 +++++ cli/shots/selectors.ts | 10 +-- skills/store-screenshots/SKILL.md | 9 ++- tests/capture-landscape.test.ts | 101 ++++++++++++++++++++++++++++++ tests/wave1.test.ts | 9 +++ 13 files changed, 230 insertions(+), 48 deletions(-) create mode 100644 tests/capture-landscape.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5404098..e2f056b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,10 @@ ## Unreleased +- `validate`: in-app purchases removed from sale (DEVELOPER_REMOVED_FROM_SALE, REMOVED_FROM_SALE) are no blockers; a warning only when the config still sells one. +- `shots capture --device landscape`: Mac App Store / Chromebook frames from a web build (desktop viewport at `shots.landscape.size`, default 1440x900 @2x) into `/landscape/`; on by default when the project renders a landscape size. The simulator and emulator sources skip it with a warning. +- `shots render` / `capture`: "playwright not found" now lists each place looked at and why it failed (an import error was reported as not found). + ## 0.8.64 ยท 2026-11-02 - `validate` / `release plan`: a macOS version is checked against Mac screenshots (APP_DESKTOP, with `apple.mac`), not the iPhone / iPad sets; with no editable version, iOS is picked over macOS. diff --git a/cli/data/flags.json b/cli/data/flags.json index 59e378c..d8952e6 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -1810,7 +1810,7 @@ "storebound settings bypass on | off | status owner opt-in: the agent also presses the owner-only buttons" ], "shots capture": [ - "storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet] [--source web|ios-sim|android-emu|folder]" + "storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet|landscape] [--source web|ios-sim|android-emu|folder]" ], "shots check": [ "storebound shots check [--lang en,tr] [--variants] [--json]" diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 446ee91..5e48f4c 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -202,7 +202,9 @@ export interface ShotsConfig { /** Tablet frames: default 1032 x 1352 @2x (13" iPad minus its 24 px bar) with apple.ipad, else 800 x 1240 @2x. */ tablet?: { w: number; h: number; dpr?: number; bar?: number }; /** Landscape layout (Mac, Chromebook, and the Play tablet slots with playTablets): copy on one side, the frame on the - * other. Frames: //landscape/.png at `size` CSS px (default 1440 x 900 @2x). frame: 'window' (a + * other. Frames: //landscape/.png at `size` CSS px (default 1440 x 900 @2x = 2880 x 1800), made by + * `shots capture --device landscape` (web source: a desktop viewport, no touch, no status bar crop; ios-sim / android-emu + * have no landscape device, use a web build or a folder). frame: 'window' (a * Mac window around the game, the default for mac) or 'none' (the bare frame, the default for Play sizes). side: * where the copy goes (default 'left'). playTablets: render tab7 / tab10 landscape (1920 x 1080 / 2560 x 1440). */ landscape?: { frame?: 'window' | 'none'; side?: 'left' | 'right'; playTablets?: boolean; size?: { w: number; h: number; dpr?: number } }; @@ -217,7 +219,7 @@ 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 type ShotSelector = string | string[] | { phone?: string | string[]; tablet?: string | string[]; landscape?: 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. */ @@ -235,7 +237,10 @@ export interface ShotSpec { delayMs?: number; /** Web: JS run in the page before the screenshot. */ eval?: string[]; - device?: 'phone' | 'tablet' | 'both'; + /** Which frames this shot makes: phone (default), tablet, both (phone + tablet), landscape (/landscape/.png at + * shots.landscape.size, web source only). When the project renders a landscape size (apple.mac, play.chromebook, + * shots.landscape.playTablets), shots without a device and 'both' shots also make a landscape frame. */ + device?: 'phone' | 'tablet' | 'both' | 'landscape'; /** Web, capture.session "per-language": how this shot is reached in the already booted page. "spa" (default): * history.pushState to its path + a popstate event, then waitFor / eval (switch scenes from those); "reload": * page.goto in the same page. A failing waitFor there falls back to a fresh page. */ diff --git a/cli/lib/validate-rules.ts b/cli/lib/validate-rules.ts index 43d8906..c1e5491 100644 --- a/cli/lib/validate-rules.ts +++ b/cli/lib/validate-rules.ts @@ -163,6 +163,7 @@ export interface PlaySnapshot { const EDITABLE = ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY']; const IN_FLIGHT = ['WAITING_FOR_REVIEW', 'IN_REVIEW', 'PENDING_DEVELOPER_RELEASE', 'PROCESSING_FOR_DISTRIBUTION', 'READY_FOR_DISTRIBUTION', 'READY_FOR_SALE', 'ACCEPTED']; const IAP_OK = ['READY_TO_SUBMIT', 'WAITING_FOR_REVIEW', 'IN_REVIEW', 'APPROVED']; +const IAP_OFF_SALE = ['DEVELOPER_REMOVED_FROM_SALE', 'REMOVED_FROM_SALE']; /** Age rating questions whose null means "not answered" (kidsAgeBand null = not a kids app). */ // Left null on apps App Review accepted: gracRatingClassificationNumber (a Korean GRAC rating number, for apps GRAC // rated), socialMediaAgeRestricted (asked only when socialMedia is true). socialMedia itself is a newer question that @@ -281,12 +282,17 @@ export function appleRules(s: AppleSnapshot): Finding[] { const iaps = new Map(s.iaps.map(i => [i.productId, i] as const)); for (const id of s.expectedProducts) if (!iaps.has(id)) add('blocker', 'iap', `${id}: not on App Store Connect`, 'storebound apple iap'); + const expected = new Set(s.expectedProducts); for (const i of s.iaps) { + // a product the developer took off sale (old consumables, a retired unlock) does not block a submission; it matters + // only when the config still sells it + if (IAP_OFF_SALE.includes(i.state)) { if (expected.has(i.productId)) add('warn', 'iap', `${i.productId}: ${i.state}, but the config still sells it`, 'put it back on sale in App Store Connect, or drop it from products'); continue; } if (!IAP_OK.includes(i.state)) add('blocker', 'iap', `${i.productId}: ${i.state}`, i.state === 'MISSING_METADATA' ? 'storebound apple iap + apple iap-review' : 'see the review-rejections skill'); else if (!i.reviewScreenshot && i.state !== 'APPROVED') add('blocker', 'iap', `${i.productId}: no review screenshot`, 'storebound apple iap-review'); } for (const g of s.firstProductGaps ?? []) add(g.level === 'error' ? 'blocker' : 'warn', 'iap-first', g.msg, g.fix); - if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${s.iaps.length} in-app purchases ready`); + const onSale = s.iaps.filter(i => !IAP_OFF_SALE.includes(i.state)).length, offSale = s.iaps.length - onSale; + if (s.iaps.length && !f.some(x => x.code === 'iap')) add('ok', 'iap', `${onSale} in-app purchases ready${offSale ? ` (${offSale} removed from sale, not checked)` : ''}`); f.push(...appleSubscriptionRules(s)); for (const m of s.capabilityMismatches ?? []) add('warn', 'capabilities', m.msg, m.fix); diff --git a/cli/shots/browser.ts b/cli/shots/browser.ts index e009c05..bd33563 100644 --- a/cli/shots/browser.ts +++ b/cli/shots/browser.ts @@ -30,24 +30,30 @@ import * as rt from '../lib/rt'; class PlaywrightShapeError extends Error {} async function chromium(root: string): Promise { + // why each place failed, for the error: a swallowed import error read as "not found" (a Mac run had it installed) + const tried: string[] = []; for (const base of [root, rt.packageRoot()]) { + let p = ''; try { - const p = rt.resolveFrom('playwright', base); + p = rt.resolveFrom('playwright', base); // only an installed copy (node_modules): outside a project Bun auto-installs the newest playwright into its global // cache (~/.bun/install/cache), whose browser build was never downloaded ("headless shell: not installed" on // Linux CI, where no Google Chrome hides it); storebound's own copy is next - if (!isInstalledPackage(p)) continue; + if (!isInstalledPackage(p)) { tried.push(`${p}: not an installed copy (Bun's global cache)`); continue; } // playwright's entry is CommonJS: depending on the runtime and version the ESM import puts chromium on the // namespace or only under default (a project's playwright 1.57 under a global Bun install gave undefined) const m = await rt.importPath(p); const c = m?.chromium ?? m?.default?.chromium; if (!c || typeof c.launch !== 'function') throw new PlaywrightShapeError(`playwright at ${p} has no chromium.launch (exports: ${Object.keys(m ?? {}).slice(0, 8).join(', ') || 'none'}${m?.default ? `; default: ${Object.keys(m.default).slice(0, 8).join(', ')}` : ''})`); return c; - } catch (e) { if (e instanceof PlaywrightShapeError) throw e; /* next */ } + } catch (e) { + if (e instanceof PlaywrightShapeError) throw e; + tried.push(p ? `${p}: ${(e as Error).message.split('\n')[0]}` : `not resolvable from ${base}`); + } } // only reached in the compiled sidecar: the bundled playwright-core (not a runtime dependency of the npm package) if (compiled()) return (await import('playwright-core')).chromium; - throw new Error('playwright not found: in your project run `bun add -d playwright` (and `bunx playwright install chromium` if Google Chrome is not installed)'); + throw new Error(`playwright not found: in your project run \`bun add -d playwright\` (and \`bunx playwright install chromium\` if Google Chrome is not installed)\n ${tried.join('\n ')}`); } /** A resolved module path inside a node_modules folder (not one of Bun's auto-installs from its global cache). */ diff --git a/cli/shots/capture-web.ts b/cli/shots/capture-web.ts index 0229eeb..a2e4ff5 100644 --- a/cli/shots/capture-web.ts +++ b/cli/shots/capture-web.ts @@ -23,11 +23,15 @@ import { join } from 'node:path'; 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 { type CaptureDevice, shotsFor } from './devices'; import { applySelectors, candidates, probe } from './selectors'; -import type { FrameDims } from './lib'; +import { DEFAULT_LANDSCAPE, type FrameDims } from './lib'; -type Device = 'phone' | 'tablet'; +/** Viewport per device class; landscape defaults to shots.landscape.size's default when a caller passes only phone / tablet. */ +export type CaptureDims = Record<'phone' | 'tablet', FrameDims> & { landscape?: FrameDims }; +const dimsOf = (dims: CaptureDims, device: Device): FrameDims => dims[device] ?? DEFAULT_LANDSCAPE; + +type Device = CaptureDevice; export const STEPS = ['goto', 'waitFor', 'eval', 'delay', 'screenshot'] as const; export type Step = typeof STEPS[number]; export type Timing = Record; @@ -100,14 +104,14 @@ export async function hashServed(baseUrl: string): Promise { } /** Every web frame to capture, in a fixed order (lang, device, shot), with its URL and its input hash. */ -export function planWeb(cap: ShotsCapture, o: { langs: string[]; devices: Device[]; only: string[]; dims: Record; framesDir: string; - build: string; copyText?: (lang: string) => string; fonts?: string }): WebJob[] { +export function planWeb(cap: ShotsCapture, o: { langs: string[]; devices: Device[]; only: string[]; dims: CaptureDims; framesDir: string; + build: string; copyText?: (lang: string) => string; fonts?: string; allLandscape?: boolean }): WebJob[] { const jobs: WebJob[] = []; - for (const lang of o.langs) for (const device of o.devices) for (const [name, spec] of shotsFor(cap, device, o.only)) { + for (const lang of o.langs) for (const device of o.devices) for (const [name, spec] of shotsFor(cap, device, o.only, o.allLandscape)) { const q = (cap.query ?? '').replaceAll('{lang}', lang); const path = (spec.path ?? '/').replaceAll('{lang}', lang) + (q ? ((spec.path ?? '/').includes('?') ? '&' : '?') + q : ''); const url = (cap.baseUrl ?? '').replace(/\/$/, '') + path; - const hash = sha(JSON.stringify({ spec, url, dims: o.dims[device], session: cap.session ?? 'per-shot', build: o.build, copy: o.copyText?.(lang) ?? '', fonts: o.fonts ?? '' })); + const hash = sha(JSON.stringify({ spec, url, dims: dimsOf(o.dims, device), session: cap.session ?? 'per-shot', build: o.build, copy: o.copyText?.(lang) ?? '', fonts: o.fonts ?? '' })); const frame = `${lang}/${device}/${name}.png`; jobs.push({ lang, device, name, spec, url, path, out: join(o.framesDir, frame), frame, hash }); } @@ -137,7 +141,7 @@ export interface CaptureReport { /* eslint-disable @typescript-eslint/no-explicit-any */ /** Captures the jobs in a browser: per-shot contexts, or one page per language + device in per-language sessions. */ -export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: Record; auth?: AuthPlan }): Promise { +export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: CaptureDims; auth?: AuthPlan }): Promise { const auth = o.auth; const mask = (t: string): string => (auth ? maskSecrets(t, auth.secrets) : t); const expired = new Set(); @@ -147,8 +151,8 @@ export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[] let finished = 0; const total = jobs.length; const newPage = async (j: WebJob): Promise<{ c: any; page: any; errs: string[] }> => { - const d = o.dims[j.device]; - const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: true, isMobile: true, serviceWorkers: 'block', locale: j.lang, ...(auth?.contextOptions ?? {}) }); + const d = dimsOf(o.dims, j.device); + const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: d.dpr, hasTouch: j.device !== 'landscape', isMobile: j.device !== 'landscape', serviceWorkers: 'block', locale: j.lang, ...(auth?.contextOptions ?? {}) }); // landscape = a desktop window: no touch, no mobile emulation // sign in once per context, cookies kept in it (request auth); a failed sign-in stops the whole capture if (auth) try { await auth.prepare(c); } catch (e) { await c.close(); throw Object.assign(new Error(mask((e as Error).message)), { authFailed: true }); } const page = await c.newPage(); @@ -167,7 +171,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 + await applySelectors(s.page, j.spec, { device: j.device, w: dimsOf(o.dims, j.device).w, h: dimsOf(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); @@ -251,14 +255,14 @@ export function writeCaptureState(root: string, report: CaptureReport, cache: Ca /* 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 }> { +export async function probeSelectors(browser: any, jobs: WebJob[], o: { dims: CaptureDims; 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 ?? {}) }); + const d = dimsOf(o.dims, j.device); + const c = await browser.newContext({ viewport: { width: d.w, height: d.h }, deviceScaleFactor: 1, hasTouch: j.device !== 'landscape', isMobile: j.device !== 'landscape', serviceWorkers: 'block', locale: j.lang, ...(o.auth?.contextOptions ?? {}) }); try { if (o.auth) await o.auth.prepare(c); const page = await c.newPage(); diff --git a/cli/shots/capture.ts b/cli/shots/capture.ts index c5b6d24..b0cedc4 100644 --- a/cli/shots/capture.ts +++ b/cli/shots/capture.ts @@ -1,9 +1,15 @@ // 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] +// storebound shots capture [--lang en,tr] [--only hero,shop] [--device phone|tablet|landscape] [--source web|ios-sim|android-emu|folder] // [--dry [--probe]] [--keep-booted] [--concurrency N] [--only-missing | --force] // Frames go to ///.png: the screen BELOW the status bar (the template draws one). +// Devices: phone, tablet and landscape (/landscape/.png, web source only: Mac App Store and Chromebook +// screenshots; a desktop window, no status bar crop). The default device list adds landscape when the project renders a +// landscape size (apple.mac, play.chromebook, shots.landscape.playTablets); then shots with no device (and 'both') are +// captured for landscape too, since render needs a landscape frame per scene. A shot says device: 'landscape' to be +// landscape only, 'phone' / 'tablet' to stay out of it. ios-sim / android-emu skip landscape with a warning. // shots.capture.source (default web): -// web headless Chromium (playwright), mobile viewport + touch at phone / tablet size. Each shot: +// web headless Chromium (playwright), mobile viewport + touch at phone / tablet size, or a desktop viewport (no touch) +// at shots.landscape.size (default 1440 x 900 @2x) for landscape. Each shot: // { path, waitFor?: JS expression that turns true, eval?: [JS], delayMs?, device? }; URL = baseUrl + path + // query, "{lang}" replaced. Parallel contexts, per-language page reuse, the input-hash cache and the // per-frame timing (goto / waitFor / eval / delay / screenshot): capture-web.ts. Signed-in apps: @@ -36,15 +42,16 @@ import { done, log, step, warn } from '../lib/log'; import { closeBrowser, launch } from './browser'; import { checkFrameFolder } from './check'; import { adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, adbInstallArgs, adbScreencapArgs, adbStartArgs, adbStateArgs, adbTarget, - cropArgs, cropTopFor, fill, pickSimulator, shotsFor, simBootArgs, simBootStatusArgs, simInstallArgs, simLaunchArgs, simOpenUrlArgs, + cropArgs, cropTopFor, fill, CAPTURE_DEVICES, type CaptureDevice, 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 { type CopyFile, copyText, type FrameDims, frameDims, landscapeDims, rendersLandscape } from './lib'; 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'; -type Device = 'phone' | 'tablet'; +type Device = CaptureDevice; +type PhoneTablet = 'phone' | 'tablet'; export function sh(cmd: string, args: string[], o: { allowFail?: boolean; timeout?: number } = {}): { out: Buffer; err: string; code: number } { const r = spawnSync(cmd, args, { timeout: o.timeout ?? 120_000, maxBuffer: 256 * 1024 * 1024 }); @@ -75,7 +82,7 @@ export function localeOf(config: ShipConfig, lang: string, source: Source): stri } /** Crop the status bar off a full-screen capture and store it as a 24-bit RGB frame. */ -function storeFrame(tmp: string, out: string, cropTop: ShotsCapture['cropTop'], device: Device, dims: FrameDims): void { +function storeFrame(tmp: string, out: string, cropTop: ShotsCapture['cropTop'], device: PhoneTablet, dims: FrameDims): void { const { w, h } = imageInfo(readFileSync(tmp)); if (!h) throw new Error('screenshot is not a PNG'); const top = cropTopFor(cropTop, device, h, dims); @@ -98,13 +105,20 @@ export async function run(ctx: Ctx): Promise { if (problems.length) throw new Error(`shots.capture:\n ${problems.join('\n ')}`); const langs = a.list('lang').length ? a.list('lang') : config.languages.map(l => l.id); const only = a.list('only'); - const devices: Device[] = a.get('device') ? [a.get('device') as Device] : ['phone', 'tablet']; - if (devices.some(d => d !== 'phone' && d !== 'tablet')) throw new Error('--device: phone | tablet'); + if (a.get('device') && !CAPTURE_DEVICES.includes(a.get('device') as Device)) throw new Error(`--device: ${CAPTURE_DEVICES.join(' | ')}`); + const landscapeOn = rendersLandscape(config); + let devices: Device[] = a.get('device') ? [a.get('device') as Device] : ['phone', 'tablet', ...(landscapeOn ? ['landscape' as const] : [])]; + const allLandscape = landscapeOn || a.get('device') === 'landscape'; // shots with no device / 'both' also make a landscape frame + if (source !== 'web' && source !== 'folder' && devices.includes('landscape')) { + warn('shots', `landscape frames (Mac, Chromebook) are not supported from ${source}: skipped; capture them from a web build (--source web) or put them in //landscape/ (--source folder)`); + devices = devices.filter(d => d !== 'landscape'); + } + const mobile = devices.filter((d): d is PhoneTablet => d !== 'landscape'); const framesDir = resolve(shots.frames); - const dims = frameDims(config); + const dims = { ...frameDims(config), landscape: landscapeDims(config) }; const tmpDir = join(root, '.storebound', 'tmp'); const target = (lang: string, device: Device, name: string) => { const d = join(framesDir, lang, device); if (!dry) mkdirSync(d, { recursive: true }); return join(d, `${name}.png`); }; - const total = langs.length * devices.reduce((t, d) => t + shotsFor(cap, d, only).length, 0); + const total = langs.length * devices.reduce((t, d) => t + shotsFor(cap, d, only, allLandscape).length, 0); let failed = 0, n = 0, i = 0; const progress = (what: string) => log('shots', step(++i, total, what)); /** Runs a simctl / adb step, or prints it with --dry. */ @@ -125,7 +139,7 @@ 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)) { + for (const lang of langs) for (const device of devices) for (const [name, s] of shotsFor(cap, device, only, allLandscape)) { 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(' | ')}`); @@ -133,7 +147,7 @@ export async function run(ctx: Ctx): Promise { 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 jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build: '', fonts: '', allLandscape }); 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); } @@ -152,7 +166,7 @@ export async function run(ctx: Ctx): Promise { let copy: CopyFile = {}; try { copy = JSON.parse(readFileSync(resolve(shots.copy), 'utf8')) as CopyFile; } catch { /* no copy yet */ } const fontsCss = join(root, '.storebound', 'fonts', 'fonts.css'); - const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build, copyText: l => copyText(l, copy[l]), + const jobs = planWeb(cap, { langs, devices, only, dims, framesDir, build, allLandscape, copyText: l => copyText(l, copy[l]), fonts: existsSync(fontsCss) ? readFileSync(fontsCss, 'utf8') : '' }); const cache = readCache(root); const { run: todo, skipped } = selectJobs(jobs, cache, a.has('force') ? 'force' : a.has('only-missing') ? 'only-missing' : 'changed'); @@ -174,7 +188,7 @@ export async function run(ctx: Ctx): Promise { const ios = need(cap.ios, 'shots.capture.ios'); if (!dry) { requireTool('xcrun'); requireTool('ffmpeg'); } const list = dry && !rt.which('xcrun') ? '{"devices":{}}' : sh('xcrun', ['simctl', 'list', 'devices', '-j']).out.toString(); - for (const device of devices) { + for (const device of mobile) { const want = simTarget(ios, device); const todo = shotsFor(cap, device, only); if (!todo.length) continue; @@ -222,7 +236,7 @@ export async function run(ctx: Ctx): Promise { const and = need(cap.android, 'shots.capture.android'); const adb = adbPath(); if (!dry) { requireTool(adb, 'adb'); requireTool('ffmpeg'); } - for (const device of devices) { + for (const device of mobile) { const todo = shotsFor(cap, device, only); if (!todo.length) continue; const serial = adbTarget(and, device); diff --git a/cli/shots/devices.ts b/cli/shots/devices.ts index 1fa9f20..3691f22 100644 --- a/cli/shots/devices.ts +++ b/cli/shots/devices.ts @@ -43,7 +43,7 @@ export function validateCapture(c: ShotsCapture | undefined): string[] { 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 (s.device && !['phone', 'tablet', 'both', 'landscape'].includes(s.device)) e.push(`shots.capture.shots.${name}.device: phone | tablet | both | landscape`); if (src === 'web' && s.path === undefined) e.push(`shots.capture.shots.${name}.path: URL path (source web)`); if ((src === 'web' || src === 'folder') && (s.args || s.extras)) e.push(`shots.capture.shots.${name}: args / extras only apply to ios-sim / android-emu`); for (const [k, v] of Object.entries(s.extras ?? {})) { @@ -96,9 +96,20 @@ export function adbTarget(and: NonNullable, device: 'ph return device === 'tablet' ? and.serials?.tablet : and.serials?.phone ?? and.serial; } -/** Which shots run on which device class. */ -export function shotsFor(c: ShotsCapture, device: 'phone' | 'tablet', only: string[] = []): [string, ShotSpec][] { - return Object.entries(c.shots ?? {}).filter(([name, s]) => (!only.length || only.includes(name)) && ((s.device ?? 'phone') === 'both' || (s.device ?? 'phone') === device)); +/** Capture device classes: phone / tablet frames (status bar cut) and desktop landscape frames (Mac, Chromebook). */ +export type CaptureDevice = 'phone' | 'tablet' | 'landscape'; +export const CAPTURE_DEVICES: readonly CaptureDevice[] = ['phone', 'tablet', 'landscape']; + +/** Which shots run on which device class. phone / tablet: device unset = phone, 'both' = phone + tablet (unchanged). + * landscape: the shots with device 'landscape'; with `allLandscape` (the project renders a landscape size, or + * --device landscape was asked for) also every shot with no device or 'both', because render needs a landscape frame + * for each scene. A shot that says 'phone' or 'tablet' explicitly stays off the landscape list. */ +export function shotsFor(c: ShotsCapture, device: CaptureDevice, only: string[] = [], allLandscape = false): [string, ShotSpec][] { + return Object.entries(c.shots ?? {}).filter(([name, s]) => { + if (only.length && !only.includes(name)) return false; + if (device === 'landscape') return s.device === 'landscape' || (allLandscape && (s.device === undefined || s.device === 'both')); + return s.device !== 'landscape' && ((s.device ?? 'phone') === 'both' || (s.device ?? 'phone') === device); + }); } // ---- iOS Simulator (xcrun simctl) ---- diff --git a/cli/shots/lib.ts b/cli/shots/lib.ts index f20964c..3087424 100644 --- a/cli/shots/lib.ts +++ b/cli/shots/lib.ts @@ -175,6 +175,15 @@ export function frameDims(config: Pick): Record<' return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } }; } +/** Landscape capture viewport (shots.landscape.size): a desktop window, 1440 x 900 CSS px @2x = 2880 x 1800 (the Mac size). No status bar. */ +export const DEFAULT_LANDSCAPE: FrameDims = { w: 1440, h: 900, dpr: 2, bar: 0 }; + +/** Viewport `shots capture` uses for landscape frames (/landscape/.png): shots.landscape.size over the default. */ +export function landscapeDims(config: Pick): FrameDims { + const s = config.shots?.landscape?.size; + return { ...DEFAULT_LANDSCAPE, ...(s ? { w: s.w, h: s.h, ...(s.dpr ? { dpr: s.dpr } : {}) } : {}), bar: 0 }; +} + /** Play tablet size keys (7" and 10"): optional on Play, switched by play.tablets. */ export const PLAY_TABLET_SIZES: readonly string[] = ['tab7', 'tab10']; @@ -193,6 +202,12 @@ export function defaultSizes(config: Pick, tablets : k.startsWith('ios') ? apple : PLAY_TABLET_SIZES.includes(k) ? play && tabs : play)); } +/** True when `shots render` draws a landscape size for this project (apple.mac, play.chromebook, shots.landscape.playTablets): + * it then needs /landscape/.png frames, which `shots capture` makes with the landscape device. Same rules as render. */ +export function rendersLandscape(config: Pick): boolean { + return defaultSizes(config).some(s => renderDims(s, config.shots).landscape); +} + /** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */ export function variantScenes(shots: ShotsConfig, variant?: string): ShotScene[] { if (!variant) return shots.scenes; diff --git a/cli/shots/selectors.ts b/cli/shots/selectors.ts index 1efb778..7c84b60 100644 --- a/cli/shots/selectors.ts +++ b/cli/shots/selectors.ts @@ -4,7 +4,7 @@ // 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'; +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[] { @@ -19,12 +19,12 @@ export function validateSelector(where: string, sel: unknown): string[] { 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`]; + const o = sel as Record, 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, any }`]; + return [`${where}: a selector, a list of selectors, or { phone, tablet, landscape, any }`]; } /** Minimal Playwright surface used here, so tests can fake a page. */ diff --git a/skills/store-screenshots/SKILL.md b/skills/store-screenshots/SKILL.md index c2bccb1..ad3d9f7 100644 --- a/skills/store-screenshots/SKILL.md +++ b/skills/store-screenshots/SKILL.md @@ -83,7 +83,12 @@ the enum has them). No Duo app preview sizes are published. Google Play has no f alpha) and requires the set, and `apple screenshots --platform macos` uploads it as `APP_DESKTOP` to the macOS version (versions are per platform, pitfall 79; the iOS run uploads the iOS sets only and says where the Mac set goes). `validate` prints an info note when the app has a macOS version but no Mac screenshots. Frames: landscape -captures in `//landscape/.png`, `shots.landscape.size` CSS px (default 1440 x 900 @2x). +captures in `//landscape/.png`, `shots.landscape.size` CSS px (default 1440 x 900 @2x). Make them with +`storebound shots capture --device landscape` from a web build (a desktop viewport, no touch, no status bar crop); with +`apple.mac`, `play.chromebook` or `shots.landscape.playTablets` the default `capture` run adds the landscape device and +captures every shot with no `device` (and `both`) for it too, because render needs a landscape frame per scene; a shot +with `device: 'landscape'` is landscape only, `'phone'` / `'tablet'` stays out. `ios-sim` / `android-emu` have no +landscape device (skipped with a warning): use a web build or the folder source. **Landscape layout (Mac, Chromebook, Play tablets).** Copy in a column on one side (`shots.landscape.side`, default left), the scene's first frame on the other. `shots.landscape.frame`: `'window'` draws a Mac window (title bar with @@ -119,6 +124,8 @@ the icon, feature graphic and phone screenshots and prints `tablet screenshots: `///.png`, local, not committed. - `web` (default): `baseUrl`, `query` with `{lang}`, shots: `path` / `waitFor` / `eval` / `delayMs` / `device`. Phone 440 x 902 @3x and tablet 800 x 1240 @2x by default; the template draws the status bar above. + Landscape (Mac / Chromebook): `--device landscape` or `device: 'landscape'` per shot, a desktop viewport at + `shots.landscape.size` (default 1440 x 900 @2x), `waitSelector` / `click` take a `landscape` list too. Speed (many languages): frames run in parallel browser contexts (`--concurrency N`, `capture.concurrency`; default min(3, cores / 4) since game boots are GPU-heavy). `capture.session: 'per-language'` boots the app once per language and device and switches shots in that page (per shot `navigate: 'spa'` = pushState + popstate to its diff --git a/tests/capture-landscape.test.ts b/tests/capture-landscape.test.ts new file mode 100644 index 0000000..6768a76 --- /dev/null +++ b/tests/capture-landscape.test.ts @@ -0,0 +1,101 @@ +// `shots capture --device landscape`: desktop frames for Mac App Store / Chromebook screenshots from a web build. +// Dry runs and pure planning only: no browser, no simulator. +import { afterEach, describe, expect, test } from 'bun:test'; +import { rmSync } from 'node:fs'; +import type { ShipConfig } from '../cli/lib/config'; +import { planWeb } from '../cli/shots/capture-web'; +import { run as captureRun } from '../cli/shots/capture'; +import { shotsFor, validateCapture } from '../cli/shots/devices'; +import { frameDims, landscapeDims, rendersLandscape } from '../cli/shots/lib'; +import { BASE, capture, project, restore } from './helpers/store-mock'; + +afterEach(restore); + +const cap = { source: 'web', baseUrl: 'http://127.0.0.1:5173', shots: { + hero: { path: '/?scene=hero' }, wide: { path: '/?scene=wide', device: 'landscape' }, pad: { path: '/pad', device: 'tablet' }, both: { path: '/both', device: 'both' }, ph: { path: '/ph', device: 'phone' }, +} } as const; +const cfg = (over: Record = {}, shots: Record = {}): ShipConfig => + ({ ...BASE, ...over, shots: { frames: 'frames', copy: 'copy.json', scenes: [], capture: cap, ...shots } }) as unknown as ShipConfig; +const names = (c: Parameters[0], d: 'phone' | 'tablet' | 'landscape', all = false) => shotsFor(c, d, [], all).map(([n]) => n); + +describe('landscape device selection', () => { + test('phone / tablet unchanged; landscape only for explicit shots unless the project renders landscape', () => { + expect(names(cap, 'phone')).toEqual(['hero', 'both', 'ph']); + expect(names(cap, 'tablet')).toEqual(['pad', 'both']); + expect(names(cap, 'landscape')).toEqual(['wide']); + expect(names(cap, 'landscape', true)).toEqual(['hero', 'wide', 'both']); // not the explicit phone / tablet ones + }); + test('validateCapture accepts landscape and names it in the error', () => { + expect(validateCapture({ ...cap, shots: { a: { path: '/', device: 'landscape' } } })).toEqual([]); + expect(validateCapture({ ...cap, shots: { a: { path: '/', device: 'desktop' as 'both' } } }).join()).toContain('phone | tablet | both | landscape'); + }); + test('rendersLandscape follows the render rules (mac, chromebook, playTablets)', () => { + expect(rendersLandscape(cfg())).toBe(false); + expect(rendersLandscape(cfg({ apple: { ...BASE.apple!, mac: true } }))).toBe(true); + expect(rendersLandscape(cfg({ apple: undefined, play: { ...BASE.play!, chromebook: true } }))).toBe(true); + expect(rendersLandscape(cfg({}, { landscape: { playTablets: true } }))).toBe(true); + }); + test('landscapeDims: 1440 x 900 @2x default, shots.landscape.size overrides', () => { + expect(landscapeDims(cfg())).toEqual({ w: 1440, h: 900, dpr: 2, bar: 0 }); + expect(landscapeDims(cfg({}, { landscape: { size: { w: 1280, h: 800 } } }))).toEqual({ w: 1280, h: 800, dpr: 2, bar: 0 }); + expect(landscapeDims(cfg({}, { landscape: { size: { w: 1920, h: 1080, dpr: 1 } } }))).toEqual({ w: 1920, h: 1080, dpr: 1, bar: 0 }); + }); +}); + +describe('planWeb with landscape', () => { + test('jobs target /landscape/.png and the hash tracks the landscape viewport', () => { + const c = cfg(); + const base = { langs: ['en'], devices: ['landscape' as const], only: [], framesDir: '/f', build: 'b', allLandscape: true }; + const a = planWeb(cap, { ...base, dims: { ...frameDims(c), landscape: landscapeDims(c) } }); + expect(a.map(j => j.frame)).toEqual(['en/landscape/hero.png', 'en/landscape/wide.png', 'en/landscape/both.png']); + const b = planWeb(cap, { ...base, dims: { ...frameDims(c), landscape: { w: 1280, h: 800, dpr: 2, bar: 0 } } }); + expect(b[0]!.hash).not.toBe(a[0]!.hash); + }); +}); + +describe('capture --dry', () => { + const dry = async (config: ShipConfig, argv: string[]) => { + const p = project(config); + try { return await capture(() => captureRun(p.ctx(['--dry', ...argv], ['dry']))); } finally { rmSync(p.root, { recursive: true, force: true }); } + }; + test('--device landscape lists landscape targets, no phone / tablet', async () => { + const r = await dry(cfg(), ['--device', 'landscape', '--lang', 'en']); + expect(r.out).toContain('en/landscape/wide.png <- http://127.0.0.1:5173/?scene=wide'); + expect(r.out).toContain('en/landscape/hero.png'); // --device landscape captures every shot without a phone / tablet device + expect(r.out).not.toContain('en/phone/'); + expect(r.out).not.toContain('en/tablet/'); + expect(r.out).toContain('capture planned (dry run): 3 frames from web'); + }); + test('default devices add landscape only when the project renders a landscape size', async () => { + const plain = await dry(cfg(), ['--lang', 'en']); + expect(plain.out).not.toContain('en/landscape/hero.png'); + expect(plain.out).toContain('en/phone/hero.png'); + const mac = await dry(cfg({ apple: { ...BASE.apple!, mac: true } }), ['--lang', 'en']); + expect(mac.out).toContain('en/phone/hero.png'); + expect(mac.out).toContain('en/tablet/pad.png'); + for (const n of ['hero', 'wide', 'both']) expect(mac.out).toContain(`en/landscape/${n}.png`); + expect(mac.out).not.toContain('en/landscape/pad.png'); + expect(mac.out).not.toContain('en/landscape/ph.png'); + }); + test('shot selectors accept a landscape key and print the landscape viewport', async () => { + const c = cfg({ apple: { ...BASE.apple!, mac: true } }, { capture: { ...cap, shots: { hero: { path: '/', waitSelector: { phone: '#m', landscape: '#desk' } } } } }); + const r = await dry(c, ['--lang', 'en']); + expect(r.out).toContain('waitSelector on landscape 1440x900: #desk'); + expect(r.out).toContain('waitSelector on phone 440x902: #m'); + }); + test('ios-sim / android-emu skip landscape with a warning', async () => { + const mac = { apple: { ...BASE.apple!, mac: true } }; + const sim = await dry(cfg(mac, { capture: { source: 'ios-sim', ios: { bundleId: 'com.example.app', device: 'iPhone 17 Pro Max' }, shots: { hero: { path: '/', device: 'landscape' } } } }), ['--device', 'landscape', '--lang', 'en']); + expect(sim.err + sim.out).toContain('landscape frames (Mac, Chromebook) are not supported from ios-sim'); + expect(sim.out).toContain('0 frames'); + const and = await dry(cfg(mac, { capture: { source: 'android-emu', android: { package: 'com.example.app' }, shots: { hero: { path: '/' } } } }), ['--lang', 'en']); + expect(and.err + and.out).toContain('not supported from android-emu'); + expect(and.out).not.toContain('landscape/hero'); + }); + test('an unknown device is refused', async () => { + const p = project(cfg()); + try { + await expect(captureRun(p.ctx(['--dry', '--device', 'desktop'], ['dry']))).rejects.toThrow('--device: phone | tablet | landscape'); + } finally { rmSync(p.root, { recursive: true, force: true }); } + }); +}); diff --git a/tests/wave1.test.ts b/tests/wave1.test.ts index 894e88b..788ed78 100644 --- a/tests/wave1.test.ts +++ b/tests/wave1.test.ts @@ -290,6 +290,15 @@ describe('validate rules', () => { expect(f.find(x => x.code === 'availability')!.fix).toContain('Pricing and Availability'); expect(f.findIndex(x => x.level === 'ok')).toBeGreaterThan(f.findLastIndex(x => x.level === 'blocker')); }); + test('apple: products removed from sale are not blockers, unless the config still sells them', () => { + const s = appleOk(); + s.iaps = [...s.iaps, { productId: 'old1', state: 'DEVELOPER_REMOVED_FROM_SALE', reviewScreenshot: false }, { productId: 'old2', state: 'REMOVED_FROM_SALE', reviewScreenshot: false }]; + expect(appleRules(s).filter(f => f.level !== 'ok')).toEqual([]); + expect(appleRules(s).find(f => f.code === 'iap')!.msg).toBe('1 in-app purchases ready (2 removed from sale, not checked)'); + s.expectedProducts = ['p1', 'old1']; + const f = appleRules(s).find(x => x.code === 'iap')!; + expect([f.level, f.msg]).toEqual(['warn', 'old1: DEVELOPER_REMOVED_FROM_SALE, but the config still sells it']); + }); test('apple: non-exempt needs an approved declaration', () => { const s = appleOk(); s.build = { ...s.build!, usesNonExemptEncryption: true, declarationState: 'IN_REVIEW' };