apple creative-assets: App Store Header and Search Results (PRODUCT_PAGE_HEADER_ASSET, APP_STORE_SEARCH_RESULTS_ASSET) through the App Asset Library: local size / alpha / video checks, upload once by MD5, select only approved assets, replace on a changed file; check / validate note, MCP tool, gallery and desktop Art row, docs, pitfall 75
This commit is contained in:
parent
14ffe3545a
commit
e5369090fa
17 changed files with 486 additions and 16 deletions
126
tests/creative-assets.test.ts
Normal file
126
tests/creative-assets.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
// 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, approval wait, select, replace
|
||||
// on a changed file, nothing on the same file).
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { md5 } from '../cli/apple/asc';
|
||||
import { REF, run } from '../cli/apple/creative-assets';
|
||||
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('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('apple creative-assets against the fake App Store Connect', () => {
|
||||
let s: Stores;
|
||||
beforeAll(ascEnv);
|
||||
afterAll(restore);
|
||||
beforeEach(() => { upsertTiming.waitMs = 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[] = []) => {
|
||||
const a = s.calls.length;
|
||||
const r = await capture(() => run(p.ctx(argv)));
|
||||
if (r.exit) throw new Error(`exit ${r.exit}: ${r.out}\n${r.err}`);
|
||||
return { out: r.out, 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 approve = () => { for (const t of ['appAssetLibraryImages', 'appAssetLibraryVideos']) for (const x of s.asc.all(t)) x.attributes.state = 'APPROVED'; };
|
||||
|
||||
test('dry run, upload, wait for approval, select, same file = nothing, changed file = replace', async () => {
|
||||
const header = png(3840, 1646, false, 1);
|
||||
const p = setup({ 'store/renders/en/creative/header.png': header, 'store/renders/en/creative/search.png': png(1920, 1280, false, 2) });
|
||||
|
||||
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(first.out).toContain("waiting for Apple's approval");
|
||||
expect(s.asc.all('appAssetLibraryImages').map(x => x.attributes.referenceName)).toContain(REF(md5(header)));
|
||||
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(0);
|
||||
|
||||
const again = await go(p);
|
||||
expect(again.w).toEqual([]); // still waiting: no second upload
|
||||
|
||||
approve();
|
||||
const sel = await go(p);
|
||||
expect(sel.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([]);
|
||||
|
||||
// a changed header: uploaded, selected only after approval, then the old placement is replaced
|
||||
const q = project(CFG, { 'store/renders/en/creative/header.png': png(3840, 1646, false, 9), 'store/renders/en/creative/search.png': png(1920, 1280, false, 2) });
|
||||
expect((await go(q)).w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}']);
|
||||
approve();
|
||||
const rep = await go(q);
|
||||
expect(rep.w).toEqual(['DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
|
||||
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(2);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
|
@ -71,6 +71,10 @@ const LISTS: Record<string, { type: string; one?: boolean }> = {
|
|||
'subscriptions/winBackOffers': { type: 'winBackOffers' },
|
||||
'betaGroups/betaTesters': { type: 'betaTesters' },
|
||||
'apps/analyticsReportRequests': { type: 'analyticsReportRequests' },
|
||||
'apps/assetLibrary': { type: 'appAssetLibraries', one: true },
|
||||
'appAssetLibraries/images': { type: 'appAssetLibraryImages' },
|
||||
'appAssetLibraries/videos': { type: 'appAssetLibraryVideos' },
|
||||
'appStoreVersionLocalizations/placements': { type: 'appAssetLibraryPlacements' },
|
||||
};
|
||||
/** Stores refuse a second resource with the same key (409 ENTITY_ERROR ... already exists). */
|
||||
const UNIQUE: Record<string, (r: R) => string> = {
|
||||
|
|
@ -97,7 +101,9 @@ const UNIQUE: Record<string, (r: R) => string> = {
|
|||
winBackOffers: r => `${r.rels.subscription}|${r.attributes.offerId}`,
|
||||
betaTesters: r => String(r.attributes.email).toLowerCase(),
|
||||
};
|
||||
const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$/;
|
||||
const ASSETS = /Screenshots$|^appPreviews$|^appScreenshots$|^appAssetLibrary(Images|Videos)$/;
|
||||
/** App Asset Library assets: a state of their own (AWAITING_UPLOAD -> WAITING_FOR_REVIEW after the commit; a test sets APPROVED). */
|
||||
const LIBRARY = /^appAssetLibrary(Images|Videos)$/;
|
||||
/** Attributes a new resource of a type starts with. */
|
||||
const DEFAULTS: Record<string, Record<string, unknown>> = {
|
||||
inAppPurchases: { state: 'MISSING_METADATA' }, subscriptions: { state: 'MISSING_METADATA' }, reviewSubmissions: { state: 'READY_FOR_REVIEW' },
|
||||
|
|
@ -178,7 +184,7 @@ export class FakeAsc {
|
|||
return json({ errors: [{ status: '409', code: 'ENTITY_ERROR.ATTRIBUTE.INVALID.DUPLICATE', title: 'The provided entity includes an attribute with a value that has already been used', detail: 'already exists' }] }, 409);
|
||||
if (ASSETS.test(d.type) && d.attributes?.fileName) {
|
||||
main.attributes.uploadOperations = [{ method: 'PUT', url: `${UPLOAD_HOST}${main.id}`, offset: 0, length: Number(d.attributes.fileSize ?? 0), requestHeaders: [{ name: 'Content-Type', value: 'image/png' }] }];
|
||||
main.attributes.assetDeliveryState = { state: 'AWAITING_UPLOAD' };
|
||||
if (LIBRARY.test(d.type)) main.attributes.state = 'AWAITING_UPLOAD'; else main.attributes.assetDeliveryState = { state: 'AWAITING_UPLOAD' };
|
||||
}
|
||||
// a tester belongs to the app of its groups (GET /v1/betaTesters?filter[apps]=)
|
||||
if (d.type === 'betaTesters') { const g = [main.rels.betaGroups].flat()[0]; const app = g ? this.res.get(String(g))?.rels.app : undefined; if (app) main.rels.app = app; }
|
||||
|
|
@ -304,7 +310,8 @@ export class FakeAsc {
|
|||
rels: { [singular(self.type)]: self.id, ...Object.fromEntries(Object.entries(inc.relationships ?? {}).map(([k, v]) => [k, v.data.id])) } });
|
||||
if (attrs.uploaded === true && ASSETS.test(self.type)) {
|
||||
const { uploaded: _u, ...rest } = attrs;
|
||||
Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
|
||||
if (LIBRARY.test(self.type)) Object.assign(self.attributes, rest, { state: this.parts.includes(self.id) ? 'WAITING_FOR_REVIEW' : 'FAILED' });
|
||||
else Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
|
||||
delete self.attributes.uploadOperations;
|
||||
} else if (self.type === 'reviewSubmissions' && (attrs.submitted === true || attrs.canceled === true)) {
|
||||
// seen live (2026-10-12): a REJECTED item that is not marked resolved blocks every submit with this 409, and an
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue