apple screenshots: iPhone Duo through the App Asset Library (IPHONE_DUO placement group, APP_SCREENSHOT), pitfall 130

This commit is contained in:
CRTkafa 2026-11-08 14:19:12 +07:00
parent b1043f5d79
commit 73a962fbdc
10 changed files with 174 additions and 32 deletions

View file

@ -3,6 +3,9 @@
## Unreleased
- **iPhone Duo screenshots upload.** `apple screenshots` now sends them through the App Asset Library (display class
IPHONE_DUO, placement APP_SCREENSHOT) instead of skipping them as "not in the API" (pitfall 130).
## 0.8.71 · 2026-11-07
- 0.8.69 and 0.8.70 were tagged but not published (the desktop release check stopped them); 0.8.71 ships both sections below. Desktop Portfolio: the Play state of an app uses the same words as Releases ("Not live yet: in review or blocked before review").

113
cli/apple/duo-library.ts Normal file
View file

@ -0,0 +1,113 @@
// iPhone Duo screenshots through the App Asset Library (App Store Connect API 4.5.1, pitfall 130). ScreenshotDisplayType
// has no Duo value and never will: Apple added the display class IPHONE_DUO only to AppAssetLibraryDisplayClass and
// deprecated appScreenshotSets / appScreenshots in the same release. Flow:
// 1. GET /v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT -> the iOS placementProfileGroup whose
// displayClassId is IPHONE_DUO (its id is the placementGroup of every Duo screenshot);
// 2. each file is uploaded once to the app's library (POST appAssetLibraryImages, category
// APP_SCREENSHOTS_AND_PREVIEWS, referenceName "storebound <md5>", the same MD5 reuse as creative assets);
// 3. POST appAssetLibraryPlacements (placementType APP_SCREENSHOT, placementGroup, the version localization).
// Screenshots are reviewed with the version, so a processed asset is placed at once (no approval wait).
// Upsert: a group whose placements already show the local files in order is left alone; otherwise its placements are
// removed and the files placed in order (outer display files first, then inner; at most 10). Read back at the end.
import { readFileSync } from 'node:fs';
import { basename } from 'node:path';
import { log, warn } from '../lib/log';
import { elapsed } from '../lib/progress';
import { classify, pickAsset, processingTiming, REF, selectable } from './creative-assets';
import { type Asc, md5, type J } from './asc';
import { partHeaders } from './screenshot-health';
const TAG = 'apple';
export const DUO_CATEGORY = 'APP_SCREENSHOTS_AND_PREVIEWS';
export const DUO_MAX = 10;
const sumOf = (a: J | undefined): string | undefined => /^storebound ([0-9a-f]{32})$/.exec(String(a?.attributes?.referenceName ?? ''))?.[1];
/** iOS placement profile group ids of the IPHONE_DUO display class in an appAssetLibraryRefData response. Pure. */
export function duoGroups(refData: J[]): string[] {
const ids = new Set<string>();
for (const r of refData) for (const g of (r?.attributes?.placementProfileGroups ?? []) as J[])
if (g?.displayClassId === 'IPHONE_DUO' && (!g.platform || g.platform === 'IOS') && g.placementProfileGroupId) ids.add(String(g.placementProfileGroupId));
return [...ids];
}
/** Whether a group's current placements (asset MD5s in order) already show `want`. Pure. */
export const duoUpToDate = (shown: (string | undefined)[], want: string[]): boolean => shown.length === want.length && shown.every((s, i) => s === want[i]);
export interface DuoResult { locale: string; action: 'keep' | 'place' | 'wait' | 'refuse' | 'plan'; files: number }
/** Resolves the Duo placement group once per run; throws with the guardrail text when the API lists none. */
export async function duoGroup(c: Asc): Promise<string> {
const ref = await c.all('/v1/appAssetLibraryRefData?filter[placementTypes]=APP_SCREENSHOT');
const g = duoGroups(ref);
if (!g.length) throw new Error('appAssetLibraryRefData lists no iOS IPHONE_DUO placement group for APP_SCREENSHOT: report it (pitfall 130); the console version page > App Previews and Screenshots > iPhone Duo takes them meanwhile');
if (g.length > 1) log(TAG, `iPhone Duo: ${g.length} placement groups (${g.join(', ')}), using ${g[0]}`);
return g[0]!;
}
/** Makes the iPhone Duo screenshots of one version localization show `files` in order. */
export async function syncDuo(c: Asc, o: { appId: string; group: string; loc: J; files: string[]; dry: boolean }): Promise<DuoResult> {
const locale: string = o.loc.attributes.locale;
const tag = `${locale} IPHONE_DUO`;
const files = o.files.slice(0, DUO_MAX);
if (o.files.length > DUO_MAX) warn(TAG, `${tag}: ${o.files.length} files, only the first ${DUO_MAX} are used`);
const lib = (await c.asc('GET', `/v1/apps/${o.appId}/assetLibrary`)).data as J;
const library = () => c.all(`/v1/appAssetLibraries/${lib.id}/images?filter[category]=${DUO_CATEGORY}&limit=200`);
let assets = await library();
const want = files.map(f => md5(readFileSync(f)));
const placedAll = await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`);
const placed = placedAll.filter(p => p.attributes?.placementGroup === o.group);
const shown = placed.map(p => sumOf(assets.find(a => a.id === p.relationships?.image?.data?.id)));
if (duoUpToDate(shown, want)) { log(TAG, `${tag}: ${files.length} up to date`); return { locale, action: 'keep', files: files.length }; }
if (o.dry) {
const up = want.filter(s => !pickAsset(assets, s).asset).length;
log(TAG, `${tag}: would ${placed.length ? `replace ${placed.length} placed` : 'place'} ${files.length} screenshot(s) through the Asset Library (${up} to upload)`);
return { locale, action: 'plan', files: files.length };
}
const chosen: J[] = [];
for (let i = 0; i < files.length; i++) {
const f = files[i]!, sum = want[i]!;
const pick = pickAsset(assets, sum);
if (pick.refuse) { warn(TAG, `${tag} ${basename(f)}: ${pick.refuse}`); return { locale, action: 'refuse', files: files.length }; }
let a = pick.asset;
if (!a) { log(TAG, `${tag}: uploading ${basename(f)} to the Asset Library`); a = await uploadImage(c, lib.id, f, sum); assets = await library(); a = pickAsset(assets, sum).asset ?? a; }
else if (pick.unarchive) a = (await c.asc('PATCH', `/v1/${a.type}/${a.id}`, { data: { type: a.type, id: a.id, attributes: { archived: false } } })).data as J;
a = await processed(c, a, `${tag} ${basename(f)}`);
const cls = classify(a.attributes?.state);
if (cls === 'processing') return { locale, action: 'wait', files: files.length };
if (!selectable(cls)) { warn(TAG, `${tag} ${basename(f)}: ${a.attributes?.state}; nothing changed`); return { locale, action: 'refuse', files: files.length }; }
chosen.push(a);
}
// every file is ready before the old placements go, so a failed upload leaves the page as it was
for (const p of placed) await c.asc('DELETE', `/v1/appAssetLibraryPlacements/${p.id}`);
for (const a of chosen) await c.asc('POST', '/v1/appAssetLibraryPlacements', { data: { type: 'appAssetLibraryPlacements', attributes: { placementType: 'APP_SCREENSHOT', placementGroup: o.group },
relationships: { image: { data: { type: 'appAssetLibraryImages', id: a.id } }, appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: o.loc.id } } } } });
const now = (await c.all(`/v1/appStoreVersionLocalizations/${o.loc.id}/placements?filter[placementType]=APP_SCREENSHOT&include=image`)).filter(p => p.attributes?.placementGroup === o.group);
const ids = now.map(p => p.relationships?.image?.data?.id);
if (ids.length !== chosen.length || chosen.some(a => !ids.includes(a.id))) { warn(TAG, `${tag}: read back ${ids.length} placements, wanted ${chosen.length}`); process.exitCode = 1; }
else log(TAG, `${tag}: placed ${chosen.length} (removed ${placed.length})`);
return { locale, action: 'place', files: chosen.length };
}
async function uploadImage(c: Asc, libId: string, file: string, sum: string): Promise<J> {
const buf = readFileSync(file);
const type = 'appAssetLibraryImages';
const res = (await c.asc('POST', `/v1/${type}`, { data: { type, attributes: { fileName: basename(file), fileSize: buf.length, category: DUO_CATEGORY, referenceName: REF(sum) },
relationships: { assetLibrary: { data: { type: 'appAssetLibraries', id: libId } } } } })).data as J;
for (const op of (res.attributes.uploadOperations ?? []) as J[]) {
const r = await fetch(op.url, { method: op.method, headers: partHeaders(op), body: buf.subarray(op.offset, op.offset + op.length), signal: AbortSignal.timeout(300_000) });
if (r.status < 200 || r.status > 299) { await c.asc('DELETE', `/v1/${type}/${res.id}`).catch(() => {}); throw new Error(`upload ${basename(file)} part ${op.offset} -> ${r.status} (reservation deleted, nothing committed)`); }
}
return (await c.asc('PATCH', `/v1/${type}/${res.id}`, { data: { type, id: res.id, attributes: { uploaded: true } } })).data as J;
}
async function processed(c: Asc, a: J, label: string): Promise<J> {
const t0 = Date.now();
let beat = t0, cur = a;
while (classify(cur.attributes?.state) === 'processing') {
if (Date.now() - t0 >= processingTiming.timeoutMs) { warn(TAG, `${label}: still ${cur.attributes?.state ?? 'processing'} after ${elapsed(Date.now() - t0)}; re-run later to place it`); return cur; }
await processingTiming.sleep(processingTiming.pollMs);
cur = ((await c.maybe(`/v1/${cur.type}/${cur.id}`))?.data as J) ?? cur;
if (Date.now() - beat >= processingTiming.heartbeatMs) { beat = Date.now(); log(TAG, `${label}: processing (${cur.attributes?.state}, ${elapsed(Date.now() - t0)})`); }
}
return cur;
}

View file

@ -6,7 +6,7 @@
// ios65/NN.png (6.5", 1284 x 2778 -> APP_IPHONE_65) and, only with apple.ipad, ipad13/NN.png (13" iPad, 2064 x 2752 ->
// APP_IPAD_PRO_3GEN_129: the API has no "13 inch" value, the 13" display set keeps the 3rd gen 12.9" name). With
// --platform macos (or when only the macOS version is editable) and apple.mac: mac/NN.png (2880 x 1800 -> APP_DESKTOP)
// to the macOS version. iPhone Duo sets are skipped with the console deep link (no API display type yet). Up to 10
// to the macOS version. iPhone Duo (apple.iphoneDuo: iphone-duo-outer/ then iphone-duo-inner/) goes through the App Asset Library. Up to 10
// per set. --variant <id> uploads <renders>/<id>/<lang>/... instead (an A/B variant from shots.variants, e.g. for a
// Product Page Optimization treatment you then set up in App Store Connect). Apple processes uploads asynchronously; failed
// ones are reported at the end. Long: run in the background with a log file.
@ -20,7 +20,8 @@ import { variantRoot } from '../shots/lib';
import { findOrCreate } from '../lib/upsert';
import { pulledChecksum } from '../lib/art-pull';
import { consoleLink } from '../lib/console-links';
import { IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API } from '../shots/spec';
import { IPHONE_DUO_DISPLAY } from '../shots/spec';
import { duoGroup, syncDuo } from './duo-library';
import { type Asc, ascClient, EDITABLE, keptPrefix, md5, setUpToDate, type J } from './asc';
import { pendingScreenshots, repairPlan, repairSet, setRows, updateLedger } from './screenshot-health';
import { allVersions, parsePlatform, pickPlatform, platformLabel, stateOf, versionLabel } from './platform';
@ -42,12 +43,9 @@ export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean; iph
platform === 'MAC_OS' ? (apple?.mac ? MAC_DISPLAY : {}) : ({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}),
...(apple?.iphoneDuo ? Object.fromEntries(Object.entries(IPHONE_DUO_DISPLAY).filter((e): e is [string, string] => !!e[1])) : {}) });
/** iPhone Duo size keys `apple screenshots` skips (apple.iphoneDuo on, no API display type yet), with the message. */
export function skippedDuoSets(apple: { iphoneDuo?: boolean; appId?: string } | undefined): { dirs: string[]; msg: string } | null {
const dirs = apple?.iphoneDuo ? Object.entries(IPHONE_DUO_DISPLAY).filter(([, t]) => !t).map(([k]) => k) : [];
// the console takes them (its own "iPhone Duo" tab): send the owner straight to the version page
return dirs.length ? { dirs, msg: `${dirs.join(', ')}: ${IPHONE_DUO_NOT_IN_API}. Add them in ${consoleLink('asc.version', '> App Previews and Screenshots > iPhone Duo', { appleAppId: apple?.appId })}` } : null;
}
/** iPhone Duo size keys (render folders) `apple screenshots` uploads through the App Asset Library (no
* ScreenshotDisplayType exists for Duo: cli/apple/duo-library.ts, pitfall 130), outer display first. */
export const duoDirs = (apple: { iphoneDuo?: boolean } | undefined): string[] => (apple?.iphoneDuo ? Object.keys(IPHONE_DUO_DISPLAY) : []);
/** Display types `validate` requires on every locale: apple.screenshotTypes, else the classes App Store Connect's
* "App Previews and Screenshots" tab lists (October 2026): iPhone with Dynamic Island (medium display), filled by
@ -98,8 +96,9 @@ export async function run(ctx: Ctx): Promise<void> {
const version = await c.editableVersion(platform);
if (!version) throw new Error(`no editable ${platformLabel(platform)} App Store version (run apple listing --version x.y.z first)`);
log('apple', `screenshots for version ${versionLabel(version)}`);
const duoSkip = platform === 'MAC_OS' ? null : skippedDuoSets(config.apple);
if (duoSkip) warn('apple', duoSkip.msg);
const duo = platform === 'MAC_OS' ? [] : duoDirs(config.apple);
const duoGroupId = duo.length ? await duoGroup(c) : '';
const appId = duo.length ? await c.app() : '';
if (platform === 'MAC_OS' && !config.apple?.mac) throw new Error('macOS version: set apple.mac: true and render the mac size (storebound shots render --size mac) first');
if (platform !== 'MAC_OS' && config.apple?.mac) log('apple', 'Mac screenshots (mac/, APP_DESKTOP) go to the macOS version: run apple screenshots --platform macos');
const langOf = byAppleLocale(config.languages);
@ -127,6 +126,11 @@ export async function run(ctx: Ctx): Promise<void> {
if (!existsSync(d) || !files.length) { warn('apple', `${locale}: nothing in ${d}, skipped`); continue; }
await syncSet(loc.id, sets, type, files, `${locale} ${type}`);
}
if (duo.length) {
const files = duo.flatMap(dir => numberedImages(join(src, lang.id, dir)).filter(f => f.endsWith('.png')));
if (!files.length) warn('apple', `${locale}: nothing in ${duo.map(d => join(src, lang.id, d)).join(' or ')}, iPhone Duo skipped`);
else await syncDuo(c, { appId, group: duoGroupId, loc, files, dry: ctx.args.has('dry') });
}
});
}
log('apple', `${tasks.length} locales, ${jobs} in parallel`);

View file

@ -700,8 +700,8 @@ export interface ShipConfig {
*/
iphoneMedium?: boolean;
/** Also render and check iPhone Duo (foldable) screenshots: outer 1398 x 2034 and inner 2007 x 2853 (landscape too).
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. Not uploaded while the API has no
* display type for them (cli/shots/spec.ts IPHONE_DUO_DISPLAY). */
* Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds. `apple screenshots` uploads them through
* the App Asset Library (display class IPHONE_DUO; there is no Duo ScreenshotDisplayType). */
iphoneDuo?: boolean;
/** Also render, check and upload Mac App Store screenshots (size key `mac`, 2880 x 1800, 16:10; Apple also takes
* 2560 x 1600, 1440 x 900, 1280 x 800). Landscape layout from <frames>/<lang>/landscape/ (shots.landscape). Uploaded

View file

@ -61,11 +61,10 @@ export const IMAGE_SPECS: Record<string, ImageSpec> = {
feature: { store: 'play', type: 'featureGraphic', label: 'Play feature graphic', sizes: [[1024, 500]], min: 1, max: 1, maxBytes: 15 * MB, alpha: false },
};
/** ASC screenshotDisplayType per iPhone Duo size key. null = NOT IN THE API YET: ScreenshotDisplayType in App Store
* 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. */
/** iPhone Duo size keys. Values stay null: ScreenshotDisplayType has no Duo value and never will (API 4.5.1 put the
* display class IPHONE_DUO into the App Asset Library only and deprecated screenshot sets); apple screenshots
* uploads Duo through the library (cli/apple/duo-library.ts, pitfall 130). */
export const IPHONE_DUO_DISPLAY: Record<string, string | null> = { 'iphone-duo-outer': null, 'iphone-duo-inner': null };
export const IPHONE_DUO_NOT_IN_API = 'not uploaded: the App Store Connect API has no iPhone Duo display type yet (the console takes them); the renders stay 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)';
@ -75,7 +74,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; 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}` : ''}` };
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; apple screenshots uploads them through the App Asset Library (display class IPHONE_DUO)${o.play ? `. ${PLAY_FOLDABLE}` : ''}` };
}
/** validate / check note for an app with a macOS version but no Mac screenshots (apple.mac off or no mac renders). */

