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

This commit is contained in:
CRTkafa 2026-11-03 23:14:34 +07:00
parent cdd9321794
commit d0f568ebc2
13 changed files with 230 additions and 48 deletions

View file

@ -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 `<lang>/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.

View file

@ -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]"

View file

@ -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: <frames>/<lang>/landscape/<name>.png at `size` CSS px (default 1440 x 900 @2x). frame: 'window' (a
* other. Frames: <frames>/<lang>/landscape/<name>.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 (<lang>/landscape/<name>.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. */

View file

@ -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);

View file

@ -30,24 +30,30 @@ import * as rt from '../lib/rt';
class PlaywrightShapeError extends Error {}
async function chromium(root: string): Promise<any> {
// 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). */

View file

@ -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<Step | 'total', number>;
@ -100,14 +104,14 @@ export async function hashServed(baseUrl: string): Promise<string> {
}
/** 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<Device, FrameDims>; 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<Device, FrameDims>; auth?: AuthPlan }): Promise<CaptureReport> {
export async function captureWeb(browser: any, cap: ShotsCapture, jobs: WebJob[], o: { concurrency: number; dims: CaptureDims; auth?: AuthPlan }): Promise<CaptureReport> {
const auth = o.auth;
const mask = (t: string): string => (auth ? maskSecrets(t, auth.secrets) : t);
const expired = new Set<string>();
@ -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<Device, FrameDims>; 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();

View file

@ -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 <shots.frames>/<lang>/<device>/<name>.png: the screen BELOW the status bar (the template draws one).
// Devices: phone, tablet and landscape (<lang>/landscape/<name>.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<void> {
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 <shots.frames>/<lang>/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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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);

View file

@ -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<ShotsCapture['android']>, 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) ----

View file

@ -175,6 +175,15 @@ export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): 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 (<lang>/landscape/<name>.png): shots.landscape.size over the default. */
export function landscapeDims(config: Pick<ShipConfig, 'shots'>): 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<ShipConfig, 'apple' | 'play'>, 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 <lang>/landscape/<name>.png frames, which `shots capture` makes with the landscape device. Same rules as render. */
export function rendersLandscape(config: Pick<ShipConfig, 'apple' | 'play' | 'shots'>): 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;

View file

@ -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<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`];
const o = sel as Record<string, unknown>, bad = Object.keys(o).filter(k => !['phone', 'tablet', 'landscape', 'any'].includes(k));
if (bad.length) return [`${where}: unknown key ${bad.join(', ')} (phone | tablet | landscape | any)`];
if (!Object.keys(o).length) return [`${where}: at least one of phone | tablet | landscape | any`];
return Object.entries(o).filter(([, v]) => !okList(v)).map(([k]) => `${where}.${k}: a selector or a list of selectors`);
}
return [`${where}: a selector, a list of selectors, or { phone, tablet, 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. */

View file

@ -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 `<frames>/<lang>/landscape/<name>.png`, `shots.landscape.size` CSS px (default 1440 x 900 @2x).
captures in `<frames>/<lang>/landscape/<name>.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:
`<shots.frames>/<lang>/<phone|tablet>/<shot>.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

View file

@ -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<string, unknown> = {}, shots: Record<string, unknown> = {}): ShipConfig =>
({ ...BASE, ...over, shots: { frames: 'frames', copy: 'copy.json', scenes: [], capture: cap, ...shots } }) as unknown as ShipConfig;
const names = (c: Parameters<typeof shotsFor>[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 <lang>/landscape/<name>.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 }); }
});
});

View file

@ -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' };