209 lines
13 KiB
TypeScript
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 }); }
|
|
});
|
|
});
|