apple creative-assets: place Header and Search Results assets once Apple approved them (the version page offers only approved assets); waiting files are reported per locale and placement with their upload date and the re-run command, in the run, --verify, validate and the report; iPhone Duo: the published API spec still has no Duo type, the note says the console takes Duo and the API does not yet

This commit is contained in:
CRTkafa 2026-10-20 03:04:14 +07:00
parent 47dfd372fe
commit c2f34c62f3
13 changed files with 163 additions and 78 deletions

View file

@ -4,7 +4,10 @@
// --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, MAX_FAILED, pickAsset, processingTiming, REF, run, selectable } from '../cli/apple/creative-assets';
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';
@ -111,29 +114,35 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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 () => {
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/appAssetLibraryPlacements',
'POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'POST /v1/appAssetLibraryPlacements']);
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').map(x => x.attributes.placementType).sort()).toEqual(['APP_STORE_SEARCH_RESULTS_ASSET', 'PRODUCT_PAGE_HEADER_ASSET']);
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(first.out).not.toContain("waiting for Apple's approval");
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));
const rep = await go(q);
expect(rep.w).toEqual(['POST /v1/appAssetLibraryImages', 'PATCH /v1/appAssetLibraryImages/{id}', 'DELETE /v1/appAssetLibraryPlacements/{id}', 'POST /v1/appAssetLibraryPlacements']);
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, re-run selects without a second upload', async () => {
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)
@ -143,6 +152,8 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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']);
});
@ -153,8 +164,8 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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']);
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 () => {
@ -175,6 +186,12 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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');
@ -187,7 +204,7 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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 App Review (live version)');
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']);
@ -218,7 +235,24 @@ describe('apple creative-assets against the fake App Store Connect', () => {
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);
expect(r.w.filter(x => x === 'POST /v1/appAssetLibraryPlacements')).toHaveLength(2);
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 () => {

View file

@ -106,6 +106,7 @@ describe('iPhone Duo (foldable) sizes', () => {
expect(n.level).toBe('info');
expect(n.msg).toContain('optional now, required from April 2027 for iOS 27.1 SDK builds');
expect(n.msg).toContain('tablet screenshots');
expect(n.msg).toContain('the App Store Connect console takes Duo screenshots and previews, the API does not yet');
expect(iphoneDuoNote({ apple: true, duo: false, today: after })!.level).toBe('warn');
const base = { version: null, build: null, locales: [], expectedLocales: [], requiredDisplayTypes: [], reviewDetail: null, ageRating: null, priceSchedule: true, availability: true, iaps: [], expectedProducts: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 } };
const f = appleRules({ ...base, today: before } as unknown as Parameters<typeof appleRules>[0]).find(x => x.code === 'screenshots-duo')!;