View file

@ -27,8 +27,7 @@ State of storebound for whoever picks it up next. Public-safe: no project, app o
- The Play Games Services console page paths.
- The Games Configuration API bodies (no pinned spec).
- Placing a Header and Search Results asset on a live App Store version.
- **iPhone Duo:** App Store Connect's API has no Duo screenshot type yet. Uploads skip with a console link; wire it
when the spec gains the type (`IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`).
- **iPhone Duo:** uploaded through the App Asset Library (`cli/apple/duo-library.ts`); previews still use the deprecated sets.
## Rules that matter here

View file

@ -82,8 +82,7 @@ languages, every store size, a review gallery and an app preview video. No store
Play takes a YouTube URL in `play.video`.
7. **iPhone Duo (foldable).** `apple.iphoneDuo: true` renders and checks the outer (1398 x 2034) and inner
(2007 x 2853) sets. Optional now, reportedly required from April 2027 for iOS 27.1 SDK builds (`check` / `validate`
note it). The API has no Duo display type yet, so `apple screenshots` skips them with a link to the console's
iPhone Duo tab. Play has no foldable slot: foldables use the 7" / 10" tablet sets.
note it). `apple screenshots` uploads them through the App Asset Library (display class IPHONE_DUO). Play has no foldable slot: foldables use the 7" / 10" tablet sets.
Details: the [store-screenshots](../skills/store-screenshots/SKILL.md) and [app-previews](../skills/app-previews/SKILL.md)
skills.

