storebound/tests/teaser.test.ts

375 lines
24 KiB
TypeScript

// The teaser video (media/teaser, v9, the story cut tight): every CLI line it shows is real output of the command it
// names, the game screens and screenshot tiles are the demo game's own, every desktop frame is a real capture of the app
// (desktop.ts), every cut lands on a beat of the music, each shot is 2-4 s, each caption is at most six words and fully
// on screen for at least 2 s, the whole cut is 40-48 s, frame 0 already moves, the last 4 s hold the end card with
// `npx storebound init`, and it is 16:9 only.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { ALPHA3 } from '../cli/lib/money';
import { SIZES } from '../cli/shots/lib';
const ROOT = join(import.meta.dir, '..');
const CLI = join(ROOT, 'cli', 'index.ts');
const FX = join(import.meta.dir, 'fixtures', 'scan');
const MOCK = join(import.meta.dir, 'helpers', 'teaser-store-mock.ts');
interface Scene { id: string; start: number; end: number; x: number }
type Line = { at: number; text: string; cmd?: boolean; head?: boolean; final?: boolean };
interface Teaser {
FORMAT: { w: number; h: number }; DURATION: number; FPS: number; SCENES: Scene[]; MONTAGE: string[];
CAPTIONS: { id: string; pos: 'top' | 'edge'; text: string; at: number; until: number }[];
captionTimes(): { id: string; text: string; pos: string; at: number; from: number; to: number; gone: number; hold: number }[];
veil(t: number): number;
GAME_SCREENS: string[]; GAME_SPEED: number;
PRIVACY_ANSWERS: string[]; PRIVACY_STEPS: string[]; DATA_SAFETY_CSV: string[]; DECL_TOTAL: number; PRICE_ROWS: string[]; PRICE_CMD: string;
PILE: { at: number; kind: string }[]; PILE_TITLES: Record<string, string>;
PROMPT: string; REJECTS: { fixture: string; rule: string; title: string; head: string }[]; REJECT_STEP: number; SAFE: number;
STAMP: { at: number; d: number; jolt: number }; REJECT_LAND: { d: number }; REJECT_PUSH: [number, number]; REJECT_DARK: number;
ticks: () => { t: number; kind: string }[];
BUILD_LINES: Line[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; PRODUCT_LINES: Line[];
DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string;
CONSOLE_CMD: string; CONSOLE_LINES: Line[]; CONSOLES_SWAP: number[];
SCAN_CMD: string; RISKS_LINE: string; RESCAN_LINE: string; FINDINGS: string[]; FIX_AT: number[];
DESK: Record<string, { groups: { frame: string }[]; swap?: number[]; show?: number[]; rings: { at: number; until: number; target: string }[];
pointer?: { at: number; d: number; target: string; click?: number; until?: number } }>;
DESK_SCENES: string[]; DESK_FRAMES: string[]; CLICK_T: number;
END: { markAt: number; wordAt: number; cmdAt: number; cps: number; urlAt: number; d: number; full: number }; END_CMD: string; END_URL: string;
SHOT_NOTES: Record<string, string>;
}
type Box = { x: number; y: number; w: number; h: number };
const T = (await import(join(ROOT, 'media/teaser/script.js'))) as Teaser;
/** Output lines without the clock prefix and the "(0s)" timer, as the video shows them. */
const lines = (out: string): string[] => out.split('\n').map(l => l.replace(/^\d\d:\d\d:\d\d /, '').replace(/ \(\d+s\)$/, '').trimEnd());
const run = (argv: string[], cwd: string, env: Record<string, string> = {}): { code: number; out: string } => {
const r = Bun.spawnSync(['bun', CLI, ...argv], { cwd, env: { ...process.env, NO_COLOR: '1', ASC_KEY_ID: '', ASC_ISSUER_ID: '', ASC_P8: '', PLAY_SERVICE_ACCOUNT: '', ...env }, stdout: 'pipe', stderr: 'pipe' });
return { code: r.exitCode, out: r.stdout.toString() + r.stderr.toString() };
};
/** Every line is in the output, in the given order. */
function inOrder(out: string[], want: string[]): void {
for (const l of want) expect(out, l).toContain(l);
const idx = want.map(l => out.indexOf(l));
expect([...idx].sort((a, b) => a - b)).toEqual(idx);
}
// One throwaway project: `storebound init` in an empty folder plus two products, store answers from the teaser store
// double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml), placeholder keys.
let dir = '', keys: Record<string, string> = {};
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'sb-teaser-'));
expect(run(['init'], dir).code).toBe(0);
const cfg = join(dir, 'storebound.config.ts');
// the teaser's app has answered its declarations (a fresh init lists them as unanswered and nothing is sent)
writeFileSync(cfg, readFileSync(cfg, 'utf8').replace(/^ unanswered: \[[^\]]*\],$/m, ' unanswered: [],').replace(' dataSafety: {', ` products: [
{ id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } },
{ id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } },
],
dataSafety: {`));
writeFileSync(join(dir, 'bunfig.toml'), `preload = [${JSON.stringify(MOCK)}]\n`);
const ec = generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
const rsa = generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
writeFileSync(join(dir, 'k.p8'), ec);
writeFileSync(join(dir, 'sa.json'), JSON.stringify({ client_email: 'sb@example.com', private_key: rsa, token_uri: 'https://oauth2.googleapis.com/token' }));
keys = { ASC_KEY_ID: 'ABCDE12345', ASC_ISSUER_ID: '00000000-0000-0000-0000-000000000000', ASC_P8: join(dir, 'k.p8'), PLAY_SERVICE_ACCOUNT: join(dir, 'sa.json') };
});
afterAll(() => rmSync(dir, { recursive: true, force: true }));
describe('teaser: every CLI line is real output', () => {
test('pile + consoles: `storebound console plan apple-app-privacy` (offline): answer sheet, steps, never-press, the owner\'s Publish', () => {
expect(T.CONSOLE_CMD).toBe('storebound console plan apple-app-privacy');
const r = run(T.CONSOLE_CMD.split(' ').slice(1), dir);
expect(r.code).toBe(0);
const out = lines(r.out);
inOrder(out, T.PRIVACY_ANSWERS);
inOrder(out, T.PRIVACY_STEPS);
inOrder(out, T.CONSOLE_LINES.map(l => l.text));
const fin = T.CONSOLE_LINES.find(l => l.final)!;
expect(fin.text).toBe("Final button (the owner's): Publish");
expect(T.CONSOLE_LINES.find(l => l.text.startsWith('Never press:'))!.text).toContain('"Publish"');
});
test('pile: the Data safety CSV of `storebound play data-safety --dry`, its row count from `storebound check`', () => {
const r = run(['play', 'data-safety', '--dry'], dir);
expect(r.code).toBe(0);
inOrder(lines(r.out), T.DATA_SAFETY_CSV);
expect(run(['check'], dir).out).toContain(`data safety: ${T.DECL_TOTAL} CSV rows`);
expect(T.PILE_TITLES.csv).toContain(`${T.DECL_TOTAL} rows`);
});
test('pile: the price sheet of `storebound prices ppp --offline --dry`', () => {
const r = run(T.PRICE_CMD.split(' '), dir);
expect(r.code).toBe(0);
inOrder(lines(r.out), T.PRICE_ROWS);
});
test('builds: `storebound apple build --dry` and `storebound play build --dry` in a Capacitor project', () => {
const d = mkdtempSync(join(tmpdir(), 'sb-teaser-build-'));
try {
const touch = (p: string, s = '') => { mkdirSync(dirname(join(d, p)), { recursive: true }); writeFileSync(join(d, p), s); };
touch('storebound.config.ts', `export default { app: { name: 'Example', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'hi@example.com' },
languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }], apple: { bundleId: 'com.example.game', signing: { teamId: 'TEAMID1234' } }, play: { packageName: 'com.example.game' } };\n`);
touch('capacitor.config.ts', 'export default {}');
touch('ios/App/App.xcodeproj/project.pbxproj');
touch('android/settings.gradle'); touch('android/gradlew', '#!/bin/sh');
touch('android/app/build.gradle', "android {\n defaultConfig {\n versionCode((project.findProperty('versionCode') ?: 1) as Integer)\n versionName project.findProperty('versionName') ?: '1.0'\n }\n}\n");
const cmds = T.BUILD_LINES.filter(l => l.cmd).map(l => l.text.replace(/^\$ storebound /, ''));
expect(cmds).toEqual(['apple build --dry --build-number 52 --version 1.0.0', 'play build --dry --version-code 52']);
const outs = cmds.map(c => run(c.split(' '), d, { HOME: join(d, 'home') }));
for (const o of outs) expect(o.code).toBe(0);
// each command's lines in its own output, in order
const groups: string[][] = [];
for (const l of T.BUILD_LINES) if (l.cmd) groups.push([]); else groups.at(-1)!.push(l.text);
groups.forEach((g, i) => {
const out = lines(outs[i]!.out);
for (const l of g) expect(out, l).toContain(l);
expect(out.indexOf(g[0]!)).toBeLessThan(out.lastIndexOf(g.at(-1)!));
});
} finally { rmSync(d, { recursive: true, force: true }); }
});
test('products: `play products --dry` before, `catalog diff` after, with the two products in the config', () => {
const cmds = T.PRODUCT_LINES.filter(l => l.cmd).map(l => l.text.replace(/^\$ storebound /, ''));
expect(cmds).toEqual(['play products --dry', 'catalog diff']);
const before = run(cmds[0]!.split(' '), dir, { ...keys, TEASER_STORE: 'new' });
const after = run(cmds[1]!.split(' '), dir, { ...keys, TEASER_STORE: 'catalog' });
expect(before.code).toBe(0);
expect(after.code).toBe(0);
const out = [...lines(before.out), ...lines(after.out)];
for (const l of T.PRODUCT_LINES.filter(x => !x.cmd)) expect(out, l.text).toContain(l.text);
// "175 countries": the App Store's storefronts, the region prices shown
expect(Object.keys(ALPHA3)).toHaveLength(175);
expect(T.PRODUCT_LINES.some(l => l.text.includes('175 region prices'))).toBe(true);
expect(T.CAPTIONS.find(c => c.id === 'products')!.text).toBe('Products, 175 countries.');
expect(out.join('\n')).toContain('0 mismatch(es), 0 warning(s), 0 price difference(s)');
});
});
describe('teaser: the scan text is real', () => {
const scanText = (fixture: string, demo: boolean): string => {
const env = { REVIEW_DEMO_USER: demo ? 'demo@example.com' : '', REVIEW_DEMO_PASSWORD: demo ? 'example' : '' };
const r = run(['scan', '--dir', join(FX, fixture)], ROOT, env);
expect(r.code).toBe(demo ? 0 : 1); // exit 1 on blockers (before the fixes)
return r.out;
};
let before = '', after = '';
beforeAll(() => { before = scanText('teaser', false); after = scanText('teaser-fixed', true); });
test('reject: each card is a real finding line of `storebound scan` (teaser, teaser-manifest), its rule number in it', () => {
const manifest = run(['scan', '--dir', join(FX, 'teaser-manifest')], ROOT, {}).out;
for (const f of T.REJECTS) {
const out = f.fixture === 'teaser' ? before : manifest;
expect(out, f.rule).toMatch(new RegExp(`^ \\d\\. ${f.head.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm'));
expect(f.head, f.rule).toContain(f.rule.replace('Guideline ', ''));
expect(f.title.split(' ').length, f.title).toBeLessThanOrEqual(6);
}
expect(T.REJECTS.map(f => f.rule)).toEqual(['Guideline 2.1(a)', 'Guideline 5.1.1(v)', 'Guideline 5.1.2(i)', 'ITMS-91064']);
});
test('scan: the risks line, all five findings in order, and the clean re-scan of teaser-fixed', () => {
expect(T.SCAN_CMD).toBe('storebound scan');
const out = lines(before);
expect(out).toContain(T.RISKS_LINE);
inOrder(out, T.FINDINGS.map((f, i) => ` ${i + 1}. ${f}`));
expect(T.FIX_AT).toHaveLength(T.FINDINGS.length);
expect(after).toContain(T.RESCAN_LINE);
});
test('forms: the declarations proposal lines and the ask-owner line', () => {
for (const d of T.DECL_LINES) expect(before).toContain(`detected ${d.what}: ${d.value}`);
for (const d of T.DECL_LINES) expect(before).toContain(`(${d.where})`);
expect(before).toContain(T.DECL_ASK);
});
});
describe('teaser: the game and its screenshots are the demo game\'s', () => {
test('game: the screens are the demo game\'s own captures (its config\'s capture shots)', async () => {
const cfg = (await import(join(ROOT, 'examples/demo-game/storebound.config.ts'))).default as { languages: { id: string }[]; shots: { capture: { shots: Record<string, unknown> } } };
expect(T.GAME_SCREENS).toEqual(Object.keys(cfg.shots.capture.shots));
expect(T.SHOT_LANGS).toEqual(cfg.languages.map(l => l.id));
const render = readFileSync(join(ROOT, 'media/teaser/render.ts'), 'utf8');
expect(render).toContain("'examples/demo-game/store/shots/frames/en/phone'");
});
test('shots: seven store sizes the demo game renders (iPhone, iPad, Play phone and tablets, feature graphic)', () => {
for (const s of T.SHOT_SIZES) expect(SIZES[s.id], s.id).toBeDefined();
expect(T.SHOT_SIZES).toHaveLength(7);
// the caption claims sizes, not a language count the demo does not have
expect(T.CAPTIONS.find(c => c.id === 'shots')!.text).toBe('Screenshots, every size.');
});
test('ask: the request is a plain sentence to the agent', () => {
expect(T.PROMPT).toBe('Get this game ready for review on both stores.');
});
});
describe('teaser: timing and format', () => {
test('16:9 only: the master is 1920x1080; the pipeline has no vertical output', () => {
expect(T.FORMAT).toEqual({ w: 1920, h: 1080 });
const render = readFileSync(join(ROOT, 'media/teaser/render.ts'), 'utf8');
expect(render).not.toMatch(/1080\s*x\s*1920|w: 1080, h: 1920|vertical/i);
expect(render).toContain('scale=1280:720');
});
test('the story: the shots in order, the montage in the middle, 40-48 s, hard cuts only', () => {
expect(T.SCENES.map(s => s.id)).toEqual(['game', 'pile', 'reject', 'ask', 'builds', 'shots', 'products', 'forms', 'consoles', 'scan', 'button', 'waiting', 'end']);
expect(T.MONTAGE).toEqual(['builds', 'shots', 'products', 'forms', 'consoles']);
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
expect(T.DURATION).toBeGreaterThanOrEqual(40);
expect(T.DURATION).toBeLessThanOrEqual(48);
// the montage: 3 s per beat
for (const id of T.MONTAGE) { const s = T.SCENES.find(x => x.id === id)!; expect(s.end - s.start, id).toBe(3); }
for (const s of T.SCENES) expect(s.x, s.id).toBe(0);
// every shot says why it cuts (the animatic's shot list)
for (const s of T.SCENES) expect(T.SHOT_NOTES[s.id], s.id).toBeTruthy();
});
test('every cut lands on a beat of music.js (120 BPM: whole half seconds), shots 2-4 s, the end card 5 s', () => {
const music = readFileSync(join(ROOT, 'media/teaser/music.js'), 'utf8');
expect(music).toContain('export const STEP = 0.5;');
expect(T.SCENES[0]!.start).toBe(0);
for (let i = 1; i < T.SCENES.length; i++) expect(T.SCENES[i]!.start).toBe(T.SCENES[i - 1]!.end);
for (const s of T.SCENES) {
expect(Number.isInteger(s.start / 0.5), s.id).toBe(true);
expect(s.end - s.start, s.id).toBeGreaterThanOrEqual(2);
expect(s.end - s.start, s.id).toBeLessThanOrEqual(s.id === 'end' ? 5 : 4);
}
// the fixes chime on beats too, and the pile lands one card per beat
const scan = T.SCENES.find(s => s.id === 'scan')!;
for (const f of T.FIX_AT) expect(Number.isInteger((scan.start + f) / 0.5), String(f)).toBe(true);
T.PILE.forEach((c, i) => expect(c.at, c.kind).toBe(i * 0.5));
});
test('captions: at most six words, fully on screen for at least 2 s, never over the end card', () => {
const c = T.captionTimes();
expect(new Set(c.map(x => x.id)).size).toBe(c.length);
const end = T.SCENES.find(s => s.id === 'end')!;
for (const x of c) {
expect(x.hold, x.text).toBeGreaterThanOrEqual(2);
expect(x.at, x.text).toBeGreaterThanOrEqual(T.SCENES.find(y => y.id === x.id)!.start);
expect(x.gone, x.text).toBeLessThanOrEqual(end.start);
expect(x.text.split(' ').length, x.text).toBeLessThanOrEqual(6);
}
for (let i = 1; i < c.length; i++) expect(c[i]!.at, c[i]!.text).toBeGreaterThanOrEqual(c[i - 1]!.gone);
for (const x of c) expect(x.pos, x.id).toBe(T.MONTAGE.includes(x.id) ? 'edge' : 'top');
expect(c.map(x => x.text)).toEqual(['Your game is done.', 'Then come the stores.', 'Rejected. Again.', 'So my AI did it.',
'Builds.', 'Screenshots, every size.', 'Products, 175 countries.', 'Forms.', 'Consoles.', 'Nothing left to reject.', 'You press Submit.']);
});
test('the cold open moves on frame 0: no fade from black, the screens already rushing past', () => {
expect(T.CAPTIONS[0]!.at).toBeLessThanOrEqual(0.2);
expect(T.GAME_SPEED).toBeGreaterThan(200);
const js = readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8');
expect(js).toContain("set(el, 'opacity', String(i === 0 ? 1 :");
});
test('the end card: the logo, `npx storebound init` and storebound.dev, complete for at least the last 4 s', () => {
const e = T.SCENES.find(s => s.id === 'end')!;
expect(T.SCENES.at(-1)!.id).toBe('end');
expect(T.END_CMD).toBe('npx storebound init');
expect(T.END_URL).toBe('storebound.dev');
expect(T.DURATION - (e.start + T.END.full)).toBeGreaterThanOrEqual(4);
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
expect(html).toMatch(/#brand \.cmdl \{[^}]*font: 800 7\dpx/);
});
test('reject: 4 s, the cards land one per beat and all read >= 1 s before the REJECTED stamp hits on a beat, then it darkens', () => {
const r = T.SCENES.find(s => s.id === 'reject')!;
expect(r.end - r.start).toBe(4);
expect(T.REJECTS.length).toBeLessThanOrEqual(5);
T.REJECTS.forEach((_, i) => expect(Number.isInteger((r.start + i * T.REJECT_STEP) / 0.5)).toBe(true));
expect(T.STAMP.at - T.STAMP.d - ((T.REJECTS.length - 1) * T.REJECT_STEP + T.REJECT_LAND.d)).toBeGreaterThanOrEqual(0.7);
expect(Number.isInteger((r.start + T.STAMP.at) / 0.5)).toBe(true);
expect(T.ticks().some(x => x.kind === 'stamp' && x.t === r.start + T.STAMP.at)).toBe(true);
expect(T.REJECT_DARK).toBeGreaterThan(T.STAMP.at);
expect(T.STAMP.jolt).toBeLessThanOrEqual(6); // no shake: one tiny 1-frame jolt at most
expect(readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8')).not.toMatch(/shake/);
});
// The render's layout data: `render.ts --animatic` writes every reject card's box at every frame of the shot.
const CHECKS = join(ROOT, 'media/teaser/out/animatic-checks.json');
test.skipIf(!existsSync(CHECKS))('reject: every card and the stamp stay inside the 6% safe margins at every frame (animatic layout data)', () => {
const j = JSON.parse(readFileSync(CHECKS, 'utf8')) as { duration: number; rejectLayout?: { t: number; cards: Box[]; stamp: Box | null }[] };
expect(j.duration).toBe(T.DURATION);
const r = T.SCENES.find(s => s.id === 'reject')!;
expect(j.rejectLayout?.length).toBe(Math.round((r.end - r.start) * T.FPS));
const mx = T.SAFE * T.FORMAT.w, my = T.SAFE * T.FORMAT.h;
for (const f of j.rejectLayout!) for (const [k, b] of [...f.cards.map((c, i) => [`card ${i + 1}`, c] as const), ['stamp', f.stamp] as const]) {
if (!b) continue;
const at = `${k} at ${f.t.toFixed(3)} s`;
expect(b.x, at).toBeGreaterThanOrEqual(mx);
expect(b.y, at).toBeGreaterThanOrEqual(my);
expect(b.x + b.w, at).toBeLessThanOrEqual(T.FORMAT.w - mx);
expect(b.y + b.h, at).toBeLessThanOrEqual(T.FORMAT.h - my);
}
expect(j.rejectLayout!.at(-1)!.cards.length).toBe(T.REJECTS.length);
expect(j.rejectLayout!.some(f => f.stamp)).toBe(true);
});
test.skipIf(!existsSync(CHECKS))('no text overflows anywhere: every text node fits its element and its card, every card in frame, at every frame (animatic layout data)', () => {
const j = JSON.parse(readFileSync(CHECKS, 'utf8')) as { duration: number; textFrames?: number; textProblems?: unknown[] };
expect(j.duration).toBe(T.DURATION);
expect(j.textFrames).toBe(Math.round(T.DURATION * T.FPS));
expect(j.textProblems?.slice(0, 10)).toEqual([]);
});
test('the picture darkens with the rejection and lifts on the drop', () => {
const r = T.SCENES.find(s => s.id === 'reject')!, b = T.SCENES.find(s => s.id === 'builds')!;
expect(T.veil(r.start)).toBe(0);
expect(T.veil(r.end - 0.1)).toBeGreaterThan(0.5);
expect(T.veil(b.start)).toBe(0);
});
});
// The desktop frames: the real app (fixture mode) in the consoles beat, the button and the waiting scene, captured by
// media/teaser/desktop.ts. The frames are gitignored output; the file checks run wherever they were captured
// (render.ts captures them when they are missing).
const DESK_DIR = join(ROOT, 'media/teaser/out/desktop');
const captured = existsSync(join(DESK_DIR, 'boxes.json'));
/** Width and height of a JPEG (its first SOF marker). */
function jpegSize(buf: Buffer): { w: number; h: number } | null {
if (buf[0] !== 0xff || buf[1] !== 0xd8) return null;
for (let i = 2; i + 9 < buf.length;) {
if (buf[i] !== 0xff) { i++; continue; }
const m = buf[i + 1]!;
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) return { w: buf.readUInt16BE(i + 7), h: buf.readUInt16BE(i + 5) };
i += 2 + buf.readUInt16BE(i + 2);
}
return null;
}
describe('teaser: the desktop scenes are real captures of the app', () => {
test('consoles, the button and the click: the plan panel, Send for review, then Waiting for review', () => {
expect(T.DESK_SCENES).toEqual(['consoles', 'button', 'waiting']);
expect(T.DESK.consoles!.groups.map(g => g.frame)).toEqual(['console-plan']);
expect(T.DESK.consoles!.show).toEqual(T.CONSOLES_SWAP);
expect(T.DESK.button!.groups.map(g => g.frame)).toEqual(['submit-ready']);
expect(T.DESK.waiting!.groups.map(g => g.frame)).toEqual(['submit-ready', 'submit-waiting']);
// the pointer rests on Submit in the button scene and presses it once, at the start of the waiting scene
expect(T.DESK.button!.pointer!.target).toBe('submitBtn');
expect(T.DESK.button!.pointer!.click).toBeUndefined();
expect(T.DESK.waiting!.pointer!.target).toBe('submitBtn');
const w = T.SCENES.find(s => s.id === 'waiting')!;
expect(T.CLICK_T).toBe(w.start + T.DESK.waiting!.pointer!.click!);
expect(T.DESK.waiting!.swap![0]).toBeGreaterThan(T.DESK.waiting!.pointer!.click!);
// the waiting scene picks up exactly where the button scene's camera stopped (no jump on the cut)
const cam = (id: string) => (T.DESK[id] as unknown as { cam: { from: unknown; to: unknown } }).cam;
expect(cam('waiting').from).toEqual(cam('button').to);
});
test('the console plan panel shows the real plan; no drawn system dialogs or mails', () => {
const src = readFileSync(join(ROOT, 'media/teaser/desktop.ts'), 'utf8');
expect(src).toContain("realConsolePlan('apple-app-privacy')");
expect(src).toContain("['console', 'plan', form, '--json']");
const html = readFileSync(join(ROOT, 'media/teaser/index.html'), 'utf8');
expect(html).not.toMatch(/Submit for Review<span class="hov">|id="sbtn"|id="mail"/);
});
test('every frame a scene names is one desktop.ts captures from the app (no mock frames)', () => {
const src = readFileSync(join(ROOT, 'media/teaser/desktop.ts'), 'utf8');
expect(src).toContain("VITE_FIXTURES: '1'");
for (const f of T.DESK_FRAMES) expect(src, f).toMatch(new RegExp(`shot\\([a-z.]+, '${f}'\\)`));
const js = readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8');
expect(js).toContain('out/desktop/${g.frame}.jpg');
});
test.if(captured)('the captured frames are present: 1280x800 @2x JPEGs, every ring and pointer target captured', () => {
const boxes = JSON.parse(readFileSync(join(DESK_DIR, 'boxes.json'), 'utf8')) as Record<string, unknown> & { source: string };
expect(boxes.source).toContain('VITE_FIXTURES=1');
for (const f of T.DESK_FRAMES) {
const p = join(DESK_DIR, `${f}.jpg`);
expect(existsSync(p), p).toBe(true);
expect(jpegSize(readFileSync(p)), f).toEqual({ w: 2560, h: 1600 });
}
for (const id of T.DESK_SCENES) {
for (const c of T.DESK[id]!.rings) expect(boxes[c.target], c.target).toBeDefined();
if (T.DESK[id]!.pointer) expect(boxes[T.DESK[id]!.pointer!.target], id).toBeDefined();
}
});
});