apple creative-assets: select Header and Search Results assets once processed (Prepare for Submission), not once approved: Apple reviews an asset only in a review submission, so 0.8.13 waited forever; upload and select in one run with a bounded processing wait; --add-for-review puts unreviewed assets into the draft review submission (never submits); --verify reads every slot back read-only (MCP apple_creative_assets_verify); a rejected file or one that failed processing twice is not re-uploaded, an archived copy is unarchived; fake commits library assets to PREPARE_FOR_SUBMISSION; pitfall 76 rewritten; apple-name check test only looks at name hints

This commit is contained in:
CRTkafa 2026-10-19 05:42:53 +07:00
parent 3e4015b686
commit 183579449c
14 changed files with 378 additions and 95 deletions

View file

@ -54,7 +54,8 @@ describe('apple.name', () => {
expect((j.hints as string[]).join('\n')).toContain('already being used');
p = project({ ...NAMED, play: undefined }, files);
j = jsonOut((await capture(() => check(p.ctx(['--json'], ['json'])))).out);
expect(j.hints).toEqual([]);
// only the name hint is under test (the Duo and Header and Search Results notes are hints of their own)
expect((j.hints as string[]).filter(h => /App Store name|already being used/.test(h))).toEqual([]);
p = project({ ...PLAIN, app: { ...PLAIN.app, name: 'STAR DASH Stone Age to the Year 2100' }, play: undefined }, files);
const r = await capture(() => check(p.ctx(['--json'], ['json'])));
j = jsonOut(r.out);

View file

@ -1,9 +1,10 @@
// 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).
// 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 { REF, run } from '../cli/apple/creative-assets';
import { classify, MAX_FAILED, pickAsset, processingTiming, REF, run, selectable } 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';
@ -62,11 +63,31 @@ describe('creative asset checks', () => {
});
});
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; s = installStores(); });
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' }] };
@ -76,47 +97,84 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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 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) 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/')) };
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 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 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 + select in one run (no approval wait), same file = nothing, changed file = replace', 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(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(first.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements',
'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(lib().map(x => x.attributes.referenceName)).toContain(REF(md5(files()['store/renders/en/creative/header.png'])));
expect(s.asc.all('appAssetLibraryPlacements').map(x => x.attributes.placementType).sort()).toEqual(['APP_STORE_SEARCH_RESULTS_ASSET', 'PRODUCT_PAGE_HEADER_ASSET']);
expect(first.out).toContain('--add-for-review');
expect(first.out).not.toContain("waiting for Apple's approval");
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 q = project(CFG, files(9));
const rep = await go(q);
expect(rep.w).toEqual(['DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(rep.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements')).toHaveLength(2);
});
test('processing that outlasts the bounded wait: not selected, re-run 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(['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}', 'POST /v1/appAssetLibraryPlacements']);
expect(s.asc.all('appAssetLibraryPlacements').map(x => x.attributes.placementType)).toEqual(['APP_STORE_SEARCH_RESULTS_ASSET']);
});
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 ok = await go(p, ['--verify']);
expect(ok.w).toEqual([]);
expect(ok.out).toContain('every slot shows the local file');
});
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;

View file

@ -102,7 +102,8 @@ const UNIQUE: Record<string, (r: R) => string> = {
betaTesters: r => String(r.attributes.email).toLowerCase(),
};
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). */
/** App Asset Library assets: a state of their own (AWAITING_UPLOAD -> PREPARE_FOR_SUBMISSION after the commit, as Apple's
* "App Store asset statuses" describe it: processed, not in a submission yet; a test sets the review states). */
const LIBRARY = /^appAssetLibrary(Images|Videos)$/;
/** Attributes a new resource of a type starts with. */
const DEFAULTS: Record<string, Record<string, unknown>> = {
@ -310,9 +311,14 @@ 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;
if (LIBRARY.test(self.type)) Object.assign(self.attributes, rest, { state: this.parts.includes(self.id) ? 'WAITING_FOR_REVIEW' : 'FAILED' });
if (LIBRARY.test(self.type)) Object.assign(self.attributes, rest, { state: this.parts.includes(self.id) ? 'PREPARE_FOR_SUBMISSION' : 'FAILED' });
else Object.assign(self.attributes, rest, { assetDeliveryState: { state: this.parts.includes(self.id) ? 'COMPLETE' : 'FAILED' } });
delete self.attributes.uploadOperations;
} else if (LIBRARY.test(self.type) && typeof attrs.archived === 'boolean') {
// archive / unarchive (model: an unarchived asset is back in Prepare for Submission)
const { archived, ...rest } = attrs;
Object.assign(self.attributes, rest, { state: archived ? 'ARCHIVED' : 'PREPARE_FOR_SUBMISSION' });
delete self.attributes.archived;
} 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
// empty draft cannot be canceled