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:
parent
47dfd372fe
commit
c2f34c62f3
13 changed files with 163 additions and 78 deletions
30
CHANGELOG.md
30
CHANGELOG.md
|
|
@ -2,20 +2,22 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **Header and Search Results: select once processed, then add for review.** `storebound apple creative-assets` now
|
||||
selects an asset for the product page header or search results slot as soon as it is processed ("Prepare for
|
||||
Submission"), as the console's Browse Assets does: Apple reviews an asset only once it is in a review submission, so
|
||||
0.8.13's "select once approved" never finished. Upload and select happen in one run (processing wait up to 2 min with
|
||||
a heartbeat). `--add-for-review` adds the selected, unreviewed assets to the app's draft review submission (never
|
||||
submits; Submit for Review stays the owner's); `--verify` (MCP `apple_creative_assets_verify`) reads every slot back
|
||||
read-only and fails when one differs from the local file. A file App Review rejected, or one that failed processing
|
||||
twice, is not uploaded again; an archived copy of the same file is unarchived instead of uploaded. A live app without
|
||||
an editable version uses the live version: approved assets are selected there, new ones are uploaded and wait for a
|
||||
review of their own. Art without words needs no copy per locale: `<renders>/creative/header.png` / `search.*`
|
||||
fill every language without its own file, uploaded once and placed per locale (the dry run counts files, not slots).
|
||||
If App Store Connect refuses a placement on the live version, the run says so, leaves the live page unchanged (the
|
||||
new placement goes first, the old one is removed only after Apple accepted it), links the live version page and does
|
||||
not retry the other live slots. Pitfall 76 rewritten.
|
||||
- **Header and Search Results: upload, add for review, place once approved.** `storebound apple creative-assets`
|
||||
uploads each file once into the Asset Library (MD5 upsert: a rejected file or one that failed processing twice is
|
||||
not uploaded again, an archived copy is unarchived), and places it on the version as soon as Apple approved it: the
|
||||
version page's slots offer only approved assets. Until then every locale and placement says "uploaded <date>,
|
||||
waiting for Apple's approval; run `storebound apple creative-assets` again later", and a re-run places it (an
|
||||
approved old asset stays shown meanwhile). `--add-for-review` adds the unreviewed assets to the app's draft review
|
||||
submission (never submits; Submit for Review stays the owner's); `--verify` (MCP `apple_creative_assets_verify`)
|
||||
reads every slot back read-only with the same waiting state, and `validate` / the report list waiting files. A live
|
||||
app without an editable version uses the live version; if App Store Connect refuses a placement there, the run says
|
||||
so, leaves the live page unchanged (the new placement goes first, the old one is removed only after Apple accepted
|
||||
it), links the live version page and does not retry the other live slots. Art without words needs no copy per
|
||||
locale: `<renders>/creative/header.png` / `search.*` fill every language without its own file, uploaded once and
|
||||
placed per locale (the dry run counts files, not slots). Pitfall 76 rewritten.
|
||||
- **iPhone Duo.** Apple's published App Store Connect API spec (re-checked: still the 2026-10-06 file) has no Duo
|
||||
screenshot or preview type, so `apple screenshots` keeps skipping the Duo sets with the console link; the check /
|
||||
validate note now says the console takes Duo and the API does not yet.
|
||||
|
||||
- **macOS (tvOS, visionOS) versions are no longer invisible.** `apple status`, `listing`, `listing pull`, `resubmit`,
|
||||
`submit`, `release` and `screenshots` take `--platform ios|macos|tvos|visionos` (MCP tools and the desktop's owner
|
||||
|
|
|
|||
|
|
@ -10,14 +10,14 @@
|
|||
// "Submit assets from Asset Library", checked 19 Oct 2026):
|
||||
// 1. a file is uploaded once into the app's Asset Library (POST appAssetLibraryImages / Videos, category
|
||||
// CREATIVE_ASSETS, its MD5 in referenceName "storebound <md5>" because library assets carry no checksum);
|
||||
// 2. after processing it is "Prepare for Submission", and in that state it can already be selected for a slot
|
||||
// (POST appAssetLibraryPlacements, placementType PRODUCT_PAGE_HEADER_ASSET / APP_STORE_SEARCH_RESULTS_ASSET, the
|
||||
// version localization). Approval is NOT needed first: an asset is only reviewed once it is in a review
|
||||
// submission, so "select only approved assets" waits forever (pitfall 76);
|
||||
// 3. App Review: the asset is a review submission item of its own (reviewSubmissionItems appAssetLibraryImage /
|
||||
// appAssetLibraryVideo; console "Add for Review"). Before the app's first approved version it goes in the same
|
||||
// submission as that version; afterwards it can go alone. --add-for-review adds it to the draft submission;
|
||||
// pressing Submit for Review stays the owner's.
|
||||
// 2. after processing it is "Prepare for Submission"; App Review reviews it as a review submission item of its own
|
||||
// (reviewSubmissionItems appAssetLibraryImage / appAssetLibraryVideo; console "Add for Review"): before the app's
|
||||
// first approved version in the same submission as that version, afterwards alone. --add-for-review adds it to
|
||||
// the draft submission; pressing Submit for Review stays the owner's;
|
||||
// 3. the version page's slots offer only approved assets ("Browse approved assets", seen in a real release, 20 Oct
|
||||
// 2026): an APPROVED asset is selected (POST appAssetLibraryPlacements, placementType PRODUCT_PAGE_HEADER_ASSET /
|
||||
// APP_STORE_SEARCH_RESULTS_ASSET, the version localization); any other one is reported per locale and placement
|
||||
// as "uploaded <date>, waiting for Apple's approval", and a later run places it (pitfall 76).
|
||||
// Upsert: a slot that already shows the same MD5 is left alone; an asset with the same MD5 in the library is reused
|
||||
// (an archived one is unarchived), never uploaded twice; a REJECTED file is not uploaded again (change the file); a
|
||||
// changed file replaces the slot (the old placement is removed, then the new one is created). Read back at the end.
|
||||
|
|
@ -33,7 +33,7 @@ import * as rt from '../lib/rt';
|
|||
import { ffprobe } from '../shots/report';
|
||||
import { imageInfo } from '../shots/spec';
|
||||
import { checkCreativeImage, checkCreativeVideo, type CreativeFile, creativeFiles, PLACEMENT_TYPE, type Placement } from '../shots/creative';
|
||||
import { ascClient, md5, type J } from './asc';
|
||||
import { type Asc, ascClient, md5, type J } from './asc';
|
||||
import { AmbiguousWriteError } from '../lib/upsert';
|
||||
import { addItem, ensureDraft, OtherSubmissionError } from './submission';
|
||||
|
||||
|
|
@ -83,6 +83,29 @@ export function pickAsset(assets: J[], sum: string): { asset?: J; unarchive?: bo
|
|||
return {};
|
||||
}
|
||||
|
||||
/** "uploaded 2026-10-19, waiting for Apple's approval (IN_REVIEW); run `storebound apple creative-assets` again later".
|
||||
* The version page's header / search slots offer only approved assets ("Browse approved assets"), so an asset that
|
||||
* is not APPROVED waits; a later run places it. Pure. */
|
||||
export function waitingText(a: J): string {
|
||||
const d = String(a?.attributes?.createdDate ?? '').slice(0, 10);
|
||||
return `${d ? `uploaded ${d}, ` : ''}waiting for Apple's approval (${a?.attributes?.state ?? '?'}); run \`storebound apple creative-assets\` again later`;
|
||||
}
|
||||
|
||||
/** Local creative files whose library asset (same MD5) is processed but not APPROVED yet, per locale and placement:
|
||||
* for validate and the report (one library read, no placements). */
|
||||
export async function creativeWaiting(c: Asc, appId: string, local: Map<string, CreativeFile[]>): Promise<{ locale: string; placement: string; file: string; text: string }[]> {
|
||||
const lib = (await c.maybe(`/v1/apps/${appId}/assetLibrary`))?.data as J | undefined;
|
||||
if (!lib) return [];
|
||||
const assets = [...(await c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=CREATIVE_ASSETS&limit=200`)),
|
||||
...(await c.all(`/v1/appAssetLibraries/${lib.id}/videos?filter[category]=CREATIVE_ASSETS&limit=200`))];
|
||||
const out: { locale: string; placement: string; file: string; text: string }[] = [];
|
||||
for (const [locale, fs] of local) for (const f of fs) {
|
||||
const a = pickAsset(assets, md5(readFileSync(f.file))).asset;
|
||||
if (a && selectable(classify(a.attributes?.state)) && a.attributes?.state !== 'APPROVED') out.push({ locale, placement: PLACEMENT_TYPE[f.placement], file: basename(f.file), text: waitingText(a) });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
const reason = (a: J): string => {
|
||||
const d = ((a.attributes?.stateDetails ?? []) as J[]).map(x => x?.code ?? x?.message ?? x?.description).filter(Boolean);
|
||||
return d.length ? ` (${d.join(', ')})` : '';
|
||||
|
|
@ -146,7 +169,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const version = editable ?? await live();
|
||||
const onLive = !editable;
|
||||
if (!version) throw new Error(`no editable or live App Store version: the header and search results slots belong to a version page (run apple listing --version x.y.z first). ${consoleLink('asc.version')}`);
|
||||
if (onLive && !verify) log(TAG, `no editable version: using the live version ${version.attributes.versionString}, where only approved assets can be selected`);
|
||||
if (onLive && !verify) log(TAG, `no editable version: using the live version ${version.attributes.versionString}`);
|
||||
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
|
||||
const library = async (): Promise<J[]> => [
|
||||
...(await c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=CREATIVE_ASSETS&limit=200`)),
|
||||
|
|
@ -208,7 +231,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (dry) {
|
||||
// one upload per file: a later locale with the same file reuses it (the real run finds it by MD5)
|
||||
const first = !planned.has(sum); planned.add(sum);
|
||||
log(TAG, `${tag}: would ${first ? 'upload' : 'reuse'} ${basename(f.file)}${first ? ' to the Asset Library' : ' (uploaded once for an earlier locale)'} and select it ${onLive ? 'on the live version once approved' : 'once processed'}`);
|
||||
log(TAG, `${tag}: would ${first ? 'upload' : 'reuse'} ${basename(f.file)}${first ? ' to the Asset Library' : ' (uploaded once for an earlier locale)'} and select it once Apple approved it`);
|
||||
row(first ? 'upload' : 'reuse'); continue;
|
||||
}
|
||||
log(TAG, `${tag}: uploading ${basename(f.file)} to the Asset Library`);
|
||||
|
|
@ -227,8 +250,9 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
if (cls === 'processing') { row('wait', asset); continue; }
|
||||
warn(TAG, `${tag}: ${basename(f.file)} is ${asset.attributes?.state}${reason(asset)}; not selected`); row('refuse', asset); continue;
|
||||
}
|
||||
if (onLive && asset.attributes?.state !== 'APPROVED') {
|
||||
log(TAG, `${tag}: ${basename(f.file)} is ${asset.attributes?.state} in the Asset Library; the live version takes it once App Review approved it (re-run then)`);
|
||||
// the version page's slots take only approved assets ("Browse approved assets"): the rest wait, a re-run places them
|
||||
if (asset.attributes?.state !== 'APPROVED') {
|
||||
log(TAG, `${tag}: ${basename(f.file)} ${waitingText(asset)}`);
|
||||
row('approve', asset); continue;
|
||||
}
|
||||
const action = placed.length ? 'replace' : 'select';
|
||||
|
|
@ -285,7 +309,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
log(TAG, `${added} creative asset(s) added to draft review submission ${sid}; nothing was submitted`);
|
||||
}
|
||||
}
|
||||
const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} file(s) to upload${n(['reuse']) ? ` (used by ${n(['upload', 'reuse'])} slots)` : ''}, ${n(['wait'])} still processing, ${approve ? `${approve} waiting for App Review (live version), ` : ''}${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`;
|
||||
const summary = `creative assets: ${n(['keep'])} up to date, ${n(['select', 'replace'])} ${dry ? 'to select' : 'selected'}, ${n(['upload'])} file(s) to upload${n(['reuse']) ? ` (used by ${n(['upload', 'reuse'])} slots)` : ''}, ${n(['wait'])} still processing, ${approve ? `${approve} waiting for Apple's approval, ` : ''}${n(['refuse'])} refused${off ? `, ${off} did not read back` : ''}`;
|
||||
if (unreviewed.size) {
|
||||
log(TAG, forReview
|
||||
? `owner step: Submit for Review with the version (or alone once the app has an approved version): ${consoleLink('asc.app-review')}`
|
||||
|
|
@ -295,7 +319,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
return done(TAG, dry ? `${summary} (dry run, nothing sent)` : summary);
|
||||
|
||||
async function verifySlots(): Promise<void> {
|
||||
let bad = 0;
|
||||
let bad = 0, waiting = 0;
|
||||
for (const loc of locs) {
|
||||
const locale: string = loc.attributes.locale;
|
||||
for (const f of local.get(locale) ?? []) {
|
||||
|
|
@ -305,11 +329,13 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
const shown = placed.map(p => assets.find(x => x.id === shownOf(p))).filter(Boolean) as J[];
|
||||
const ok = placed.length === 1 && shown[0] && sumOf(shown[0]) === sum;
|
||||
const what = shown.length ? shown.map(a => `${a.attributes?.fileName ?? a.id} (${a.attributes?.state})`).join(', ') : 'nothing';
|
||||
const pending = ok ? undefined : pickAsset(assets, sum).asset;
|
||||
if (ok) log(TAG, `ok ${locale} ${ptype}: ${what}`);
|
||||
else if (pending && selectable(classify(pending.attributes?.state)) && pending.attributes?.state !== 'APPROVED') { waiting++; log(TAG, `waiting ${locale} ${ptype}: ${basename(f.file)} ${waitingText(pending)} (shows ${what} until then)`); }
|
||||
else { bad++; warn(TAG, `${locale} ${ptype}: shows ${what}, wanted ${basename(f.file)}${placed.length > 1 ? ` (${placed.length} selected)` : ''}`); }
|
||||
}
|
||||
}
|
||||
if (bad) process.exitCode = 1;
|
||||
done(TAG, `creative assets verified on version ${version!.attributes.versionString} (read-only): ${bad ? `${bad} slot(s) differ from the local files` : 'every slot shows the local file'}`);
|
||||
done(TAG, `creative assets verified on version ${version!.attributes.versionString} (read-only): ${bad ? `${bad} slot(s) differ from the local files` : waiting ? 'every other slot shows the local file' : 'every slot shows the local file'}${waiting ? `, ${waiting} waiting for Apple's approval` : ''}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,7 +80,7 @@ export const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
|||
'apple appinfo': [() => import('./apple/appinfo'), 'categories, age rating, content rights, Free price, territories'],
|
||||
'apple review-details': [() => import('./apple/review-details'), 'App Review contact, demo account (from .storebound/, masked) + notes'],
|
||||
'apple status': [() => import('./apple/status'), 'versions, builds, IAPs, review submissions (--wait-review, --cancel-submission <id>); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and selects it once processed (MD5 upsert); --add-for-review, --verify (read-only), --dry'],
|
||||
'apple creative-assets': [() => import('./apple/creative-assets'), 'Header and Search Results: product page header (21:9) and search results asset (3:2) per locale, or one universal 5244x2950 PNG; uploads to the Asset Library and places it once Apple approved it, reporting waiting ones per locale (MD5 upsert, safe to re-run); --add-for-review, --verify (read-only), --dry'],
|
||||
'apple previews': [() => import('./apple/previews'), 'app preview videos per locale (MD5-idempotent, poster frame time code); --dry'],
|
||||
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first); --platform ios|macos|tvos|visionos (default: the platform that has the version this needs; asks when several do)'],
|
||||
'apple signing': [() => import('./apple/signing'), 'status | ensure: bundle id capabilities, distribution certificate (key kept in .storebound/signing), App Store profiles installed for Xcode; --dry, asks first'],
|
||||
|
|
|
|||
|
|
@ -847,6 +847,7 @@
|
|||
"play"
|
||||
],
|
||||
"validate": [
|
||||
"add-for-review",
|
||||
"allow-price-changes",
|
||||
"apple",
|
||||
"capacitor",
|
||||
|
|
@ -877,6 +878,7 @@
|
|||
"unity",
|
||||
"variant",
|
||||
"variants",
|
||||
"verify",
|
||||
"weekly",
|
||||
"write"
|
||||
],
|
||||
|
|
|
|||
|
|
@ -57,6 +57,8 @@ export function rendersFindings(store: 'apple' | 'play', r: RendersCheck | undef
|
|||
|
||||
export interface AppleSnapshot {
|
||||
version: { versionString: string; state: string; copyright?: string | null; platform?: string } | null;
|
||||
/** Header and Search Results files uploaded but not approved yet, per locale and placement (apple creative-assets). */
|
||||
creativeWaiting?: { locale: string; placement: string; file: string; text: string }[];
|
||||
/** Editable or rejected versions of the app's other platforms (an app's macOS version has its own state). */
|
||||
otherPlatformVersions?: { versionString: string; state: string; platform: string }[];
|
||||
/** app.copyright from the config (undefined: not set). */
|
||||
|
|
@ -206,6 +208,7 @@ export function appleRules(s: AppleSnapshot): Finding[] {
|
|||
else if (EDITABLE.includes(s.version.state)) add('ok', 'version', `version ${vl}: ${s.version.state}`);
|
||||
else if (IN_FLIGHT.includes(s.version.state)) add('warn', 'version', `version ${vl} is ${s.version.state} (nothing to prepare)`);
|
||||
else add('warn', 'version', `version ${vl}: ${s.version.state}`);
|
||||
for (const w of s.creativeWaiting ?? []) add('warn', 'creative-waiting', `${w.locale} ${w.placement}: ${w.file} ${w.text}`, 'storebound apple creative-assets (a re-run places it once approved; --add-for-review sends it to review)');
|
||||
for (const o of s.otherPlatformVersions ?? []) {
|
||||
const name = PLATFORM_NAMES[o.platform] ?? o.platform;
|
||||
add('warn', 'version-platform', `version ${o.versionString} (${name}) is ${o.state} too: this report covers ${vl || 'iOS'} only`, `storebound validate --platform ${name.toLowerCase()}; storebound apple status lists every platform`);
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ export const TOOLS: ToolDef[] = [
|
|||
seconds: { type: 'integer', minimum: 15, maximum: 30 }, captions: { type: 'boolean', default: true } },
|
||||
args: i => [...opt('lang', i.lang), ...opt('target', i.target), ...opt('seconds', i.seconds), ...(i.captions === false ? ['--no-captions'] : [])] },
|
||||
{ name: 'apple_creative_assets', title: 'App Store header and search results assets', command: ['apple', 'creative-assets'], write: true, destructive: true, network: true, idempotent: true,
|
||||
description: 'Header and Search Results (iOS / iPadOS 27+): checks <renders>/<lang>/creative/ (header 3840x1646, search 3:2, universal 5244x2950 PNG; no alpha; videos 5-30 s, 30/60 fps), uploads new files to the App Asset Library and selects them for PRODUCT_PAGE_HEADER_ASSET / APP_STORE_SEARCH_RESULTS_ASSET on the editable version once processed (Prepare for Submission is enough; MD5 upsert, same file = nothing sent, a rejected file is never re-uploaded). add_for_review puts unreviewed assets into the draft review submission (never submits). Play counterpart: feature graphic + promo video.',
|
||||
description: 'Header and Search Results (iOS / iPadOS 27+): checks <renders>/<lang>/creative/ (header 3840x1646, search 3:2, universal 5244x2950 PNG; no alpha; videos 5-30 s, 30/60 fps), uploads new files to the App Asset Library and selects them for PRODUCT_PAGE_HEADER_ASSET / APP_STORE_SEARCH_RESULTS_ASSET on the editable version once Apple approved them (the slots take only approved assets; until then each locale and placement is reported as waiting, and a re-run places them; MD5 upsert, same file = nothing sent, a rejected file is never re-uploaded). add_for_review puts unreviewed assets into the draft review submission (never submits). Play counterpart: feature graphic + promo video.',
|
||||
props: { only, add_for_review: { type: 'boolean', default: false, description: 'Also add the selected, unreviewed assets to the draft review submission (Submit for Review stays the owner\'s)' } },
|
||||
args: i => [...opt('only', i.only), ...(i.add_for_review === true ? ['--add-for-review'] : [])] },
|
||||
{ name: 'apple_creative_assets_verify', title: 'Verify App Store header and search results', command: ['apple', 'creative-assets', '--verify'], write: false, network: true,
|
||||
|
|
|
|||
|
|
@ -56,10 +56,10 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
|
|||
};
|
||||
|
||||
/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store
|
||||
* Connect API 4.5.1 (spec checked 2026-10-18) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
|
||||
* Connect API 4.5.1 (spec re-checked 2026-10-20: the published zip is still the 2026-10-06 one, same sha256) has no Duo value (only AppAssetLibraryDisplayClass lists IPHONE_DUO).
|
||||
* When Apple adds it: regenerate (bun tools/gen-api.ts) and put the exact enum names here; uploads then include them. */
|
||||
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
|
||||
export const IPHONE_DUO_NOT_IN_API = 'App Store Connect does not take iPhone Duo screenshots through the API yet; storebound keeps them in store/renders';
|
||||
export const IPHONE_DUO_NOT_IN_API = 'the App Store Connect console takes iPhone Duo screenshots, the API does not yet; storebound keeps them in store/renders';
|
||||
/** Reportedly required from this date for apps built with the iOS 27.1 SDK or later. */
|
||||
export const IPHONE_DUO_REQUIRED_FROM = '2027-04-01';
|
||||
const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" / 10" tablet screenshots (tab7, tab10, play.tablets)';
|
||||
|
|
@ -69,7 +69,7 @@ const PLAY_FOLDABLE = 'Google Play has no foldable slot: foldables show the 7" /
|
|||
export function iphoneDuoNote(o: { apple: boolean; duo: boolean; play?: boolean; today?: Date }): { level: 'info' | 'warn'; msg: string } | null {
|
||||
if (!o.apple || o.duo) return null;
|
||||
const late = (o.today ?? new Date()).toISOString().slice(0, 10) >= IPHONE_DUO_REQUIRED_FROM;
|
||||
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
|
||||
return { level: late ? 'warn' : 'info', msg: `no iPhone Duo screenshots (outer 1398x2034, inner 2007x2853; apple.iphoneDuo): optional now, required from April 2027 for iOS 27.1 SDK builds; the App Store Connect console takes Duo screenshots and previews, the API does not yet (apple screenshots skips them with the console link)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
|
||||
}
|
||||
|
||||
/** Play screenshots in general (any size the store accepts): 320..3840 px per side, long side at most 2x the short. */
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import type { ScanFinding } from './lib/scan/rules';
|
|||
import { playClient } from './play/client';
|
||||
import { listOneTimeProducts } from './play/products';
|
||||
import { creativeNote } from './shots/creative';
|
||||
import { creativeWaiting, localCreative } from './apple/creative-assets';
|
||||
|
||||
/** Profile facts for the checks: config + local credentials + public URL checks (no store API). */
|
||||
export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
|
||||
|
|
@ -51,6 +52,15 @@ export async function collectProfile(loaded: Loaded): Promise<ProfileFacts> {
|
|||
};
|
||||
}
|
||||
|
||||
/** Header and Search Results files uploaded but not approved yet (the slots take only approved assets). Never fails
|
||||
* validate: a local file problem or an unreadable library is a note for creative-assets itself. */
|
||||
async function creativeWaitingOf(ctx: Ctx, c: Awaited<ReturnType<typeof ascClient>>, appId: string): Promise<AppleSnapshot['creativeWaiting']> {
|
||||
try {
|
||||
const { local } = localCreative(ctx);
|
||||
return local.size ? await creativeWaiting(c, appId, local) : [];
|
||||
} catch { return []; }
|
||||
}
|
||||
|
||||
export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
||||
const { config, root } = ctx.loaded;
|
||||
const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId);
|
||||
|
|
@ -118,6 +128,7 @@ export async function collectApple(ctx: Ctx): Promise<AppleSnapshot> {
|
|||
profile: await collectProfile(ctx.loaded),
|
||||
version: v ? { versionString: v.attributes.versionString, state: v.attributes.appVersionState ?? v.attributes.appStoreState, copyright: v.attributes.copyright ?? null, platform } : null,
|
||||
otherPlatformVersions,
|
||||
creativeWaiting: await creativeWaitingOf(ctx, c, appId),
|
||||
configCopyright: config.app.copyright,
|
||||
build, locales, reviewDetail, ageRating,
|
||||
expectedLocales: config.languages.map(l => l.apple).filter((x): x is string => !!x),
|
||||
|
|
|
|||
|
|
@ -100,14 +100,16 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a
|
|||
- **Not rendered by `shots render`**: the scene templates are portrait device shots; make the wide art yourself (or from
|
||||
the feature graphic artwork at the larger size) and drop it in.
|
||||
- **Upload and select:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset
|
||||
Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`), and once processed
|
||||
("Prepare for Submission", a bounded wait of 2 minutes with a heartbeat) the run selects them for the slots of the
|
||||
editable version (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`), as the
|
||||
console's Browse Assets > Save does. Approval is not needed first. Same file (MD5) = nothing sent; an archived copy is
|
||||
Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`; processing is a bounded
|
||||
wait of 2 minutes with a heartbeat). The version page's slots offer only approved assets ("Browse approved assets"),
|
||||
so an asset is placed (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`)
|
||||
once Apple approved it; until then each locale and placement says "uploaded <date>, waiting for Apple's approval; run
|
||||
`storebound apple creative-assets` again later", and a later run places it. `--verify`, `validate` and the report
|
||||
show the same waiting state. Same file (MD5) = nothing sent; an archived copy is
|
||||
unarchived; a file App Review rejected is never uploaded again (change the file). MCP: `apple_creative_assets` (dry by
|
||||
default).
|
||||
- **Review:** an asset is reviewed only once it is in a review submission (its own item, `appAssetLibraryImage` /
|
||||
`appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the selected, unreviewed assets to the
|
||||
`appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the uploaded, unreviewed assets to the
|
||||
app's draft submission, once: before the first approved version it is the same submission as that version, later
|
||||
they can go alone. Pressing Submit for Review stays the owner's.
|
||||
- **Live app, no editable version:** the run uses the live version, where only APPROVED assets are selected (Apple
|
||||
|
|
|
|||
|
|
@ -913,31 +913,33 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
`screenshots-duo` note in check / validate. `tests/ppo.test.ts`.
|
||||
|
||||
## 76. Header and Search Results assets are not uploaded like screenshots
|
||||
- **Symptom:** a header or search results image was "uploaded" but the version page shows no header, a run kept saying
|
||||
"waiting for Apple's approval" for days, or a re-run uploaded the same file again.
|
||||
- **Symptom:** a header or search results image was "uploaded" but the version page shows no header, a run waited on
|
||||
an asset nobody had sent to review, or a re-run uploaded the same file again.
|
||||
- **Cause:** these slots take assets from the app's Asset Library (`GET /v1/apps/{id}/assetLibrary`), not a set of
|
||||
their own: a slot is an `appAssetLibraryPlacements` row (`PRODUCT_PAGE_HEADER_ASSET` or
|
||||
`APP_STORE_SEARCH_RESULTS_ASSET`, the version localization). An asset is reviewed only once it is in a review
|
||||
submission (its own item, `reviewSubmissionItems` with `appAssetLibraryImage` / `appAssetLibraryVideo`; the console's
|
||||
"Add for Review"). Apple's "App Store asset statuses": a processed asset is "Prepare for Submission" and can already
|
||||
be selected for a version. So an earlier storebound that selected only APPROVED assets waited forever: nothing ever
|
||||
sent the asset to review. Library assets carry no `sourceFileChecksum`, a rejected asset stays rejected (uploading
|
||||
the same bytes again only adds a copy), and the search slot is the API's `APP_STORE_SEARCH_RESULTS_ASSET`
|
||||
(`SEARCH_RESULTS_ADS_ASSET` is the Apple Ads one). Seen live (19 Oct 2026): placements of a live version read back
|
||||
with state `ACTIVE`, which the 4.5.1 spec's `AppAssetLibraryPlacementState` does not list; never validate placement
|
||||
states against the spec enum.
|
||||
"Add for Review"); after processing it is "Prepare for Submission". The version page's slots offer only approved
|
||||
assets ("Browse approved assets", seen in a real release, 20 Oct 2026), so placing waits for Apple's approval.
|
||||
Library assets carry no `sourceFileChecksum`, a rejected asset stays rejected (uploading the same bytes again only
|
||||
adds a copy), and the search slot is the API's `APP_STORE_SEARCH_RESULTS_ASSET` (`SEARCH_RESULTS_ADS_ASSET` is the
|
||||
Apple Ads one). Seen live (19 Oct 2026): placements of a live version read back with state `ACTIVE`, which the 4.5.1
|
||||
spec's `AppAssetLibraryPlacementState` does not list; never validate placement states against the spec enum.
|
||||
- **Do:** `storebound apple creative-assets`: MD5 in `referenceName` ("storebound <md5>"), reuse (or unarchive) a library
|
||||
asset with the same MD5, select it once processed, never re-upload a rejected file or one that failed processing
|
||||
twice. Then `--add-for-review` puts the unreviewed assets into the draft submission (with the first version, or alone
|
||||
once the app has an approved version) and `--verify` reads every slot back. With no editable version the live version
|
||||
is used and only APPROVED assets go onto it; the rest wait for their own review. Whether the API accepts a new
|
||||
placement on a live version is not documented: storebound places the new asset first and removes the old one only
|
||||
after Apple accepted it, and on a 4xx it says so, leaves the live page unchanged, gives the live version page's deep
|
||||
link (select it there, or create a version and re-run) and does not try the remaining live slots. Uploading, selecting and adding to the
|
||||
draft are normal steps, not owner buttons; Submit for Review stays the owner's.
|
||||
- **Guard:** `cli/apple/creative-assets.ts` (`classify`, `pickAsset`, `processingTiming`), `tests/creative-assets.test.ts`
|
||||
(fake App Store Connect: upload and select in one run, still processing, rejected / archived files, add-for-review
|
||||
without submitting, verify read-only, a refused live placement); the fake commits library assets to PREPARE_FOR_SUBMISSION, as Apple does.
|
||||
asset with the same MD5, never re-upload a rejected file or one that failed processing twice. `--add-for-review`
|
||||
puts the unreviewed assets into the draft submission (with the first version, or alone once the app has an approved
|
||||
version; Submit for Review stays the owner's). An asset that is not APPROVED is reported per locale and placement as
|
||||
"uploaded <date>, waiting for Apple's approval; run `storebound apple creative-assets` again later"; the run is an
|
||||
upsert, so a later run places it as soon as it is approved (an approved old asset stays shown meanwhile). `--verify`
|
||||
and `validate` / the report show the same waiting state. With no editable version the live version is used.
|
||||
Whether the API accepts a new placement on a live version is not documented: storebound places the new asset first
|
||||
and removes the old one only after Apple accepted it, and on a 4xx it says so, leaves the live page unchanged, gives
|
||||
the live version page's deep link (select it there, or create a version and re-run) and does not try the remaining
|
||||
live slots.
|
||||
- **Guard:** `cli/apple/creative-assets.ts` (`classify`, `pickAsset`, `waitingText`, `creativeWaiting`,
|
||||
`processingTiming`), `tests/creative-assets.test.ts` (fake App Store Connect: upload, wait for approval, place on a
|
||||
re-run, still processing, rejected / archived files, add-for-review without submitting, verify read-only with the
|
||||
waiting state, a refused live placement, the validate finding).
|
||||
|
||||
## 77. A macOS (tvOS, visionOS) version was invisible next to the iOS one
|
||||
- **Symptom:** `apple resubmit` said "nothing to resubmit" while a macOS version was REJECTED with an
|
||||
|
|
|
|||
|
|
@ -209,14 +209,16 @@ Screenshots". Optional: without these assets the App Store uses in-app events, a
|
|||
- **Not rendered by `shots render`**: the scene templates are portrait device shots; make the wide art yourself (or from
|
||||
the feature graphic artwork at the larger size) and drop it in.
|
||||
- **Upload and select:** `storebound apple creative-assets --dry`, then without `--dry`. Files go into the app's Asset
|
||||
Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`), and once processed
|
||||
("Prepare for Submission", a bounded wait of 2 minutes with a heartbeat) the run selects them for the slots of the
|
||||
editable version (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`), as the
|
||||
console's Browse Assets > Save does. Approval is not needed first. Same file (MD5) = nothing sent; an archived copy is
|
||||
Library (API: `appAssetLibraryImages` / `appAssetLibraryVideos`, category `CREATIVE_ASSETS`; processing is a bounded
|
||||
wait of 2 minutes with a heartbeat). The version page's slots offer only approved assets ("Browse approved assets"),
|
||||
so an asset is placed (`appAssetLibraryPlacements`, `PRODUCT_PAGE_HEADER_ASSET` / `APP_STORE_SEARCH_RESULTS_ASSET`)
|
||||
once Apple approved it; until then each locale and placement says "uploaded <date>, waiting for Apple's approval; run
|
||||
`storebound apple creative-assets` again later", and a later run places it. `--verify`, `validate` and the report
|
||||
show the same waiting state. Same file (MD5) = nothing sent; an archived copy is
|
||||
unarchived; a file App Review rejected is never uploaded again (change the file). MCP: `apple_creative_assets` (dry by
|
||||
default).
|
||||
- **Review:** an asset is reviewed only once it is in a review submission (its own item, `appAssetLibraryImage` /
|
||||
`appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the selected, unreviewed assets to the
|
||||
`appAssetLibraryVideo`; the console's "Add for Review"). `--add-for-review` adds the uploaded, unreviewed assets to the
|
||||
app's draft submission, once: before the first approved version it is the same submission as that version, later
|
||||
they can go alone. Pressing Submit for Review stays the owner's.
|
||||
- **Live app, no editable version:** the run uses the live version, where only APPROVED assets are selected (Apple
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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')!;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue