storebound/tests/creative-assets.test.ts

264 lines
19 KiB
TypeScript

// App Store "Header and Search Results" creative assets: local size / alpha / video checks, the check / validate note,
// and `apple creative-assets` against the fake App Store Connect (Asset Library upload, select once processed (no
// approval wait: pitfall 76), replace on a changed file, nothing on the same file, rejected / failed / archived files,
// --add-for-review into the draft submission without submitting, --verify read-only).
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
import { md5 } from '../cli/apple/asc';
import { classify, creativeWaiting, MAX_FAILED, pickAsset, processingTiming, REF, run, selectable, waitingText } from '../cli/apple/creative-assets';
import { ascClient } from '../cli/apple/asc';
import { appleRules } from '../cli/lib/validate-rules';
import { creativeFiles as filesOf } from '../cli/shots/creative';
import { checkCreativeImage, checkCreativeVideo, CREATIVE_NOTE, creativeFiles, creativeNote } from '../cli/shots/creative';
import { imageInfo } from '../cli/shots/spec';
import { upsertTiming } from '../cli/lib/upsert';
import { ascEnv, BASE, capture, project, restore } from './helpers/store-mock';
import { installStores, type Stores, uninstallStores, writes } from './helpers/fake-stores';
/** A PNG header (IHDR only: imageInfo reads no more), RGB or RGBA, plus `salt` bytes so files differ. */
function png(w: number, h: number, alpha = false, salt = 0): Uint8Array {
const b = new Uint8Array(33 + 1);
b.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52]);
const dv = new DataView(b.buffer);
dv.setUint32(16, w); dv.setUint32(20, h); b[24] = 8; b[25] = alpha ? 6 : 2;
b[33] = salt;
return b;
}
describe('creative asset checks', () => {
test('header: exactly 3840x1646, no alpha', () => {
expect(checkCreativeImage(imageInfo(png(3840, 1646)), 'header')).toEqual([]);
expect(checkCreativeImage(imageInfo(png(3840, 1645)), 'header')[0]!.code).toBe('size');
expect(checkCreativeImage(imageInfo(png(3840, 1646, true)), 'header').map(i => i.code)).toEqual(['alpha']);
});
test('search: 3:2 from 1920x1280 to 3840x2560', () => {
for (const [w, h] of [[1920, 1280], [3000, 2000], [3840, 2560]]) expect(checkCreativeImage(imageInfo(png(w!, h!)), 'search')).toEqual([]);
for (const [w, h] of [[1800, 1200], [3900, 2600], [2000, 1280]]) expect(checkCreativeImage(imageInfo(png(w!, h!)), 'search')[0]!.code).toBe('size');
});
test('universal: one 5244x2950 PNG for either slot', () => {
expect(checkCreativeImage(imageInfo(png(5244, 2950)), 'header', true)).toEqual([]);
expect(checkCreativeImage(imageInfo(png(5244, 2950)), 'search', true)).toEqual([]);
expect(checkCreativeImage(imageInfo(png(3840, 2160)), 'search', true)[0]!.code).toBe('size');
});
test('video: length, fps, container, aspect; ffprobe missing is a skipped-check warning', () => {
const v = (w: number, h: number, fps: number, duration: number, audio = false) => ({ bytes: 1, duration, video: { codec: 'h264', w, h, fps }, ...(audio ? { audio: { codec: 'aac', channels: 2, sampleRate: 48000 } } : {}) });
expect(checkCreativeVideo('header.mov', v(3840, 1646, 30, 10), 'header')).toEqual([]);
expect(checkCreativeVideo('search.mp4', v(1920, 1280, 60, 30), 'search')).toEqual([]);
expect(checkCreativeVideo('search.mp4', v(1920, 1280, 25, 4), 'search').map(i => i.msg).join()).toMatch(/5-30 s.*30 or 60 fps/);
expect(checkCreativeVideo('header.webm', v(3840, 1646, 30, 10), 'header')[0]!.msg).toContain('.mov');
expect(checkCreativeVideo('header.mov', v(1920, 1080, 30, 10), 'header')[0]!.msg).toContain('aspect');
expect(checkCreativeVideo('search.mp4', v(1920, 1280, 30, 10, true), 'search')[0]!.level).toBe('warn');
const skipped = checkCreativeVideo('header.mov', null, 'header');
expect(skipped).toHaveLength(1);
expect(skipped[0]!.msg).toContain('not checked (ffprobe missing');
});
test('files: own header / search first, the universal image fills the rest', () => {
const list = (d: string) => (d.endsWith('en/creative') ? ['header.png', 'notes.txt'] : d.endsWith('creative') ? ['universal.png'] : []);
const fs = creativeFiles('/r', 'en', list);
expect(fs.map(f => [f.placement, f.file, f.universal])).toEqual([['header', '/r/en/creative/header.png', false], ['search', '/r/creative/universal.png', true]]);
});
test('files: a shared <renders>/creative/header.png fills every language without its own', () => {
const list = (d: string) => (d === '/r/creative' ? ['header.png', 'search.mp4', 'universal.png'] : d === '/r/tr/creative' ? ['search.png'] : []);
expect(creativeFiles('/r', 'en', list).map(f => [f.placement, f.file, f.kind, f.universal])).toEqual([['header', '/r/creative/header.png', 'image', false], ['search', '/r/creative/search.mp4', 'video', false]]);
expect(creativeFiles('/r', 'tr', list).map(f => f.file)).toEqual(['/r/creative/header.png', '/r/tr/creative/search.png']);
});
test('info note: an App Store app without any asset; none for Play-only or with files', () => {
const langs = [{ id: 'en', apple: 'en-US' }];
expect(creativeNote({ apple: {}, languages: langs }, '/r', () => [])).toBe(CREATIVE_NOTE);
expect(CREATIVE_NOTE).toContain('optional; without it the App Store uses in-app events, app previews and screenshots');
expect(CREATIVE_NOTE).toContain('feature graphic');
expect(creativeNote({ languages: langs }, '/r', () => [])).toBeNull();
expect(creativeNote({ apple: {}, languages: langs }, '/r', d => (d.endsWith('creative') ? ['search.png'] : []))).toBeNull();
});
});
describe('asset states (pitfall 76: Prepare for Submission is selectable)', () => {
test('classify and selectable', () => {
expect(['AWAITING_UPLOAD', 'UPLOAD_COMPLETE', undefined].map(classify)).toEqual(['processing', 'processing', 'processing']);
expect(selectable(classify('PREPARE_FOR_SUBMISSION'))).toBe(true);
expect(selectable(classify('APPROVED'))).toBe(true);
expect(selectable(classify('IN_REVIEW'))).toBe(true);
for (const s of ['FAILED', 'REJECTED', 'ARCHIVED', 'UPLOAD_COMPLETE']) expect(selectable(classify(s))).toBe(false);
});
const a = (id: string, sum: string, state: string, stateDetails?: unknown[]) => ({ id, type: 'appAssetLibraryImages', attributes: { referenceName: REF(sum), state, stateDetails } });
const S = 'a'.repeat(32);
test('pickAsset: reviewed beats unreviewed, archived is unarchived, rejected and repeated failures refuse', () => {
expect(pickAsset([a('1', S, 'PREPARE_FOR_SUBMISSION'), a('2', S, 'APPROVED')], S).asset!.id).toBe('2');
expect(pickAsset([a('1', S, 'ARCHIVED')], S)).toMatchObject({ unarchive: true });
expect(pickAsset([a('1', S, 'REJECTED', [{ code: 'GUIDELINE_2_3' }])], S).refuse).toContain('GUIDELINE_2_3');
expect(pickAsset([a('1', S, 'FAILED')], S)).toEqual({});
expect(pickAsset(Array.from({ length: MAX_FAILED }, (_, i) => a(String(i), S, 'FAILED')), S).refuse).toContain('failed processing');
expect(pickAsset([a('1', 'b'.repeat(32), 'APPROVED')], S)).toEqual({});
});
});
describe('apple creative-assets against the fake App Store Connect', () => {
let s: Stores;
beforeAll(ascEnv);
afterAll(restore);
beforeEach(() => { upsertTiming.waitMs = 0; processingTiming.timeoutMs = 0; processingTiming.pollMs = 0; s = installStores(); });
afterEach(uninstallStores);
const APP = '6400000101';
const CFG = { ...BASE, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }] };
const setup = (files: Record<string, Uint8Array>) => {
s.asc.put({ type: 'appAssetLibraries', id: 'lib-1', attributes: {}, rels: { app: APP } });
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
s.asc.put({ type: 'appStoreVersionLocalizations', id: 'loc-en', attributes: { locale: 'en-US' }, rels: { appStoreVersion: 'ver-1' } });
return project(CFG, files);
};
const go = async (p: ReturnType<typeof project>, argv: string[] = [], exit = 0) => {
const a = s.calls.length;
const r = await capture(() => run(p.ctx(argv)));
if ((r.exit ?? 0) !== exit) throw new Error(`exit ${r.exit}: ${r.out}\n${r.err}`);
return { out: r.out + r.err, w: writes(s.calls.slice(a)).map(c => `${c.method} ${decodeURIComponent(c.path).replace(/\?.*/, '').replace(/\/v1\/(\w+)\/[^/]+$/, '/v1/$1/{id}')}`).filter(x => x.includes(' /v1/')) };
};
const lib = () => [...s.asc.all('appAssetLibraryImages'), ...s.asc.all('appAssetLibraryVideos')];
const files = (h = 1, q = 2) => ({ 'store/renders/en/creative/header.png': png(3840, 1646, false, h), 'store/renders/en/creative/search.png': png(1920, 1280, false, q) });
test('dry run, upload, wait for approval per locale and placement, a re-run places them; same file = nothing; changed file = replace once approved', async () => {
const p = setup(files());
const dry = await go(p, ['--dry']);
expect(dry.w).toEqual([]);
expect(dry.out).toContain('would upload header.png');
const first = await go(p);
expect(first.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
expect(lib().map(x => x.attributes.referenceName)).toContain(REF(md5(files()['store/renders/en/creative/header.png'])));
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0);
expect(first.out).toMatch(/en-US PRODUCT_PAGE_HEADER_ASSET: header\.png (uploaded \d{4}-\d\d-\d\d, )?waiting for Apple's approval \(PREPARE_FOR_SUBMISSION\); run `storebound apple creative-assets` again later/);
expect(first.out).toContain('2 waiting for Apple\'s approval');
expect(first.out).toContain('--add-for-review');
expect((await go(p)).w).toEqual([]); // still waiting: nothing sent again
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(p)).w).toEqual(['POST /v1/appAssetLibraryPlacements', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements').map(x => x.attributes.placementType).sort()).toEqual(['APP_STORE_SEARCH_RESULTS_ASSET', 'PRODUCT_PAGE_HEADER_ASSET']);
expect((await go(p)).w).toEqual([]);
const q = project(CFG, files(9));
expect((await go(q)).w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(2); // the approved old one stays shown meanwhile
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(q)).w).toEqual(['DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(2);
});
test('processing that outlasts the bounded wait: not selected, a re-run once approved selects without a second upload', async () => {
const f = files();
const p = setup(f);
for (const [k, id] of [['header', 'img-h'], ['search', 'img-s']] as const)
s.asc.put({ type: 'appAssetLibraryImages', id, attributes: { category: 'CREATIVE_ASSETS', referenceName: REF(md5(f[`store/renders/en/creative/${k}.png`])), state: 'UPLOAD_COMPLETE', fileName: `${k}.png` }, rels: { assetLibrary: 'lib-1' } });
const r1 = await go(p);
expect(r1.w).toEqual([]);
expect(r1.out).toContain('still UPLOAD_COMPLETE');
expect(r1.out).toContain('2 still processing');
for (const x of lib()) x.attributes.state = 'PREPARE_FOR_SUBMISSION';
expect((await go(p)).w).toEqual([]);
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(p)).w).toEqual(['POST /v1/appAssetLibraryPlacements', 'POST /v1/appAssetLibraryPlacements']);
});
test('a rejected file is not uploaded again; an archived one is unarchived, not re-uploaded', async () => {
const f = files();
const p = setup(f);
s.asc.put({ type: 'appAssetLibraryImages', id: 'img-rej', attributes: { category: 'CREATIVE_ASSETS', referenceName: REF(md5(f['store/renders/en/creative/header.png'])), state: 'REJECTED', fileName: 'header.png' }, rels: { assetLibrary: 'lib-1' } });
s.asc.put({ type: 'appAssetLibraryImages', id: 'img-arc', attributes: { category: 'CREATIVE_ASSETS', referenceName: REF(md5(f['store/renders/en/creative/search.png'])), state: 'ARCHIVED', fileName: 'search.png' }, rels: { assetLibrary: 'lib-1' } });
const r = await go(p, [], 1);
expect(r.out).toContain('rejected by App Review');
expect(r.w).toEqual(['PATCH /v1/appAssetLibraryImages/{id}']);
expect(lib().find(x => x.id === 'img-arc')!.attributes.state).not.toBe('ARCHIVED');
});
test('--add-for-review: unreviewed assets go into the draft once, nothing is submitted', async () => {
const p = setup(files());
await go(p);
const dry = await go(p, ['--add-for-review', '--dry']);
expect(dry.w).toEqual([]);
const add = await go(p, ['--add-for-review']);
expect(add.w).toEqual(['POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems']);
expect(add.out).toContain('nothing was submitted');
expect(add.w.some(x => x.startsWith('PATCH /v1/reviewSubmissions'))).toBe(false);
expect((await go(p, ['--add-for-review'])).w).toEqual([]);
});
test('--verify is read-only and fails on a slot that differs', async () => {
const p = setup(files());
const before = await go(p, ['--verify'], 1);
expect(before.w).toEqual([]);
expect(before.out).toContain('shows nothing, wanted header.png');
await go(p);
const wait = await go(p, ['--verify']);
expect(wait.w).toEqual([]);
expect(wait.out).toMatch(/waiting en-US PRODUCT_PAGE_HEADER_ASSET: header\.png .*waiting for Apple's approval/);
expect(wait.out).toContain("2 waiting for Apple's approval");
for (const x of lib()) x.attributes.state = 'APPROVED';
await go(p);
const ok = await go(p, ['--verify']);
expect(ok.w).toEqual([]);
expect(ok.out).toContain('every slot shows the local file');
});
test('live version only (no editable one): upload, add for review alone, select once approved', async () => {
const p = setup(files());
s.asc.all('appStoreVersions')[0]!.attributes.appVersionState = 'READY_FOR_DISTRIBUTION';
const up = await go(p, ['--add-for-review']);
expect(up.out).toContain('using the live version 1.0.0');
expect(up.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}',
'POST /v1/reviewSubmissions', 'POST /v1/reviewSubmissionItems', 'POST /v1/reviewSubmissionItems']);
expect(up.out).toContain("2 waiting for Apple's approval");
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0);
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(p)).w).toEqual(['POST /v1/appAssetLibraryPlacements', 'POST /v1/appAssetLibraryPlacements']);
expect((await go(p, ['--verify'])).out).toContain('every slot shows the local file');
});
test('live version refuses a placement: clear message with the console deep link, live page unchanged, no blind retry', async () => {
const p = setup(files());
s.asc.all('appStoreVersions')[0]!.attributes.appVersionState = 'READY_FOR_DISTRIBUTION';
await go(p);
for (const x of lib()) x.attributes.state = 'APPROVED';
s.faults.push({ method: 'POST', match: /appAssetLibraryPlacements/, when: 'before', status: 409, code: 'STATE_ERROR', detail: 'version not editable', times: 5 });
const r = await go(p, [], 1);
expect(r.out).toContain('refused to select header.png on the live version 1.0.0 (409)');
expect(r.out).toContain('https://appstoreconnect.apple.com/apps/');
expect(r.out).toContain('not tried (App Store Connect refused a placement on the live version above');
expect(r.w).toEqual(['POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0);
});
test('one shared file for many locales: the dry run plans one upload, the run uploads once and places it per locale', async () => {
const two = { ...CFG, languages: [{ id: 'en', apple: 'en-US', play: 'en-US' }, { id: 'tr', apple: 'tr', play: 'tr-TR' }] };
s.asc.put({ type: 'appAssetLibraries', id: 'lib-1', attributes: {}, rels: { app: APP } });
s.asc.put({ type: 'appStoreVersions', id: 'ver-1', attributes: { versionString: '1.0.0', appVersionState: 'PREPARE_FOR_SUBMISSION', platform: 'IOS' }, rels: { app: APP } });
for (const l of ['en-US', 'tr']) s.asc.put({ type: 'appStoreVersionLocalizations', id: `loc-${l}`, attributes: { locale: l }, rels: { appStoreVersion: 'ver-1' } });
const p = project(two, { 'store/renders/creative/header.png': png(3840, 1646, false, 4) });
const dry = await go(p, ['--dry']);
expect(dry.out).toContain('1 file(s) to upload (used by 2 slots)');
const r = await go(p);
expect(r.w.filter(x => x === 'POST /v1/appAssetLibraryImages')).toHaveLength(1);
for (const x of lib()) x.attributes.state = 'APPROVED';
expect((await go(p)).w.filter(x => x === 'POST /v1/appAssetLibraryPlacements')).toHaveLength(2);
});
test('validate / report: a file waiting for approval is named per locale and placement', async () => {
const p = setup(files());
await go(p);
const c = await ascClient(p.ctx([]).loaded.root, 'com.example.notes', APP);
const local = new Map([['en-US', filesOf(`${p.ctx([]).loaded.root}/store/renders`, 'en')]]);
const w = await creativeWaiting(c, APP, local);
expect(w.map(x => [x.locale, x.placement, x.file])).toEqual([['en-US', 'PRODUCT_PAGE_HEADER_ASSET', 'header.png'], ['en-US', 'APP_STORE_SEARCH_RESULTS_ASSET', 'search.png']]);
expect(waitingText({ attributes: { state: 'IN_REVIEW', createdDate: '2026-10-19T10:00:00Z' } })).toBe("uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later");
const f = appleRules({ version: null, build: null, expectedLocales: [], locales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true,
iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, creativeWaiting: w }).filter(x => x.code === 'creative-waiting');
expect(f).toHaveLength(2);
expect(f[0]!.msg).toContain("en-US PRODUCT_PAGE_HEADER_ASSET: header.png");
for (const x of lib()) x.attributes.state = 'APPROVED';
expect(await creativeWaiting(c, APP, local)).toEqual([]);
});
test('a bad file stops the run before any call', async () => {
const p = setup({ 'store/renders/en/creative/header.png': png(3840, 1600, true) });
const a = s.calls.length;
await expect(capture(() => run(p.ctx([])))).rejects.toThrow('creative asset problems');
expect(s.calls.length).toBe(a);
});
});