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

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