storebound/tests/shots.test.ts

209 lines
13 KiB
TypeScript

// Screenshot capture sources: config checks, simctl / adb / ffmpeg argv builders, frame checks. No simulator, emulator
// or browser needed.
import { describe, expect, test } from 'bun:test';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { loadConfig, type ShipConfig, type ShotsCapture, validateConfig } from '../cli/lib/config';
import { framesNeeded, checkFrameFolder } from '../cli/shots/check';
import {
adbAppLocaleArgs, adbDemoExitArgs, adbDemoModeArgs, adbForceStopArgs, adbInstallArgs, adbRecordArgs, adbScreencapArgs, adbStartArgs, adbStateArgs,
adbTarget, androidLocale, appleLocale, checkFrames, cropArgs, defaultCropTop, fill, pickSimulator, shotsFor, shQuote, simBootArgs, simBootStatusArgs,
simInstallArgs, simLaunchArgs, simOpenUrlArgs, simRecordArgs, simScreenshotArgs, simShutdownArgs, simStatusBarArgs, simStatusBarClearArgs, simTarget,
TOOL_HINTS, validateCapture,
} from '../cli/shots/devices';
const T = join(import.meta.dir, '..', 'templates');
/** Smallest PNG imageInfo can read: signature + IHDR (RGB) + IEND. */
function png(w: number, h: number): Uint8Array {
const b = new Uint8Array(8 + 25 + 12);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const dv = new DataView(b.buffer);
dv.setUint32(8, 13); b.set([0x49, 0x48, 0x44, 0x52], 12); dv.setUint32(16, w); dv.setUint32(20, h); b[24] = 8; b[25] = 2;
dv.setUint32(33, 0); b.set([0x49, 0x45, 0x4e, 0x44], 37);
return b;
}
describe('capture config', () => {
const web: ShotsCapture = { baseUrl: 'http://127.0.0.1:5173', shots: { hero: { path: '/' } } };
test('web', () => {
expect(validateCapture(undefined)).toEqual([]);
expect(validateCapture(web)).toEqual([]);
expect(validateCapture({ shots: { hero: { path: '/' } } }).join()).toContain('baseUrl');
expect(validateCapture({ ...web, shots: { hero: {} } }).join()).toContain('shots.capture.shots.hero.path');
expect(validateCapture({ ...web, shots: { hero: { path: '/', args: ['-x'] } } }).join()).toContain('only apply to ios-sim / android-emu');
expect(validateCapture({ ...web, shots: {} }).join()).toContain('at least one shot');
expect(validateCapture({ ...web, shots: { 'a b': { path: '/' } } }).join()).toContain('must be a file name');
expect(validateCapture({ ...web, shots: { a: { path: '/', device: 'watch' as 'phone' } } }).join()).toContain('phone | tablet | both');
expect(validateCapture({ ...web, cropTop: -1 }).join()).toContain('cropTop');
expect(validateCapture({ ...web, source: 'unity' as 'web' })).toEqual(['shots.capture.source: one of web, ios-sim, android-emu, folder']);
});
test('ios-sim', () => {
const ios: ShotsCapture = { source: 'ios-sim', ios: { device: 'iPhone 17 Pro Max', bundleId: 'com.example.game' }, shots: { hero: { url: 'game://x' } } };
expect(validateCapture(ios)).toEqual([]);
expect(validateCapture({ ...ios, ios: { devices: { phone: 'iPhone 17 Pro Max' }, bundleId: 'com.example.game' } })).toEqual([]);
const e = validateCapture({ ...ios, ios: {} as never }).join('\n');
expect(e).toContain('ios.bundleId');
expect(e).toContain('ios.device');
});
test('android-emu', () => {
const and: ShotsCapture = { source: 'android-emu', android: { package: 'com.example.game' }, shots: { hero: { extras: { seed: 1, lang: '{lang}', fast: true } } } };
expect(validateCapture(and)).toEqual([]);
expect(validateCapture({ ...and, android: undefined }).join()).toContain('android.package');
const bad = validateCapture({ ...and, shots: { hero: { extras: { 'a b': 1, x: [1] as unknown as string } } } }).join('\n');
expect(bad).toContain('extras.a b: key');
expect(bad).toContain('extras.x: string | number | boolean');
});
test('folder needs no shots', () => {
expect(validateCapture({ source: 'folder', shots: {} })).toEqual([]);
expect(validateCapture({ source: 'folder', folder: 'my/frames', shots: { a: { args: ['-x'] } } }).join()).toContain('only apply');
});
test('validateConfig reports capture problems', async () => {
const { config } = await loadConfig(join(T, 'storebound.config.ts'));
expect(validateConfig(config)).toEqual([]);
const broken = { ...config, shots: { ...config.shots!, capture: { source: 'ios-sim', shots: { hero: {} } } } };
expect(validateConfig(broken).join('\n')).toContain('shots.capture.ios.bundleId');
});
test('shots per device class', () => {
const c: ShotsCapture = { shots: { a: {}, b: { device: 'tablet' }, c: { device: 'both' } } };
expect(shotsFor(c, 'phone').map(x => x[0])).toEqual(['a', 'c']);
expect(shotsFor(c, 'tablet').map(x => x[0])).toEqual(['b', 'c']);
expect(shotsFor(c, 'phone', ['c']).map(x => x[0])).toEqual(['c']);
});
test('device targets', () => {
expect(simTarget({ bundleId: 'x', device: 'P' }, 'phone')).toBe('P');
expect(simTarget({ bundleId: 'x', device: 'P', devices: { phone: 'Q', tablet: 'T' } }, 'phone')).toBe('Q');
expect(simTarget({ bundleId: 'x', device: 'P' }, 'tablet')).toBeUndefined();
expect(adbTarget({ package: 'x', serial: 'emulator-5554' }, 'phone')).toBe('emulator-5554');
expect(adbTarget({ package: 'x' }, 'phone')).toBeUndefined();
expect(adbTarget({ package: 'x', serials: { tablet: 'emulator-5556' } }, 'tablet')).toBe('emulator-5556');
});
test('tool hints', () => { for (const k of ['xcrun', 'adb', 'ffmpeg']) expect(TOOL_HINTS[k]).toContain('not found'); });
});
describe('ios simulator argv', () => {
const U = 'ABCD-1234';
test('pick a simulator', () => {
const list = JSON.stringify({ devices: {
'com.apple.CoreSimulator.SimRuntime.iOS-18-0': [{ udid: 'old', name: 'iPhone 16 Pro Max', state: 'Shutdown' }],
'com.apple.CoreSimulator.SimRuntime.iOS-26-0': [{ udid: 'new', name: 'iPhone 16 Pro Max', state: 'Shutdown' }, { udid: 'gone', name: 'iPad', state: 'Shutdown', isAvailable: false }],
'com.apple.CoreSimulator.SimRuntime.iOS-9-0': [{ udid: 'nine', name: 'iPhone 16 Pro Max', state: 'Shutdown' }],
} });
expect(pickSimulator(list, 'iPhone 16 Pro Max')!.udid).toBe('new');
expect(pickSimulator(list, 'old')!.udid).toBe('old');
expect(pickSimulator(list, 'iPad')).toBeNull();
const booted = list.replace('"udid":"old","name":"iPhone 16 Pro Max","state":"Shutdown"', '"udid":"old","name":"iPhone 16 Pro Max","state":"Booted"');
expect(pickSimulator(booted, 'iPhone 16 Pro Max')!.udid).toBe('old');
});
test('lifecycle', () => {
expect(simBootArgs(U)).toEqual(['simctl', 'boot', U]);
expect(simBootStatusArgs(U)).toEqual(['simctl', 'bootstatus', U, '-b']);
expect(simShutdownArgs(U)).toEqual(['simctl', 'shutdown', U]);
expect(simInstallArgs(U, '/b/Game.app')).toEqual(['simctl', 'install', U, '/b/Game.app']);
});
test('status bar', () => {
const a = simStatusBarArgs(U);
expect(a.slice(0, 4)).toEqual(['simctl', 'status_bar', U, 'override']);
expect(a.join(' ')).toContain('--time 9:41 --batteryState charged --batteryLevel 100');
expect(simStatusBarClearArgs(U)).toEqual(['simctl', 'status_bar', U, 'clear']);
});
test('launch with language and args', () => {
expect(appleLocale('en-US')).toBe('en_US');
expect(appleLocale('tr')).toBe('tr');
expect(appleLocale('zh-Hans')).toBe('zh-Hans');
expect(simLaunchArgs(U, 'com.example.game', { lang: 'de', locale: 'de-DE', shot: 'hero', baseArgs: ['-seed', '1'], args: ['-scene', '{shot}', '-lang', '{lang}', '-loc', '{locale}'] }))
.toEqual(['simctl', 'launch', '--terminate-running-process', U, 'com.example.game', '-AppleLanguages', '(de-DE)', '-AppleLocale', 'de_DE',
'-seed', '1', '-scene', 'hero', '-lang', 'de', '-loc', 'de-DE']);
expect(simLaunchArgs(U, 'b', { lang: 'tr', shot: 'x' }).slice(-4)).toEqual(['-AppleLanguages', '(tr)', '-AppleLocale', 'tr']);
});
test('deep link, screenshot, video', () => {
expect(fill('game://shot/{shot}?lang={lang}&l={locale}', { lang: 'ja', shot: 'shop' })).toBe('game://shot/shop?lang=ja&l=ja');
expect(simOpenUrlArgs(U, 'game://x')).toEqual(['simctl', 'openurl', U, 'game://x']);
expect(simScreenshotArgs(U, '/t/a.png')).toEqual(['simctl', 'io', U, 'screenshot', '--type=png', '--mask=ignored', '/t/a.png']);
expect(simRecordArgs(U, '/t/a.mp4')).toEqual(['simctl', 'io', U, 'recordVideo', '--codec=h264', '--mask=ignored', '--force', '/t/a.mp4']);
});
});
describe('android argv', () => {
test('serial prefix and basics', () => {
expect(adbStateArgs()).toEqual(['get-state']);
expect(adbStateArgs('emulator-5554')).toEqual(['-s', 'emulator-5554', 'get-state']);
expect(adbInstallArgs('/b/g.apk', 'x')).toEqual(['-s', 'x', 'install', '-r', '/b/g.apk']);
expect(adbForceStopArgs('com.example.game')).toEqual(['shell', 'am', 'force-stop', 'com.example.game']);
expect(adbScreencapArgs('x')).toEqual(['-s', 'x', 'exec-out', 'screencap', '-p']);
});
test('demo mode', () => {
const d = adbDemoModeArgs('x');
expect(d[0]).toEqual(['-s', 'x', 'shell', 'settings', 'put', 'global', 'sysui_demo_allowed', '1']);
expect(d.map(a => a.join(' ')).join('\n')).toContain('-e command clock -e hhmm 0941');
expect(d.every(a => a[0] === '-s')).toBe(true);
expect(adbDemoExitArgs()).toEqual(['shell', 'am', 'broadcast', '-a', 'com.android.systemui.demo', '-e', 'command', 'exit']);
});
test('per-app language', () => {
expect(androidLocale('pt_BR')).toBe('pt-BR');
expect(androidLocale('es-419')).toBe('es-419');
expect(adbAppLocaleArgs('com.example.game', 'pt_BR')).toEqual(['shell', 'cmd', 'locale', 'set-app-locales', 'com.example.game', '--locales', 'pt-BR']);
});
test('am start: activity + typed extras', () => {
const a = adbStartArgs('com.example.game', { lang: 'tr', shot: 'hero', extras: { seed: 7, speed: 0.5, fast: true, lang: '{lang}', title: "it's {shot}" } }, 'x');
expect(a.slice(0, 3)).toEqual(['-s', 'x', 'shell']);
expect(a[3]).toBe("am start -W -n com.example.game/.MainActivity --ei seed 7 --ef speed 0.5 --ez fast true --es lang tr --es title 'it'\\''s hero'");
expect(adbStartArgs('p', { lang: 'en', shot: 's', activity: 'com.unity3d.player.UnityPlayerActivity' })[1]).toBe('am start -W -n p/com.unity3d.player.UnityPlayerActivity');
});
test('am start: deep link', () => {
const a = adbStartArgs('com.example.game', { lang: 'en', shot: 'shop', url: 'game://shot/{shot}?lang={lang}&x=1' });
expect(a).toEqual(['shell', "am start -W -a android.intent.action.VIEW -d 'game://shot/shop?lang=en&x=1' com.example.game"]);
});
test('shell quoting and screenrecord limit', () => {
expect(shQuote('plain-1.0')).toBe('plain-1.0');
expect(shQuote('a b')).toBe("'a b'");
expect(shQuote("it's")).toBe("'it'\\''s'");
expect(adbRecordArgs('/sdcard/a.mp4', 999)).toEqual(['shell', 'screenrecord', '--time-limit', '180', '/sdcard/a.mp4']);
expect(adbRecordArgs('/sdcard/a.mp4', 0.2, 'x')).toEqual(['-s', 'x', 'shell', 'screenrecord', '--time-limit', '1', '/sdcard/a.mp4']);
});
});
describe('frames', () => {
const dims = { phone: { w: 440, h: 902 }, tablet: { w: 800, h: 1240 } };
test('crop', () => {
expect(defaultCropTop(956)).toBe(54);
expect(defaultCropTop(2868)).toBe(162);
expect(cropArgs('in.png', 'out.png', 162)).toEqual(['-v', 'error', '-y', '-i', 'in.png', '-vf', 'crop=iw:ih-162:0:162', '-pix_fmt', 'rgb24', 'out.png']);
expect(cropArgs('in.png', 'out.png', 0)).toEqual(['-v', 'error', '-y', '-i', 'in.png', '-pix_fmt', 'rgb24', 'out.png']);
});
test('checkFrames', () => {
const found = new Map([
['en/phone/ok.png', { w: 1320, h: 2706 }],
['en/phone/wide.png', { w: 1320, h: 2868 }],
['en/phone/small.png', { w: 440, h: 902 }],
['en/tablet/ok.png', { w: 1600, h: 2480 }],
]);
const need = (device: 'phone' | 'tablet', name: string) => ({ lang: 'en', device, name });
const r = checkFrames(found, [need('phone', 'ok.png'), need('phone', 'wide.png'), need('phone', 'small.png'), need('phone', 'gone.png'), need('tablet', 'ok.png')], dims);
expect(r.map(x => x.file)).toEqual(['en/phone/wide.png', 'en/phone/small.png', 'en/phone/gone.png']);
expect(r[0]!.msg).toContain('crop the status bar');
expect(r[1]!.msg).toContain('at least 880 px wide');
expect(r[2]!.msg).toBe('missing');
});
test('checkFrameFolder on disk', async () => {
const { config } = await loadConfig(join(T, 'storebound.config.ts'));
const cfg: ShipConfig = { ...config, shots: { ...config.shots!, capture: { source: 'folder', shots: {} } } };
const need = framesNeeded(cfg, ['en']);
expect(need.filter(n => n.device === 'phone').map(n => n.name).sort()).toEqual(['gameplay.png', 'hero.png', 'level-a.png', 'level-b.png', 'level-c.png', 'results.png']);
expect(need.some(n => n.device === 'tablet')).toBe(true);
const dir = mkdtempSync(join(tmpdir(), 'sb-frames-'));
try {
for (const n of need) {
mkdirSync(join(dir, n.lang, n.device), { recursive: true });
writeFileSync(join(dir, n.lang, n.device, n.name), n.device === 'phone' ? png(1320, 2706) : png(1600, 2480));
}
expect(checkFrameFolder(cfg, dir, ['en'])).toEqual([]);
writeFileSync(join(dir, 'en', 'phone', 'hero.png'), png(1320, 2868));
rmSync(join(dir, 'en', 'tablet', 'gameplay.png'));
const issues = checkFrameFolder(cfg, dir, ['en']);
expect(issues.map(i => i.file).sort()).toEqual(['en/phone/hero.png', 'en/tablet/gameplay.png']);
expect(checkFrameFolder(cfg, dir, ['de']).length).toBe(need.length);
} finally { rmSync(dir, { recursive: true, force: true }); }
});
});