storebound/tests/teaser.test.ts
CRT 930f960318 teaser v6: 55.5 s, v5 kept, its desktop part grown to four scenes
Portfolio (every app, both stores, rollout %, crash sparklines), the Growth and
Health charts with a real hover sweep (one captured frame per hovered day, the
pointer follows the recorded positions), the Prices matrix (both stores per
country, gaps marked), then v5's release timeline and 0 blockers. All frames are
real fixture-mode captures (desktop.ts). Tests: scenes 4-5 s, 50-60 s total,
captions >= 3 s, every desktop frame captured by desktop.ts and present.
2026-10-12 02:32:53 +08:00

248 lines
14 KiB
TypeScript

// The teaser video (media/teaser, v6): every CLI line it shows is real output of the command it names, every desktop
// frame is a real capture of the app (desktop.ts), each scene holds 4-5 s, each caption is fully on screen for at least
// 3 s, the whole cut is 50-60 s, and it is 16:9 only.
import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
import { generateKeyPairSync } from 'node:crypto';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { 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 }
interface Teaser {
FORMAT: { w: number; h: number }; DURATION: number; SCENES: Scene[]; DISSOLVE: number;
CAPTIONS: { id: string; lines: string[] }[];
captionTimes(): { id: string; text: string; at: number; from: number; to: number; hold: number }[];
TODO: { text: string }[]; MAIL: { at: number; subject: string; guideline: string }; MAIL_IN: number;
INIT_CMD: string; INIT_LINES: string[];
PLAN_CMD: string; PLAN_LINES: string[]; PLAN_STEPS: number; PLAN_PILL: string;
COPY_FILE: string; COPY: { lang: string; title: string[] }[]; SHOT_SIZES: { id: string }[]; SHOT_LANGS: string[]; SHOTS_PILL: string;
PRODUCT_LINES: { text: string; cmd?: boolean }[]; PRODUCTS_PILL: { text: string };
DECL_LINES: { what: string; value: string; where: string }[]; DECL_ASK: string; DECL_TOTAL: number;
SCAN_CMD: string; RISKS_LINE: string; RESCAN_LINE: string;
FINDINGS: { sev: string; guideline: string; title: string; where: string; why: string }[];
DESK: Record<string, { groups: { frame: string; sweep?: { seq: string; at: number; d: number } }[]; swap?: number[]; callouts: { at: number; until: number; target: string; text: string }[] }>;
DESK_SCENES: string[]; DESK_FRAMES: string[]; DESK_SWEEPS: string[];
}
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() };
};
// One throwaway project for the init, plan and products scenes: `storebound init` in an empty folder, store answers
// from the teaser store double (tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml), placeholder keys.
let dir = '', initOut = '', keys: Record<string, string> = {};
beforeAll(() => {
dir = mkdtempSync(join(tmpdir(), 'sb-teaser-'));
initOut = run(['init'], dir).out;
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('init: `storebound init` in an empty folder', () => {
expect(T.INIT_CMD).toBe('bunx storebound init');
const out = lines(initOut);
for (const l of T.INIT_LINES) expect(out, l).toContain(l);
});
test('plan: `storebound release plan 1.0.0` against a brand-new app record on both stores', () => {
expect(T.PLAN_CMD).toBe('storebound release plan 1.0.0');
const r = run(T.PLAN_CMD.split(' ').slice(1), dir, { ...keys, TEASER_STORE: 'new' });
const out = lines(r.out);
for (const l of T.PLAN_LINES) expect(out, l).toContain(l);
// the lines keep the order of the real plan
const idx = T.PLAN_LINES.map(l => out.indexOf(l));
expect([...idx].sort((a, b) => a - b)).toEqual(idx);
const steps = out.filter(l => /^\s+\d+\. /.test(l)).length;
expect(steps).toBe(T.PLAN_STEPS);
expect(T.PLAN_PILL).toStartWith(`*${steps}* steps`);
expect(r.out).not.toContain('could not be read');
});
test('products: `play products --dry` before, `catalog diff` after, with two products in the config', () => {
const cfg = join(dir, 'storebound.config.ts');
const src = readFileSync(cfg, 'utf8');
writeFileSync(cfg, src.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: {`));
try {
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);
// the pill's numbers are the region counts above, and the App Store's 175 storefronts
expect(Object.keys(ALPHA3)).toHaveLength(175);
expect(T.PRODUCTS_PILL.text).toContain('*175* App Store regions');
expect(T.PRODUCTS_PILL.text).toContain('*174* Google Play regions');
expect(out.join('\n')).toContain('0 mismatch(es), 0 warning(s), 0 price difference(s)');
} finally { writeFileSync(cfg, src); }
});
test('forms: the Data safety answer count of `storebound check`', () => {
expect(run(['check'], dir).out).toContain(`data safety: ${T.DECL_TOTAL} CSV rows`);
});
});
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('rejections: the risks line and three findings (severity, guideline, title, file:line)', () => {
expect(T.SCAN_CMD).toBe('storebound scan');
expect(before).toContain(T.RISKS_LINE);
const blocks = before.split(/\n\s+\d\. /).slice(1);
for (const f of T.FINDINGS) {
const b = blocks.find(x => x.startsWith(`${f.sev.padEnd(7)} ${f.guideline} · App Store · ${f.title}`));
expect(b, f.title).toBeDefined();
expect(b!).toContain(`at ${f.where}`);
// the plain reason is an annotation, short enough to read
expect(f.why.split(' ').length, f.why).toBeLessThanOrEqual(10);
}
});
test('rejections: the re-scan after the fixes (tests/fixtures/scan/teaser-fixed) is clean', () => {
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 store text and screenshots are the demo game\'s', () => {
test('copy: the hero title per language from examples/demo-game/store/shots/copy.json', () => {
const copy = JSON.parse(readFileSync(join(ROOT, 'examples/demo-game', T.COPY_FILE), 'utf8')) as Record<string, { hero: { title: string[] } }>;
for (const c of T.COPY) expect(c.title).toEqual(copy[c.lang]!.hero.title);
expect(T.SHOT_LANGS).toEqual(T.COPY.map(c => c.lang));
});
test('sizes: 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);
expect(T.SHOTS_PILL).toBe(`*7* sizes × *${T.COPY.length}* languages`);
expect(T.TODO.map(x => x.text)).toContain('7 screenshot sizes × 14 languages');
expect(T.TODO.map(x => x.text)).toContain('prices in 175 countries');
});
});
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('scenes are contiguous, 4-5 s each, 50-60 s in total', () => {
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(s.end - s.start, s.id).toBeGreaterThanOrEqual(4);
expect(s.end - s.start, s.id).toBeLessThanOrEqual(5);
}
expect(T.DURATION).toBe(T.SCENES.at(-1)!.end);
expect(T.DURATION).toBeGreaterThanOrEqual(50);
expect(T.DURATION).toBeLessThanOrEqual(60);
});
test('one caption per scene, fully on screen for at least 3 s, inside its scene', () => {
const c = T.captionTimes();
expect(c.map(x => x.id)).toEqual(T.SCENES.map(s => s.id));
for (const x of c) {
const s = T.SCENES.find(y => y.id === x.id)!;
expect(x.hold, x.text).toBeGreaterThanOrEqual(3);
expect(x.at).toBeGreaterThanOrEqual(s.start);
expect(x.to).toBeLessThanOrEqual(s.end);
}
});
test('the rejection mail holds at least 2 s before the first cut', () => {
expect(T.SCENES[0]!.end - T.MAIL.at - T.MAIL_IN).toBeGreaterThanOrEqual(2);
expect(T.MAIL.guideline).toStartWith('Guideline 2.1');
});
});
// The desktop sequence: four scenes of the real app (fixture mode), captured by media/teaser/desktop.ts. The frames are
// gitignored output; the file checks run wherever they were captured (render.ts captures them before every render).
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('four desktop scenes in a row before the submit card, the last one is v5\'s (timeline, then 0 blockers)', () => {
expect(T.DESK_SCENES).toEqual(['portfolio', 'charts', 'catalog', 'desktop']);
const ids = T.SCENES.map(s => s.id);
expect(ids.slice(-5)).toEqual([...T.DESK_SCENES, 'submit']);
expect(T.DESK.desktop!.groups.map(g => g.frame)).toEqual(['releases', 'ready']);
expect(T.CAPTIONS.find(c => c.id === 'desktop')!.lines.join(' ').replace(/\*/g, '')).toBe('Both stores, one view. Ready for review.');
for (const id of T.DESK_SCENES) expect(T.DESK[id]!.callouts.length, id).toBe(1 + (id === 'desktop' ? 1 : 0));
expect(T.DESK_SWEEPS).toEqual(['growth', 'health']);
});
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}'\\)|sweep\\('${f}'`));
for (const s of T.DESK_SWEEPS) expect(src, s).toContain(`sweep('${s}'`);
// the picture shows only files from out/desktop, never a drawn stand-in
const js = readFileSync(join(ROOT, 'media/teaser/teaser.js'), 'utf8');
expect(js).toContain('out/desktop/${x.g.frame}.jpg');
expect(js).toContain('out/desktop/${f.file}.jpg');
});
test.if(captured)('the captured frames are present: 1280x800 @2x JPEGs, the sweeps one frame per hovered day', () => {
const boxes = JSON.parse(readFileSync(join(DESK_DIR, 'boxes.json'), 'utf8')) as Record<string, unknown> & { source: string; sweeps: Record<string, { file: string; px: number; day: string }[]> };
expect(boxes.source).toContain('VITE_FIXTURES=1');
const files = [...T.DESK_FRAMES];
for (const s of T.DESK_SWEEPS) {
const f = boxes.sweeps[s]!;
expect(f.length, s).toBeGreaterThanOrEqual(8);
expect(new Set(f.map(x => x.day)).size, s).toBe(f.length);
for (let i = 1; i < f.length; i++) expect(f[i]!.px).toBeGreaterThan(f[i - 1]!.px);
files.push(...f.map(x => x.file));
}
const sizes = new Set<number>();
for (const f of files) {
const p = join(DESK_DIR, `${f}.jpg`);
expect(existsSync(p), p).toBe(true);
const buf = readFileSync(p);
expect(jpegSize(buf), f).toEqual({ w: 2560, h: 1600 });
sizes.add(buf.length);
}
// every sweep frame differs from the others (the tooltip moved): no copied frames
expect(sizes.size).toBeGreaterThan(files.length * 0.9);
for (const id of T.DESK_SCENES) for (const c of T.DESK[id]!.callouts) expect(boxes[c.target], c.target).toBeDefined();
});
});