View file

@ -1610,3 +1610,19 @@ that fails without the fix (see CONTRIBUTING.md).
- **Guard:** `cli/play/old-builds.ts` (play status, validate `old-builds`), `PLAY_NOT_LIVE` in `cli/lib/portfolio.ts`,
`isAutoSendOnly` in `cli/play/review-pending.ts` (the commit is redone without changesNotSentForReview),
`tests/play-old-builds.test.ts`.
## 130. iPhone Duo screenshots were skipped as "not in the API"
- **Symptom:** `apple screenshots` skipped the iPhone Duo sets with "the API has no iPhone Duo display type yet".
Wrong: ScreenshotDisplayType never gets a Duo value. API 4.5.1 put the display class `IPHONE_DUO` into the App Asset
Library (AppAssetLibraryDisplayClass) and deprecated appScreenshotSets / appScreenshots / appPreviewSets /
appPreviews in the same release.
- **Do:** `storebound apple screenshots` uploads Duo (apple.iphoneDuo, renders iphone-duo-outer/ then
iphone-duo-inner/) through the library: `appAssetLibraryRefData` (filter placementTypes APP_SCREENSHOT) gives the
iOS placementProfileGroup of IPHONE_DUO, files go up as appAssetLibraryImages (category
APP_SCREENSHOTS_AND_PREVIEWS), then appAssetLibraryPlacements (APP_SCREENSHOT, that placementGroup, the version
localization). Same MD5 reuse as creative assets; read back.
- **Never:** say an API lacks a store feature after checking one enum: search the whole spec (every schema with the
feature's name) first.
- **Guard:** `cli/apple/duo-library.ts` (duoGroups, syncDuo), `duoDirs` in `cli/apple/screenshots.ts`,
`tests/ppo.test.ts`. Previews and the other display classes still use the deprecated sets until they move too.

View file

@ -71,10 +71,9 @@ in a plain Android tablet body instead of the iPad one.
outer (1398 x 2034) and an inner (2007 x 2853) set, portrait or landscape, PNG / JPEG without alpha, 1-10 each. It is
optional now and reportedly required from April 2027 for builds with the iOS 27.1 SDK or later; `check` / `validate`
print an info note until then (a warning after, never a blocker). `apple.iphoneDuo: true` adds both sizes to
`shots render` / `shots check` / the gallery. The API (4.5.1) has no Duo `ScreenshotDisplayType` yet, so
`apple screenshots` skips the Duo folders with a message and the deep link to the version page; add them in the
console's iPhone Duo tab until then (one constant, `IPHONE_DUO_DISPLAY` in `cli/shots/spec.ts`, takes the names once
the enum has them). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the
`shots render` / `shots check` / the gallery. ScreenshotDisplayType has no Duo value (and won't get one), so
`apple screenshots` uploads the Duo folders through the App Asset Library (display class IPHONE_DUO, placement
APP_SCREENSHOT; pitfall 130). No Duo app preview sizes are published. Google Play has no foldable slot: foldables show the
7" / 10" tablet sets below.
**Mac App Store.** Apple's Screenshot specifications (checked 2026-10-20) list four 16:10 Mac sizes: 1280 x 800,

View file

@ -1,12 +1,13 @@
import { describe, expect, test } from 'bun:test';
import { displaySets, skippedDuoSets } from '../cli/apple/screenshots';
import { displaySets, duoDirs } from '../cli/apple/screenshots';
import { duoGroups, duoUpToDate } from '../cli/apple/duo-library';
import { appleRules } from '../cli/lib/validate-rules';
import { checkCreate, createExperimentReq, defaultName, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq } from '../cli/lib/ppo';
import { validateConfig } from '../cli/lib/config';
import { FORBIDDEN_ARGS, TOOLS, toArgv, validateInput } from '../cli/mcp-tools';
import { requiredSets } from '../cli/shots/report';
import { defaultSizes, SIZES } from '../cli/shots/lib';
import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, IPHONE_DUO_NOT_IN_API, iphoneDuoNote } from '../cli/shots/spec';
import { checkImage, IMAGE_SPECS, imageInfo, IPHONE_DUO_DISPLAY, iphoneDuoNote } from '../cli/shots/spec';
import { png } from './fixtures';
describe('product page optimization', () => {
@ -91,12 +92,21 @@ describe('iPhone Duo (foldable) sizes', () => {
expect(checkImage(imageInfo(png(2007, 2853)), 'iphone-duo-outer').map(i => i.code)).toEqual(['size']);
expect(checkImage(imageInfo(png(2007, 2853, { colorType: 6 })), 'iphone-duo-inner').map(i => i.code)).toEqual(['alpha']);
});
test('upload: no API display type yet, so the sets are skipped with the message', () => {
test('upload: no ScreenshotDisplayType; Duo goes through the App Asset Library (IPHONE_DUO group)', () => {
expect(Object.values(IPHONE_DUO_DISPLAY).every(t => t === null)).toBe(true);
expect(Object.keys(displaySets({ iphoneDuo: true }))).not.toContain('iphone-duo-outer');
expect(skippedDuoSets({})).toBeNull();
expect(skippedDuoSets({ iphoneDuo: true })).toEqual({ dirs: ['iphone-duo-outer', 'iphone-duo-inner'], msg: expect.stringContaining(`iphone-duo-outer, iphone-duo-inner: ${IPHONE_DUO_NOT_IN_API}. Add them in App Store Connect > the iOS version page > App Previews and Screenshots > iPhone Duo: https://`) });
expect(skippedDuoSets({ iphoneDuo: true, appId: '1234567890' })!.msg).toContain('/apps/1234567890/distribution/ios/version/inflight');
expect(duoDirs({})).toEqual([]);
expect(duoDirs({ iphoneDuo: true })).toEqual(['iphone-duo-outer', 'iphone-duo-inner']);
const ref = [{ attributes: { placementProfileGroups: [
{ placementProfileGroupId: 'g-67', platform: 'IOS', displayClassId: 'IPHONE_DYNAMIC_ISLAND_LARGE_DISPLAY' },
{ placementProfileGroupId: 'g-duo', platform: 'IOS', displayClassId: 'IPHONE_DUO' },
{ placementProfileGroupId: 'g-duo', platform: 'IOS', displayClassId: 'IPHONE_DUO' },
{ placementProfileGroupId: 'g-mac', platform: 'MAC_OS', displayClassId: 'IPHONE_DUO' }] } }];
expect(duoGroups(ref)).toEqual(['g-duo']);
expect(duoGroups([])).toEqual([]);
expect(duoUpToDate(['a', 'b'], ['a', 'b'])).toBe(true);
expect(duoUpToDate(['b', 'a'], ['a', 'b'])).toBe(false);
expect(duoUpToDate(['a', undefined], ['a', 'b'])).toBe(false);
});
test('check / validate note: info before April 2027, never a blocker; points Play to the tablet sets', () => {
const before = new Date('2026-10-18'), after = new Date('2027-04-02');
@ -106,7 +116,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(n.msg).toContain('apple screenshots uploads them through the App Asset Library');
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')!;