// Store image rules (cli/shots/spec.ts) on tiny generated PNG / JPEG buffers, the renders scan, the 13" iPad wiring // (sizes, frame defaults, crop, ASC display types) and the local renders findings of `validate`. No browser, no store. 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 { DISPLAY, displaySets, IPAD_DISPLAY, requiredDisplayTypes } from '../cli/apple/screenshots'; import { type ShipConfig, validateConfig } from '../cli/lib/config'; import { type AppleSnapshot, appleRules, type PlaySnapshot, playRules, rendersFindings } from '../cli/lib/validate-rules'; import { rendersCheck } from '../cli/shots/check'; import { cropTopFor, validateCapture } from '../cli/shots/devices'; import { DEFAULT_IPAD, DEFAULT_PHONE, DEFAULT_TABLET, defaultSizes, frameDims, SIZES } from '../cli/shots/lib'; import { allIssues, requiredSets, scanRenders } from '../cli/shots/report'; import { checkCount, checkImage, type ImageInfo, IMAGE_SPECS, imageInfo } from '../cli/shots/spec'; import { jpeg, png } from './fixtures'; const codes = (i: { code: string }[]) => i.map(x => x.code); describe('imageInfo', () => { test('png rgb / rgba / tRNS / palette / 16 bit', () => { expect(imageInfo(png(1320, 2868))).toMatchObject({ format: 'png', w: 1320, h: 2868, alpha: false, palette: false, depth: 8 }); expect(imageInfo(png(10, 20, { colorType: 6 })).alpha).toBe(true); expect(imageInfo(png(10, 20, { colorType: 4 })).alpha).toBe(true); expect(imageInfo(png(10, 20, { trns: true })).alpha).toBe(true); expect(imageInfo(png(10, 20, { colorType: 3 })).palette).toBe(true); expect(imageInfo(png(10, 20, { depth: 16 })).depth).toBe(16); }); test('jpeg and unknown', () => { expect(imageInfo(jpeg(1080, 1920))).toMatchObject({ format: 'jpeg', w: 1080, h: 1920, alpha: false, depth: 8 }); expect(imageInfo(new Uint8Array([1, 2, 3, 4, 5])).format).toBe('unknown'); expect(imageInfo(jpeg(1, 1, 1000)).bytes).toBe(1000); }); }); describe('checkImage', () => { const ok = (b: Uint8Array, k: string) => expect(checkImage(imageInfo(b), k)).toEqual([]); test('apple sizes, portrait and landscape', () => { ok(png(1320, 2868), 'ios69'); ok(png(1290, 2796), 'ios69'); ok(png(2868, 1320), 'ios69'); ok(jpeg(1284, 2778), 'ios65'); ok(png(2064, 2752), 'ipad13'); ok(png(2048, 2732), 'ipad13'); ok(png(2752, 2064), 'ipad13'); const e = checkImage(imageInfo(png(1242, 2208)), 'ios69'); expect(codes(e)).toEqual(['size']); expect(e[0]!.msg).toContain('1320x2868'); expect(codes(checkImage(imageInfo(png(1600, 2480)), 'ipad13'))).toEqual(['size']); }); test('alpha is an error on both stores', () => { expect(codes(checkImage(imageInfo(png(1320, 2868, { colorType: 6 })), 'ios69'))).toEqual(['alpha']); expect(codes(checkImage(imageInfo(png(1080, 1920, { trns: true })), 'play'))).toEqual(['alpha']); }); test('play: any accepted screenshot size, palette / 16 bit PNG rejected', () => { ok(png(1080, 1920), 'play'); ok(jpeg(1440, 2560), 'tab10'); ok(png(1242, 2208), 'play'); expect(codes(checkImage(imageInfo(png(1000, 2100)), 'play'))).toEqual(['size']); // longer than 2:1 expect(codes(checkImage(imageInfo(png(300, 500)), 'play'))).toEqual(['size']); // under 320 expect(codes(checkImage(imageInfo(png(2200, 4000)), 'tab10'))).toEqual(['size']); // over 3840 expect(codes(checkImage(imageInfo(png(1080, 1920, { colorType: 3 })), 'play'))).toEqual(['format']); expect(codes(checkImage(imageInfo(png(1080, 1920, { depth: 16 })), 'play'))).toEqual(['format']); ok(png(1320, 2868, { depth: 16 }), 'ios69'); }); test('play 8 MB per screenshot, feature graphic exact', () => { const big = imageInfo(jpeg(1080, 1920, 8 * 1024 * 1024 + 1)); expect(codes(checkImage(big, 'play'))).toEqual(['bytes']); expect(checkImage({ ...big, bytes: 8 * 1024 * 1024 }, 'play')).toEqual([]); ok(jpeg(1024, 500), 'feature'); expect(codes(checkImage(imageInfo(png(1000, 500)), 'feature'))).toEqual(['size']); expect(codes(checkImage(imageInfo(png(500, 1024)), 'feature'))).toEqual(['size']); expect(codes(checkImage(imageInfo(png(1024, 500, { colorType: 6 })), 'feature'))).toEqual(['alpha']); const info: ImageInfo = { format: 'png', w: 1024, h: 500, alpha: false, palette: false, bytes: 16 * 1024 * 1024, depth: 8 }; expect(codes(checkImage(info, 'feature'))).toEqual(['bytes']); }); test('not an image, unknown size key', () => { expect(codes(checkImage(imageInfo(new Uint8Array(40)), 'ios69'))).toEqual(['format']); expect(checkImage(imageInfo(png(1, 1)), 'nope')[0]!.msg).toContain('unknown size key'); }); }); describe('checkCount', () => { test('per store', () => { expect(checkCount(0, 'ios69', true)[0]!.level).toBe('error'); expect(checkCount(0, 'ios65', false)).toEqual([]); expect(checkCount(10, 'ipad13', true)).toEqual([]); expect(checkCount(11, 'ipad13', true)[0]).toMatchObject({ level: 'error', code: 'count' }); expect(checkCount(1, 'play', true)[0]).toMatchObject({ level: 'error', code: 'count' }); expect(checkCount(9, 'play', true)[0]).toMatchObject({ level: 'warn', code: 'count' }); expect(checkCount(2, 'feature', true)[0]!.level).toBe('warn'); }); }); describe('13" iPad', () => { test('size, spec and ASC display type agree', () => { expect(SIZES.ipad13).toEqual({ w: 2064, h: 2752, device: 'tablet' }); expect(IMAGE_SPECS.ipad13!.sizes[0]).toEqual([2064, 2752]); for (const [k, t] of Object.entries({ ...DISPLAY, ...IPAD_DISPLAY })) { expect(IMAGE_SPECS[k]!.type).toBe(t); expect(IMAGE_SPECS[k]!.sizes[0]).toEqual([SIZES[k]!.w, SIZES[k]!.h]); } expect(IPAD_DISPLAY.ipad13).toBe('APP_IPAD_PRO_3GEN_129'); }); test('only with apple.ipad', () => { expect(displaySets({})).toEqual(DISPLAY); expect(displaySets(undefined)).toEqual(DISPLAY); expect(Object.keys(displaySets({ ipad: true }))).toEqual(['ios69', 'ios65', 'ipad13']); expect(requiredDisplayTypes({})).toEqual(['APP_IPHONE_61']); expect(requiredDisplayTypes({ ipad: true })).toEqual(['APP_IPHONE_61', 'APP_IPAD_PRO_3GEN_129']); expect(requiredDisplayTypes({ ipad: true, screenshotTypes: ['APP_IPHONE_65'] })).toEqual(['APP_IPHONE_65']); expect(defaultSizes({ apple: { bundleId: 'x' } })).not.toContain('ipad13'); expect(defaultSizes({ apple: { bundleId: 'x', ipad: true } })).toContain('ipad13'); expect(defaultSizes({ play: { packageName: 'x' } } as Pick)).not.toContain('ipad13'); }); test('tablet frames follow the 13" iPad with apple.ipad', () => { expect(frameDims({}).tablet).toEqual(DEFAULT_TABLET); expect(frameDims({ apple: { bundleId: 'x', ipad: true } }).tablet).toEqual(DEFAULT_IPAD); expect(DEFAULT_IPAD.w * DEFAULT_IPAD.dpr).toBe(2064); expect((DEFAULT_IPAD.h + DEFAULT_IPAD.bar) * DEFAULT_IPAD.dpr).toBe(2752); const custom = frameDims({ apple: { bundleId: 'x', ipad: true }, shots: { tablet: { w: 834, h: 1170 } } as ShipConfig['shots'] }); expect(custom.tablet).toEqual({ w: 834, h: 1170, dpr: 2, bar: 24 }); expect(frameDims({}).phone).toEqual(DEFAULT_PHONE); }); test('crop per device class', () => { expect(cropTopFor(undefined, 'phone', 2868, DEFAULT_PHONE)).toBe(162); expect(cropTopFor(undefined, 'tablet', 2752, DEFAULT_IPAD)).toBe(48); expect(cropTopFor(undefined, 'tablet', 2560, DEFAULT_TABLET)).toBe(80); expect(cropTopFor(100, 'tablet', 2752, DEFAULT_IPAD)).toBe(100); expect(cropTopFor({ tablet: 60 }, 'tablet', 2752, DEFAULT_IPAD)).toBe(60); expect(cropTopFor({ tablet: 60 }, 'phone', 2868, DEFAULT_PHONE)).toBe(162); const base = { source: 'folder' as const, shots: {} }; expect(validateCapture({ ...base, cropTop: { phone: 162, tablet: 48 } })).toEqual([]); expect(validateCapture({ ...base, cropTop: { tablet: -1 } }).join()).toContain('cropTop.tablet'); expect(validateCapture({ ...base, cropTop: { watch: 1 } as never }).join()).toContain('cropTop.watch'); const ios = { source: 'ios-sim' as const, ios: { device: 'iPhone 17 Pro Max', bundleId: 'com.example.game' }, shots: { a: {} } }; expect(validateCapture({ ...ios, ios: { ...ios.ios, warmupMs: 0 } })).toEqual([]); expect(validateCapture({ ...ios, ios: { ...ios.ios, warmupMs: -5 } }).join()).toContain('warmupMs'); }); }); describe('renders scan', () => { const config = { languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'de', play: 'de-DE' }], apple: { bundleId: 'com.example.game', ipad: true }, play: { packageName: 'com.example.game' }, } as unknown as ShipConfig; test('required sets per language', () => { expect(requiredSets(config)).toEqual({ ios69: true, ipad13: true, play: true, feature: true }); expect(requiredSets(config, 'de')).toEqual({ ios69: false, ipad13: false, play: true, feature: true }); expect(requiredSets({ ...config, apple: { bundleId: 'x' } } as ShipConfig, 'en').ipad13).toBe(false); }); test('wrong size, alpha, too many, too large, overflow, missing sets', () => { const dir = mkdtempSync(join(tmpdir(), 'sb-renders-')); const put = (rel: string, b: Uint8Array) => { mkdirSync(join(dir, rel, '..'), { recursive: true }); writeFileSync(join(dir, rel), b); }; try { for (let i = 1; i <= 11; i++) put(`en/ios69/${String(i).padStart(2, '0')}.png`, png(1320, 2868)); put('en/ios69/03.png', png(1320, 2868, { colorType: 6 })); put('en/ipad13/01.png', png(1600, 2480)); put('en/play/01.png', png(1080, 1920)); put('en/play/01.jpg', jpeg(1080, 1920, 8 * 1024 * 1024 + 10)); put('en/feature/1024x500.png', png(1024, 500, { colorType: 3 })); put('de/play/01.jpg', jpeg(1080, 1920)); put('de/play/02.jpg', jpeg(1080, 1920)); put('de/feature/1024x500.jpg', jpeg(1024, 500)); writeFileSync(join(dir, 'report.json'), JSON.stringify({ 'en/ios69/01.png': { overflow: ['title line 1 shrunk to 60 %'], fonts: { used: { Montserrat: 20 }, fallback: [], missing: [] } } })); const scan = scanRenders(dir, { langs: ['en', 'de'], required: lang => requiredSets(config, lang), probe: () => null }); const got = allIssues(scan).map(x => `${x.where} ${x.issue.level} ${x.issue.code}`).sort(); expect(got).toEqual([ 'en/feature/1024x500.png error format', 'en/ios69 error count', 'en/ios69/01.png warn overflow', 'en/ios69/03.png error alpha', 'en/ipad13/01.png error size', 'en/play error count', 'en/play/01.jpg error bytes', ].sort()); const local = rendersCheck(config, dir)!; expect(local.apple.issues.map(i => i.where).sort()).toEqual(['en/ios69', 'en/ios69/01.png', 'en/ios69/03.png', 'en/ipad13/01.png']); expect(local.play.issues.length).toBe(3); expect(local.apple.images).toBe(12); expect(rendersCheck(config, join(dir, 'nope'))).toBeNull(); const f = rendersFindings('apple', local.apple); expect(f.filter(x => x.level === 'blocker').length).toBe(3); expect(f.find(x => x.code === 'renders-overflow')!.level).toBe('warn'); expect(f.find(x => x.code === 'renders-alpha')!.fix).toContain('rgb24'); } finally { rmSync(dir, { recursive: true, force: true }); } }); test('clean renders: one ok finding, nothing without a folder', () => { expect(rendersFindings('play', { dir: 'r', images: 4, issues: [] })).toEqual([{ store: 'play', level: 'ok', code: 'renders', msg: expect.stringContaining('4 local store images') }]); expect(rendersFindings('play', undefined)).toEqual([]); expect(rendersFindings('play', { dir: 'r', images: 0, issues: [] })).toEqual([]); }); test('appleRules / playRules carry render blockers', () => { const apple = { version: { versionString: '1.0', state: 'PREPARE_FOR_SUBMISSION' }, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, renders: { dir: 'r', images: 1, issues: [{ where: 'en/ipad13/01.png', level: 'error', code: 'size', msg: '1600x2480' }] } } as AppleSnapshot; const a = appleRules(apple).find(x => x.code === 'renders-size')!; expect(a).toMatchObject({ store: 'apple', level: 'blocker' }); expect(a.msg).toContain('en/ipad13/01.png'); const play = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [], renders: { dir: 'r', images: 1, issues: [{ where: 'en/play/01.jpg', level: 'error', code: 'bytes', msg: '8.1 MB > 8 MB' }] } } as PlaySnapshot; expect(playRules(play).find(x => x.code === 'renders-bytes')).toMatchObject({ store: 'play', level: 'blocker' }); }); test('config accepts per-device cropTop', () => { const c = { app: { name: 'G', privacyPolicyUrl: 'https://example.com/p' }, languages: [{ id: 'en' }], shots: { copy: 'c.json', frames: 'f', out: 'o', scenes: [{ id: 'a', palette: 'fire', frames: [{ src: 'a.png' }] }], capture: { source: 'folder', cropTop: { tablet: 48 }, shots: {} } } } as unknown as ShipConfig; expect(validateConfig(c).filter(e => e.includes('cropTop'))).toEqual([]); }); });