Product Page Optimization through the API (apple ppo) and an optional 6.3" iPhone size key
apple ppo status | create --variant b[,c] | upload --variant b | start | stop: v2 experiments (platform IOS, traffic proportion), one treatment per shots variant (max 3), a treatment localization per App Store locale, and the variant renders synced into the treatment screenshot sets (shared MD5-idempotent set sync with apple screenshots, which no longer creates sets on --dry). start / stop ask first and are not MCP tools; MCP: apple_ppo_status, apple_ppo_create, apple_ppo_upload. Play experiments stay console-only (documented). apple.iphoneMedium (off by default): iphone-medium size key, 1206 x 2622 (1179 x 2556 accepted), uploaded as APP_IPHONE_61; rendered, checked, required and uploaded only when enabled. Apple's scaling rule documented in the config and the store-screenshots skill.
This commit is contained in:
parent
20db1345e4
commit
d3cc7e43d4
12 changed files with 356 additions and 28 deletions
126
cli/apple/ppo.ts
Normal file
126
cli/apple/ppo.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
// App Store Product Page Optimization through the App Store Connect API, wired to the `shots` variants.
|
||||||
|
// storebound apple ppo status
|
||||||
|
// storebound apple ppo create --variant b[,c] [--name "..."] [--traffic 30] [--dry]
|
||||||
|
// storebound apple ppo upload --variant b [--experiment "..."] [--only en-US,tr] [--src store/renders] [--dry]
|
||||||
|
// storebound apple ppo start | stop [--experiment "..."] [--dry] [--yes]
|
||||||
|
// create: one test (v2 experiment, platform IOS) with one treatment per variant (named after the variant, at most 3)
|
||||||
|
// and a treatment localization per App Store locale of the config. upload: the variant renders
|
||||||
|
// (<renders>/<variant>/<lang>/ios69 ... from `storebound shots render --variant b`) into each treatment localization's
|
||||||
|
// screenshot sets, MD5-idempotent like `apple screenshots`. start / stop change what players see (stop is final, a test
|
||||||
|
// cannot be restarted) and ask first; they are not MCP tools. Google Play store listing experiments have no API: they
|
||||||
|
// stay in Play Console (store-screenshots skill). Pure planners: cli/lib/ppo.ts.
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Ctx } from '../lib/ctx';
|
||||||
|
import { byAppleLocale, confirm, need } from '../lib/ctx';
|
||||||
|
import { done, log, warn } from '../lib/log';
|
||||||
|
import {
|
||||||
|
checkCreate, createExperimentReq, defaultName, type Experiment, experimentFromApi, pickExperiment, planTreatmentLocales, planTreatments, startStopReq,
|
||||||
|
} from '../lib/ppo';
|
||||||
|
import { textTable } from '../lib/table';
|
||||||
|
import { numberedImages } from '../play/images';
|
||||||
|
import { variantRoot } from '../shots/lib';
|
||||||
|
import { type Asc, ascClient, type J } from './asc';
|
||||||
|
import { execute } from './jsonapi';
|
||||||
|
import { displaySets, syncScreenshotSet } from './screenshots';
|
||||||
|
|
||||||
|
const TAG = 'ppo';
|
||||||
|
const USAGE = 'usage: storebound apple ppo status | create --variant b | upload --variant b | start | stop';
|
||||||
|
|
||||||
|
async function experiments(c: Asc, appId: string): Promise<Experiment[]> {
|
||||||
|
return (await c.all(`/v1/apps/${appId}/appStoreVersionExperimentsV2?limit=50`)).map(experimentFromApi);
|
||||||
|
}
|
||||||
|
const treatments = (c: Asc, expId: string) => c.all(`/v2/appStoreVersionExperiments/${expId}/appStoreVersionExperimentTreatments?limit=50`);
|
||||||
|
const treatmentLocs = (c: Asc, tId: string) => c.all(`/v1/appStoreVersionExperimentTreatments/${tId}/appStoreVersionExperimentTreatmentLocalizations?limit=50`);
|
||||||
|
|
||||||
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
|
const { config, root, resolve } = ctx.loaded;
|
||||||
|
const a = ctx.args;
|
||||||
|
const sub = a._[0];
|
||||||
|
if (!sub) throw new Error(USAGE);
|
||||||
|
const dry = a.has('dry');
|
||||||
|
const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId);
|
||||||
|
const appId = await c.app();
|
||||||
|
const list = await experiments(c, appId);
|
||||||
|
const locales = config.languages.map(l => l.apple).filter((x): x is string => !!x);
|
||||||
|
|
||||||
|
if (sub === 'status') {
|
||||||
|
if (!list.length) { done(TAG, 'no Product Page Optimization tests (storebound apple ppo create --variant b)'); return; }
|
||||||
|
console.log(textTable(['test', 'state', 'traffic', 'start', 'end'], list.map(x => [x.name, x.state, x.trafficProportion !== undefined ? `${x.trafficProportion}%` : '', (x.startDate ?? '').slice(0, 10), (x.endDate ?? '').slice(0, 10)])));
|
||||||
|
for (const x of list.filter(e => !['COMPLETED', 'STOPPED'].includes(e.state))) {
|
||||||
|
for (const t of await treatments(c, x.id)) {
|
||||||
|
const locs = await treatmentLocs(c, t.id);
|
||||||
|
let shots = 0;
|
||||||
|
for (const l of locs) for (const s of await c.all(`/v1/appStoreVersionExperimentTreatmentLocalizations/${l.id}/appScreenshotSets?limit=50`)) shots += (await c.all(`/v1/appScreenshotSets/${s.id}/appScreenshots?limit=50`)).length;
|
||||||
|
log(TAG, `"${x.name}" treatment "${t.attributes.name}": ${locs.length} locale(s), ${shots} screenshot(s)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log(TAG, 'results (conversion per treatment) are in App Store Connect > Product Page Optimization; no API returns them');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'create') {
|
||||||
|
const variants = a.list('variant');
|
||||||
|
const traffic = a.num('traffic', 30);
|
||||||
|
const problems = checkCreate(variants, traffic, Object.keys(config.shots?.variants ?? {}));
|
||||||
|
if (problems.length) throw new Error(problems.join('; '));
|
||||||
|
const name = a.get('name') ?? defaultName(variants);
|
||||||
|
let exp = pickExperiment(list, name);
|
||||||
|
if (!exp) {
|
||||||
|
if (dry) { await execute(c.asc, [createExperimentReq(appId, name, traffic)], { dry, tag: TAG }); log(TAG, `then: ${variants.length} treatment(s) x ${locales.length} locale(s)`); done(TAG, 'create checked (dry run)'); return; }
|
||||||
|
const r = (await execute(c.asc, [createExperimentReq(appId, name, traffic)], { dry, tag: TAG }))[0] as J;
|
||||||
|
exp = experimentFromApi(r.data);
|
||||||
|
} else log(TAG, `experiment "${name}": exists (${exp.state})`);
|
||||||
|
const ts = await treatments(c, exp.id);
|
||||||
|
const created = (await execute(c.asc, planTreatments(exp.id, ts.map((t: J) => ({ id: t.id, name: t.attributes.name })), variants), { dry, tag: TAG })) as J[];
|
||||||
|
const all = [...ts, ...created.map(r => r.data)].filter(t => variants.includes(t.attributes.name));
|
||||||
|
for (const t of all) await execute(c.asc, planTreatmentLocales(t.id, (await treatmentLocs(c, t.id)).map((l: J) => l.attributes.locale), locales), { dry, tag: TAG });
|
||||||
|
done(TAG, dry ? 'create checked (dry run)' : `test "${name}" ready: storebound apple ppo upload --variant ${variants[0]}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'upload') {
|
||||||
|
const variant = need(a.get('variant'), '--variant <id>');
|
||||||
|
const exp = pickExperiment(list, a.get('experiment'));
|
||||||
|
if (!exp) throw new Error('no editable Product Page Optimization test (storebound apple ppo create --variant ' + variant + ')');
|
||||||
|
const t = (await treatments(c, exp.id)).find((x: J) => x.attributes.name === variant);
|
||||||
|
if (!t) throw new Error(`test "${exp.name}" has no treatment "${variant}" (storebound apple ppo create --variant ${variant} --name "${exp.name}")`);
|
||||||
|
const src = variantRoot(resolve(a.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), variant, config.shots?.variants);
|
||||||
|
const only = a.list('only');
|
||||||
|
const langOf = byAppleLocale(config.languages);
|
||||||
|
let sets = 0;
|
||||||
|
for (const loc of await treatmentLocs(c, t.id)) {
|
||||||
|
const locale: string = loc.attributes.locale;
|
||||||
|
if (only.length && !only.includes(locale)) continue;
|
||||||
|
const lang = langOf.get(locale);
|
||||||
|
if (!lang) { warn(TAG, `${locale}: no language with apple: "${locale}", skipped`); continue; }
|
||||||
|
const existing = await c.all(`/v1/appStoreVersionExperimentTreatmentLocalizations/${loc.id}/appScreenshotSets?limit=50`);
|
||||||
|
for (const [dir, type] of Object.entries(displaySets(config.apple))) {
|
||||||
|
const d = join(src, lang.id, dir);
|
||||||
|
const files = existsSync(d) ? numberedImages(d).filter(f => f.endsWith('.png')).slice(0, 10) : [];
|
||||||
|
if (!files.length) { warn(TAG, `${locale}: nothing in ${d} (storebound shots render --variant ${variant}), skipped`); continue; }
|
||||||
|
await syncScreenshotSet(c, { rel: 'appStoreVersionExperimentTreatmentLocalization', type: 'appStoreVersionExperimentTreatmentLocalizations', id: loc.id }, existing, type, files, `${variant} ${locale} ${type}`, dry);
|
||||||
|
sets++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
done(TAG, `${sets} screenshot set(s) ${dry ? 'checked (dry run)' : 'synced'} for treatment "${variant}" of "${exp.name}"`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sub === 'start' || sub === 'stop') {
|
||||||
|
const start = sub === 'start';
|
||||||
|
const name = a.get('experiment');
|
||||||
|
const exp = name ? list.find(x => x.name === name) ?? null : start ? pickExperiment(list) : list.find(x => ['WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'].includes(x.state) || (x.startDate && !x.endDate && !['COMPLETED', 'STOPPED'].includes(x.state))) ?? null;
|
||||||
|
if (!exp) throw new Error(`no test to ${sub}${name ? ` named "${name}"` : ''} (storebound apple ppo status)`);
|
||||||
|
const req = startStopReq(exp, start);
|
||||||
|
if (dry) { await execute(c.asc, [req], { dry, tag: TAG }); return; }
|
||||||
|
const q = start
|
||||||
|
? `Start "${exp.name}" (${exp.trafficProportion ?? '?'}% of App Store visitors see a treatment)? Treatments not yet approved go to App Review with your next version first.`
|
||||||
|
: `Stop "${exp.name}" now? A stopped test cannot be restarted.`;
|
||||||
|
if (!(await confirm(ctx, q))) return;
|
||||||
|
await execute(c.asc, [req], { dry, tag: TAG });
|
||||||
|
done(TAG, `${sub} requested for "${exp.name}" (storebound apple ppo status)`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error(`unknown ppo command "${sub}"\n${USAGE}`);
|
||||||
|
}
|
||||||
|
|
@ -14,20 +14,40 @@ import { byAppleLocale, need } from '../lib/ctx';
|
||||||
import { done, log, warn } from '../lib/log';
|
import { done, log, warn } from '../lib/log';
|
||||||
import { numberedImages } from '../play/images';
|
import { numberedImages } from '../play/images';
|
||||||
import { variantRoot } from '../shots/lib';
|
import { variantRoot } from '../shots/lib';
|
||||||
import { ascClient, md5, setUpToDate, type J } from './asc';
|
import { type Asc, ascClient, md5, setUpToDate, type J } from './asc';
|
||||||
|
|
||||||
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
|
/** Render size key -> ASC screenshotDisplayType (iPhone sets). */
|
||||||
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
|
export const DISPLAY: Record<string, string> = { ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65' };
|
||||||
/** 13" iPad set, uploaded only when apple.ipad is true. */
|
/** 13" iPad set, uploaded only when apple.ipad is true. */
|
||||||
export const IPAD_DISPLAY: Record<string, string> = { ipad13: 'APP_IPAD_PRO_3GEN_129' };
|
export const IPAD_DISPLAY: Record<string, string> = { ipad13: 'APP_IPAD_PRO_3GEN_129' };
|
||||||
|
|
||||||
/** Display sets this project uploads: the iPhone sets, plus the 13" iPad set with apple.ipad. */
|
/** 6.3" iPhone (Dynamic Island, medium display), uploaded only when apple.iphoneMedium is true. */
|
||||||
export const displaySets = (apple: { ipad?: boolean } | undefined): Record<string, string> => ({ ...DISPLAY, ...(apple?.ipad ? IPAD_DISPLAY : {}) });
|
export const MEDIUM_DISPLAY: Record<string, string> = { 'iphone-medium': 'APP_IPHONE_61' };
|
||||||
|
|
||||||
|
/** Display sets this project uploads: the iPhone sets, plus the 6.3" set with apple.iphoneMedium and the 13" iPad set
|
||||||
|
* with apple.ipad. */
|
||||||
|
export const displaySets = (apple: { ipad?: boolean; iphoneMedium?: boolean } | undefined): Record<string, string> =>
|
||||||
|
({ ...DISPLAY, ...(apple?.iphoneMedium ? MEDIUM_DISPLAY : {}), ...(apple?.ipad ? IPAD_DISPLAY : {}) });
|
||||||
|
|
||||||
/** Display types `validate` requires on every locale: apple.screenshotTypes, else 6.9" iPhone (+ 13" iPad with ipad). */
|
/** Display types `validate` requires on every locale: apple.screenshotTypes, else 6.9" iPhone (+ 13" iPad with ipad). */
|
||||||
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
|
export const requiredDisplayTypes = (apple: { ipad?: boolean; screenshotTypes?: string[] } | undefined): string[] =>
|
||||||
apple?.screenshotTypes ?? ['APP_IPHONE_67', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
apple?.screenshotTypes ?? ['APP_IPHONE_67', ...(apple?.ipad ? ['APP_IPAD_PRO_3GEN_129'] : [])];
|
||||||
|
|
||||||
|
/** Makes one screenshot set (of a version localization, or of a Product Page Optimization treatment localization)
|
||||||
|
* hold exactly `files`, in order: left alone when the MD5s match, else emptied and re-uploaded. Creates the set. */
|
||||||
|
export async function syncScreenshotSet(c: Asc, parent: { rel: string; type: string; id: string }, sets: J[], type: string, files: string[], tag: string, dry: boolean): Promise<void> {
|
||||||
|
let set = sets.find(s => s.attributes.screenshotDisplayType === type);
|
||||||
|
const existing = set ? await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots`) : [];
|
||||||
|
const want = files.map(f => md5(readFileSync(f)));
|
||||||
|
if (set && setUpToDate(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: s.attributes.assetDeliveryState?.state })), want)) { log('apple', `${tag}: ${files.length} up to date`); return; }
|
||||||
|
if (dry) { log('apple', `${tag}: would ${set ? `replace ${existing.length}` : 'create the set and upload'} with ${files.length}`); return; }
|
||||||
|
if (!set) set = (await c.asc('POST', '/v1/appScreenshotSets', { data: { type: 'appScreenshotSets', attributes: { screenshotDisplayType: type },
|
||||||
|
relationships: { [parent.rel]: { data: { type: parent.type, id: parent.id } } } } })).data;
|
||||||
|
for (const s of existing) await c.asc('DELETE', `/v1/appScreenshots/${s.id}`);
|
||||||
|
for (const f of files) await c.uploadAsset('appScreenshots', { appScreenshotSet: { data: { type: 'appScreenshotSets', id: set.id } } }, f);
|
||||||
|
log('apple', `${tag}: uploaded ${files.length} (removed ${existing.length})`);
|
||||||
|
}
|
||||||
|
|
||||||
export async function run(ctx: Ctx): Promise<void> {
|
export async function run(ctx: Ctx): Promise<void> {
|
||||||
const { config, root, resolve } = ctx.loaded;
|
const { config, root, resolve } = ctx.loaded;
|
||||||
const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants);
|
const src = variantRoot(resolve(ctx.args.get('src') ?? config.renders ?? config.shots?.out ?? 'store/renders'), ctx.args.get('variant'), config.shots?.variants);
|
||||||
|
|
@ -40,18 +60,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
||||||
const langOf = byAppleLocale(config.languages);
|
const langOf = byAppleLocale(config.languages);
|
||||||
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
|
const locs = await c.all(`/v1/appStoreVersions/${version.id}/appStoreVersionLocalizations?limit=50`);
|
||||||
|
|
||||||
async function syncSet(locId: string, sets: J[], type: string, files: string[], tag: string): Promise<void> {
|
const syncSet = (locId: string, sets: J[], type: string, files: string[], tag: string) =>
|
||||||
let set = sets.find(s => s.attributes.screenshotDisplayType === type);
|
syncScreenshotSet(c, { rel: 'appStoreVersionLocalization', type: 'appStoreVersionLocalizations', id: locId }, sets, type, files, tag, ctx.args.has('dry'));
|
||||||
if (!set) set = (await c.asc('POST', '/v1/appScreenshotSets', { data: { type: 'appScreenshotSets', attributes: { screenshotDisplayType: type },
|
|
||||||
relationships: { appStoreVersionLocalization: { data: { type: 'appStoreVersionLocalizations', id: locId } } } } })).data;
|
|
||||||
const existing = await c.all(`/v1/appScreenshotSets/${set.id}/appScreenshots`);
|
|
||||||
const want = files.map(f => md5(readFileSync(f)));
|
|
||||||
if (setUpToDate(existing.map(s => ({ checksum: s.attributes.sourceFileChecksum, state: s.attributes.assetDeliveryState?.state })), want)) { log('apple', `${tag}: ${files.length} up to date`); return; }
|
|
||||||
if (ctx.args.has('dry')) { log('apple', `${tag}: would replace ${existing.length} with ${files.length}`); return; }
|
|
||||||
for (const s of existing) await c.asc('DELETE', `/v1/appScreenshots/${s.id}`);
|
|
||||||
for (const f of files) await c.uploadAsset('appScreenshots', { appScreenshotSet: { data: { type: 'appScreenshotSets', id: set.id } } }, f);
|
|
||||||
log('apple', `${tag}: uploaded ${files.length} (removed ${existing.length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tasks: (() => Promise<void>)[] = [];
|
const tasks: (() => Promise<void>)[] = [];
|
||||||
for (const loc of locs) {
|
for (const loc of locs) {
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,7 @@ const COMMANDS: Record<string, [() => Promise<Mod>, string]> = {
|
||||||
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first)'],
|
'apple release': [() => import('./apple/release'), 'phased release on|pause|resume|complete, --manual-release (asks first)'],
|
||||||
'apple next-build': [() => import('./apple/next-build'), 'next safe build number (prints the number)'],
|
'apple next-build': [() => import('./apple/next-build'), 'next safe build number (prints the number)'],
|
||||||
'apple encryption': [() => import('./apple/encryption'), 'export compliance answer for builds, from the config'],
|
'apple encryption': [() => import('./apple/encryption'), 'export compliance answer for builds, from the config'],
|
||||||
|
'apple ppo': [() => import('./apple/ppo'), 'Product Page Optimization: status | create --variant b | upload --variant b | start | stop (asks first)'],
|
||||||
'apple testflight': [() => import('./apple/testflight'), 'TestFlight: status | groups | testers (CSV in .storebound/) | what-to-test --build N | info | distribute --build N --group g [--submit-review]'],
|
'apple testflight': [() => import('./apple/testflight'), 'TestFlight: status | groups | testers (CSV in .storebound/) | what-to-test --build N | info | distribute --build N --group g [--submit-review]'],
|
||||||
'icons': [() => import('./icons'), 'App Store / Play / web / adaptive / legacy launcher icons from one artwork'],
|
'icons': [() => import('./icons'), 'App Store / Play / web / adaptive / legacy launcher icons from one artwork'],
|
||||||
'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright) | ios-sim (simctl) | android-emu (adb) | folder; --dry'],
|
'shots capture': [() => import('./shots/capture'), 'raw frames: --source web (playwright) | ios-sim (simctl) | android-emu (adb) | folder; --dry'],
|
||||||
|
|
|
||||||
|
|
@ -287,6 +287,13 @@ export interface ShipConfig {
|
||||||
screenshotTypes?: string[];
|
screenshotTypes?: string[];
|
||||||
/** The app runs on iPad: renders, checks and uploads 13" iPad screenshots (2064 x 2752) and iPad app previews. */
|
/** The app runs on iPad: renders, checks and uploads 13" iPad screenshots (2064 x 2752) and iPad app previews. */
|
||||||
ipad?: boolean;
|
ipad?: boolean;
|
||||||
|
/**
|
||||||
|
* Also render, check and upload a 6.3" iPhone set (Dynamic Island, medium display, 1206 x 2622 -> APP_IPHONE_61).
|
||||||
|
* Off by default: App Store Connect lists a medium-display screenshot as required for iPhone but fills it by
|
||||||
|
* scaling the 6.5" set down when it is missing, and the 6.9" set covers the large displays. Turn it on for
|
||||||
|
* pixel-exact art on 6.3" phones, or if Apple stops scaling. (Screenshot specifications, App Store Connect Help.)
|
||||||
|
*/
|
||||||
|
iphoneMedium?: boolean;
|
||||||
/** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */
|
/** TestFlight beta groups, the tester CSV (in .storebound/) and Test Information (`storebound apple testflight`). */
|
||||||
testflight?: TfConfig;
|
testflight?: TfConfig;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
77
cli/lib/ppo.ts
Normal file
77
cli/lib/ppo.ts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// App Store Product Page Optimization (PPO) through the App Store Connect API, as pure planners. The command
|
||||||
|
// (cli/apple/ppo.ts) reads the state, plans, prints on --dry and sends; start / stop ask first and are not MCP tools.
|
||||||
|
// Sources (checked October 2026):
|
||||||
|
// https://developer.apple.com/documentation/appstoreconnectapi/app-store-version-experiments
|
||||||
|
// POST /v2/appStoreVersionExperiments {name, platform, trafficProportion; app}; PATCH {name, started,
|
||||||
|
// trafficProportion}; GET /v1/apps/{id}/appStoreVersionExperimentsV2; states PREPARE_FOR_SUBMISSION, READY_FOR_REVIEW,
|
||||||
|
// WAITING_FOR_REVIEW, IN_REVIEW, ACCEPTED, APPROVED, REJECTED, COMPLETED, STOPPED.
|
||||||
|
// Treatments: POST /v1/appStoreVersionExperimentTreatments {name; appStoreVersionExperimentV2}; localizations: POST
|
||||||
|
// /v1/appStoreVersionExperimentTreatmentLocalizations {locale; appStoreVersionExperimentTreatment}; screenshot sets:
|
||||||
|
// POST /v1/appScreenshotSets with the appStoreVersionExperimentTreatmentLocalization relationship.
|
||||||
|
// App Store Connect Help (Product Page Optimization): up to 3 treatments; traffic proportion = share of users who
|
||||||
|
// see a treatment, split evenly; a test runs up to 90 days or until stopped and cannot be restarted; the app must be
|
||||||
|
// live; treatments not yet approved go through App Review with the next version.
|
||||||
|
// https://developer.apple.com/help/app-store-connect/create-product-page-optimization-tests/overview-of-product-page-optimization
|
||||||
|
import { type Req, one } from '../apple/jsonapi';
|
||||||
|
|
||||||
|
export const MAX_TREATMENTS = 3;
|
||||||
|
export const EDITABLE_STATES = ['PREPARE_FOR_SUBMISSION', 'READY_FOR_REVIEW', 'REJECTED'];
|
||||||
|
export const RUNNING_STATES = ['WAITING_FOR_REVIEW', 'IN_REVIEW', 'ACCEPTED', 'APPROVED'];
|
||||||
|
export const DONE_STATES = ['COMPLETED', 'STOPPED'];
|
||||||
|
|
||||||
|
export interface Experiment { id: string; name: string; state: string; trafficProportion?: number; started?: boolean; startDate?: string | null; endDate?: string | null }
|
||||||
|
|
||||||
|
export const experimentFromApi = (x: { id: string; attributes: Record<string, unknown> }): Experiment => ({
|
||||||
|
id: x.id, name: String(x.attributes.name ?? ''), state: String(x.attributes.state ?? ''), trafficProportion: x.attributes.trafficProportion as number | undefined,
|
||||||
|
startDate: (x.attributes.startDate as string | null | undefined) ?? null, endDate: (x.attributes.endDate as string | null | undefined) ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Problems with the create options. */
|
||||||
|
export function checkCreate(variants: string[], traffic: number, known: string[]): string[] {
|
||||||
|
const e: string[] = [];
|
||||||
|
if (!variants.length) e.push('--variant <id>[,<id>]: which shots.variants become treatments');
|
||||||
|
if (variants.length > MAX_TREATMENTS) e.push(`at most ${MAX_TREATMENTS} treatments per test`);
|
||||||
|
for (const v of variants) if (!known.includes(v)) e.push(`unknown variant "${v}" (${known.join(', ') || 'none in shots.variants'})`);
|
||||||
|
if (!(Number.isInteger(traffic) && traffic >= 1 && traffic <= 100)) e.push('--traffic: whole percent 1-100 (share of users who see a treatment, split evenly between treatments)');
|
||||||
|
return e;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultName = (variants: string[]): string => `storebound ${variants.join(' + ')}`.slice(0, 64);
|
||||||
|
|
||||||
|
export const createExperimentReq = (appId: string, name: string, traffic: number): Req => ({ method: 'POST', path: '/v2/appStoreVersionExperiments', note: `experiment "${name}"`,
|
||||||
|
body: { data: { type: 'appStoreVersionExperiments', attributes: { name, platform: 'IOS', trafficProportion: traffic }, relationships: { app: one('apps', appId) } } } });
|
||||||
|
|
||||||
|
/** The experiment to work on: by name, else the one test that can still be edited. Never a finished one. */
|
||||||
|
export function pickExperiment(list: Experiment[], name?: string): Experiment | null {
|
||||||
|
if (name) {
|
||||||
|
const hit = list.find(x => x.name === name);
|
||||||
|
if (hit && DONE_STATES.includes(hit.state)) throw new Error(`experiment "${name}" is ${hit.state} (a stopped or completed test cannot be restarted; create a new one)`);
|
||||||
|
return hit ?? null;
|
||||||
|
}
|
||||||
|
const open = list.filter(x => EDITABLE_STATES.includes(x.state));
|
||||||
|
if (open.length > 1) throw new Error(`several experiments can be edited (${open.map(x => `"${x.name}"`).join(', ')}): pass --experiment <name>`);
|
||||||
|
return open[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Treatments named after the variants that are missing. */
|
||||||
|
export function planTreatments(experimentId: string, existing: { id: string; name: string }[], variants: string[]): Req[] {
|
||||||
|
const have = new Set(existing.map(t => t.name));
|
||||||
|
const missing = variants.filter(v => !have.has(v));
|
||||||
|
if (existing.length + missing.length > MAX_TREATMENTS) throw new Error(`a test takes at most ${MAX_TREATMENTS} treatments (has ${existing.length}, wants ${missing.length} more)`);
|
||||||
|
return missing.map(v => ({ method: 'POST', path: '/v1/appStoreVersionExperimentTreatments', note: `treatment "${v}"`,
|
||||||
|
body: { data: { type: 'appStoreVersionExperimentTreatments', attributes: { name: v }, relationships: { appStoreVersionExperimentV2: one('appStoreVersionExperiments', experimentId) } } } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Treatment localizations for every App Store locale that is missing. */
|
||||||
|
export function planTreatmentLocales(treatmentId: string, existing: string[], locales: string[]): Req[] {
|
||||||
|
return locales.filter(l => !existing.includes(l)).map(locale => ({ method: 'POST', path: '/v1/appStoreVersionExperimentTreatmentLocalizations', note: locale,
|
||||||
|
body: { data: { type: 'appStoreVersionExperimentTreatmentLocalizations', attributes: { locale }, relationships: { appStoreVersionExperimentTreatment: one('appStoreVersionExperimentTreatments', treatmentId) } } } }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** PATCH started true / false. Starting sends unapproved treatments to App Review with the app; stopping is final. */
|
||||||
|
export function startStopReq(e: Experiment, start: boolean): Req {
|
||||||
|
if (start && !EDITABLE_STATES.includes(e.state) && e.state !== 'APPROVED' && e.state !== 'ACCEPTED') throw new Error(`experiment "${e.name}" is ${e.state}: cannot start`);
|
||||||
|
if (!start && DONE_STATES.includes(e.state)) throw new Error(`experiment "${e.name}" is already ${e.state}`);
|
||||||
|
return { method: 'PATCH', path: `/v2/appStoreVersionExperiments/${e.id}`, note: `${start ? 'start' : 'stop'} "${e.name}"`,
|
||||||
|
body: { data: { type: 'appStoreVersionExperiments', id: e.id, attributes: { started: start } } } };
|
||||||
|
}
|
||||||
|
|
@ -146,7 +146,7 @@ export const TOOLS: ToolDef[] = [
|
||||||
args: i => [...opt('lang', i.lang), ...opt('only', i.only), ...opt('device', i.device), ...opt('source', i.source)] },
|
args: i => [...opt('lang', i.lang), ...opt('only', i.only), ...opt('device', i.device), ...opt('source', i.source)] },
|
||||||
{ name: 'shots_render', title: 'Render store screenshots', command: ['shots', 'render'], write: true, network: false, idempotent: true,
|
{ name: 'shots_render', title: 'Render store screenshots', command: ['shots', 'render'], write: true, network: false, idempotent: true,
|
||||||
description: 'Store screenshots + Play feature graphic in every size and language from the frames and the copy file (local PNGs, needs Playwright + ffmpeg; slow for many languages). dry_run lists what would be rendered.',
|
description: 'Store screenshots + Play feature graphic in every size and language from the frames and the copy file (local PNGs, needs Playwright + ffmpeg; slow for many languages). dry_run lists what would be rendered.',
|
||||||
props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, ipad13, play, tab7, tab10, feature' },
|
props: { lang, size: { type: 'string', description: 'Comma-separated size keys: ios69, ios65, iphone-medium, ipad13, play, tab7, tab10, feature' },
|
||||||
scenes: { type: 'string', description: 'Comma-separated scene ids' }, variant, all_variants: { type: 'boolean', default: false, description: 'Base + every variant' } },
|
scenes: { type: 'string', description: 'Comma-separated scene ids' }, variant, all_variants: { type: 'boolean', default: false, description: 'Base + every variant' } },
|
||||||
args: i => [...opt('lang', i.lang), ...opt('size', i.size), ...opt('scenes', i.scenes), ...(i.all_variants === true ? ['--variants'] : opt('variant', i.variant))] },
|
args: i => [...opt('lang', i.lang), ...opt('size', i.size), ...opt('scenes', i.scenes), ...(i.all_variants === true ? ['--variants'] : opt('variant', i.variant))] },
|
||||||
{ name: 'shots_gallery', title: 'Review gallery', command: ['shots', 'gallery'], write: true, network: false, idempotent: true,
|
{ name: 'shots_gallery', title: 'Review gallery', command: ['shots', 'gallery'], write: true, network: false, idempotent: true,
|
||||||
|
|
@ -181,13 +181,24 @@ export const TOOLS: ToolDef[] = [
|
||||||
props: { build: { type: 'string', pattern: '^[0-9][0-9.]*$', description: 'Exact build number you uploaded' }, groups: { type: 'string', description: 'Comma-separated internal group names' },
|
props: { build: { type: 'string', pattern: '^[0-9][0-9.]*$', description: 'Exact build number you uploaded' }, groups: { type: 'string', description: 'Comma-separated internal group names' },
|
||||||
version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' } },
|
version: { type: 'string', pattern: '^\\d+(\\.\\d+){0,2}$' } },
|
||||||
required: ['build', 'groups'], args: i => [...opt('build', i.build), ...opt('group', i.groups), ...opt('version', i.version), '--internal-only'] },
|
required: ['build', 'groups'], args: i => [...opt('build', i.build), ...opt('group', i.groups), ...opt('version', i.version), '--internal-only'] },
|
||||||
|
{ name: 'apple_ppo_status', title: 'Product Page Optimization status', command: ['apple', 'ppo', 'status'], write: false, network: true,
|
||||||
|
description: 'Read-only list of App Store Product Page Optimization tests (state, traffic, dates) with their treatments, locales and screenshot counts. Results per treatment are only in App Store Connect.' },
|
||||||
|
{ name: 'apple_ppo_create', title: 'Create a PPO test', command: ['apple', 'ppo', 'create'], write: true, network: true, idempotent: true,
|
||||||
|
description: 'Create an App Store Product Page Optimization test (not started) with one treatment per shots variant (max 3) and a treatment localization per App Store locale. Starting it is an owner-only terminal command.',
|
||||||
|
props: { variants: { type: 'string', pattern: '^[a-z0-9][a-z0-9-]*(,[a-z0-9][a-z0-9-]*){0,2}$', description: 'Comma-separated shots.variants ids, e.g. b or b,c' },
|
||||||
|
name: { type: 'string', description: 'Test name (default "storebound <variants>")' }, traffic: { type: 'integer', minimum: 1, maximum: 100, default: 30, description: 'Percent of visitors who see a treatment' } },
|
||||||
|
required: ['variants'], args: i => [...opt('variant', i.variants), ...opt('name', i.name), ...opt('traffic', i.traffic)] },
|
||||||
|
{ name: 'apple_ppo_upload', title: 'Upload PPO treatment screenshots', command: ['apple', 'ppo', 'upload'], write: true, destructive: true, network: true, idempotent: true,
|
||||||
|
description: 'Sync the screenshots of one shots variant (renders/<variant>/<lang>/...) into the matching treatment of the editable Product Page Optimization test, per locale (MD5-idempotent; changed sets are replaced). Nothing is shown to players until the owner starts the test.',
|
||||||
|
props: { variant, experiment: { type: 'string', description: 'Test name (default: the one editable test)' }, only },
|
||||||
|
required: ['variant'], args: i => [...opt('variant', i.variant), ...opt('experiment', i.experiment), ...opt('only', i.only)] },
|
||||||
{ name: 'icons', title: 'Generate icons', command: ['icons'], write: true, network: false, idempotent: true,
|
{ name: 'icons', title: 'Generate icons', command: ['icons'], write: true, network: false, idempotent: true,
|
||||||
description: 'Write every store / launcher / web icon from the artwork in the config (local files, needs ffmpeg).' },
|
description: 'Write every store / launcher / web icon from the artwork in the config (local files, needs ffmpeg).' },
|
||||||
];
|
];
|
||||||
|
|
||||||
/** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */
|
/** CLI flags / commands that must never be reachable through MCP. Tests assert none of the tools can produce them. */
|
||||||
export const FORBIDDEN_ARGS = ['--yes', '--manual-release', '--cancel-submission', '--production', 'rollout', 'resume', 'complete',
|
export const FORBIDDEN_ARGS = ['--yes', '--manual-release', '--cancel-submission', '--production', 'rollout', 'resume', 'complete',
|
||||||
'--submit-review', '--remove', '--invite', 'testers', 'send'];
|
'--submit-review', '--remove', '--invite', 'testers', 'send', 'start', 'stop'];
|
||||||
|
|
||||||
export function inputSchema(t: ToolDef): Record<string, unknown> {
|
export function inputSchema(t: ToolDef): Record<string, unknown> {
|
||||||
const props: Record<string, JsonSchema> = { ...(t.props ?? {}) };
|
const props: Record<string, JsonSchema> = { ...(t.props ?? {}) };
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
|
// Pure parts of the screenshot pipeline: store sizes, palettes, per-language script fonts, copy validation, job data.
|
||||||
import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config';
|
import type { Palette, ShipConfig, ShotScene, ShotsConfig } from '../lib/config';
|
||||||
|
|
||||||
/** Store image sizes. ios69 = 6.9" (APP_IPHONE_67), ios65 = 6.5" (APP_IPHONE_65), ipad13 = 13" iPad
|
/** Store image sizes. ios69 = 6.9" (APP_IPHONE_67), ios65 = 6.5" (APP_IPHONE_65), iphone-medium = 6.3" iPhone with
|
||||||
|
* Dynamic Island, medium display (APP_IPHONE_61, only with apple.iphoneMedium), ipad13 = 13" iPad
|
||||||
* (APP_IPAD_PRO_3GEN_129, only for apps that run on iPad), play = Play phone 9:16, tab7 / tab10 = Play 7" / 10"
|
* (APP_IPAD_PRO_3GEN_129, only for apps that run on iPad), play = Play phone 9:16, tab7 / tab10 = Play 7" / 10"
|
||||||
* tablets (tablet frames), feature = Play feature graphic. Rules per size: spec.ts. */
|
* tablets (tablet frames), feature = Play feature graphic. Rules per size: spec.ts. */
|
||||||
export const SIZES: Record<string, { w: number; h: number; device?: 'tablet'; feature?: true }> = {
|
export const SIZES: Record<string, { w: number; h: number; device?: 'tablet'; feature?: true }> = {
|
||||||
ios69: { w: 1320, h: 2868 },
|
ios69: { w: 1320, h: 2868 },
|
||||||
ios65: { w: 1284, h: 2778 },
|
ios65: { w: 1284, h: 2778 },
|
||||||
|
'iphone-medium': { w: 1206, h: 2622 },
|
||||||
ipad13: { w: 2064, h: 2752, device: 'tablet' },
|
ipad13: { w: 2064, h: 2752, device: 'tablet' },
|
||||||
play: { w: 1080, h: 1920 },
|
play: { w: 1080, h: 1920 },
|
||||||
tab7: { w: 1080, h: 1920, device: 'tablet' },
|
tab7: { w: 1080, h: 1920, device: 'tablet' },
|
||||||
|
|
@ -90,11 +92,12 @@ export function frameDims(config: Pick<ShipConfig, 'apple' | 'shots'>): Record<'
|
||||||
return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } };
|
return { phone: { ...DEFAULT_PHONE, ...config.shots?.phone }, tablet: { ...tablet, ...config.shots?.tablet } };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad),
|
/** Size keys this project renders by default: Apple sizes only with an apple section (iPad only with apple.ipad, the
|
||||||
|
* 6.3" iPhone only with apple.iphoneMedium),
|
||||||
* Play sizes only with a play section (both when neither is configured). */
|
* Play sizes only with a play section (both when neither is configured). */
|
||||||
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>): string[] {
|
export function defaultSizes(config: Pick<ShipConfig, 'apple' | 'play'>): string[] {
|
||||||
const apple = !!config.apple || !config.play, play = !!config.play || !config.apple;
|
const apple = !!config.apple || !config.play, play = !!config.play || !config.apple;
|
||||||
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k.startsWith('ios') ? apple : play));
|
return Object.keys(SIZES).filter(k => (k === 'ipad13' ? apple && !!config.apple?.ipad : k === 'iphone-medium' ? apple && !!config.apple?.iphoneMedium : k.startsWith('ios') ? apple : play));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */
|
/** Scenes of a variant (its order / subset, palette overrides), or the base scenes. */
|
||||||
|
|
|
||||||
|
|
@ -82,12 +82,12 @@ export function ffprobe(file: string): ProbeInfo | null {
|
||||||
|
|
||||||
/** Which sets the stores require for this config (missing ones are errors, others only checked when present). With a
|
/** Which sets the stores require for this config (missing ones are errors, others only checked when present). With a
|
||||||
* language id: Apple sets only when that language has an App Store locale, Play sets only when it has a Play code. */
|
* language id: Apple sets only when that language has an App Store locale, Play sets only when it has a Play code. */
|
||||||
export function requiredSets(c: { apple?: { ipad?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] },
|
export function requiredSets(c: { apple?: { ipad?: boolean; iphoneMedium?: boolean } | undefined; play?: unknown; languages?: { id: string; apple?: string; play?: string | string[] }[] },
|
||||||
lang?: string): Record<string, boolean> {
|
lang?: string): Record<string, boolean> {
|
||||||
const l = lang === undefined ? undefined : c.languages?.find(x => x.id === lang);
|
const l = lang === undefined ? undefined : c.languages?.find(x => x.id === lang);
|
||||||
const apple = !!c.apple && (!l || !!l.apple);
|
const apple = !!c.apple && (!l || !!l.apple);
|
||||||
const play = !!c.play && (!l || (Array.isArray(l.play) ? l.play.length > 0 : !!l.play));
|
const play = !!c.play && (!l || (Array.isArray(l.play) ? l.play.length > 0 : !!l.play));
|
||||||
return { ios69: apple, ipad13: apple && !!c.apple?.ipad, play, feature: play };
|
return { ios69: apple, ...(c.apple?.iphoneMedium ? { 'iphone-medium': apple } : {}), ipad13: apple && !!c.apple?.ipad, play, feature: play };
|
||||||
}
|
}
|
||||||
|
|
||||||
export const allIssues = (s: RendersScan): { where: string; issue: Issue }[] => [
|
export const allIssues = (s: RendersScan): { where: string; issue: Issue }[] => [
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@ const MB = 1024 * 1024;
|
||||||
export const IMAGE_SPECS: Record<string, ImageSpec> = {
|
export const IMAGE_SPECS: Record<string, ImageSpec> = {
|
||||||
// Apple accepts 1260x2736, 1290x2796 and 1320x2868 for "iPhone with Dynamic Island (large display)" = 6.9".
|
// Apple accepts 1260x2736, 1290x2796 and 1320x2868 for "iPhone with Dynamic Island (large display)" = 6.9".
|
||||||
ios69: { store: 'apple', type: 'APP_IPHONE_67', label: 'App Store iPhone 6.9"', sizes: [[1320, 2868], [1290, 2796], [1260, 2736]], min: 1, max: 10, maxBytes: null, alpha: false },
|
ios69: { store: 'apple', type: 'APP_IPHONE_67', label: 'App Store iPhone 6.9"', sizes: [[1320, 2868], [1290, 2796], [1260, 2736]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||||
|
// 6.3" iPhone with Dynamic Island, medium display (iPhone 14 Pro .. 18 Pro): optional here (apple.iphoneMedium). Apple
|
||||||
|
// scales the 6.5" (Face ID large) set down for it when it is missing. APP_IPHONE_61 is inferred: the enum has no
|
||||||
|
// descriptions and 61 is the only medium iPhone bucket (NOT VERIFIED against an upload).
|
||||||
|
'iphone-medium': { store: 'apple', type: 'APP_IPHONE_61', label: 'App Store iPhone 6.3"', sizes: [[1206, 2622], [1179, 2556]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||||
ios65: { store: 'apple', type: 'APP_IPHONE_65', label: 'App Store iPhone 6.5"', sizes: [[1284, 2778], [1242, 2688]], min: 1, max: 10, maxBytes: null, alpha: false },
|
ios65: { store: 'apple', type: 'APP_IPHONE_65', label: 'App Store iPhone 6.5"', sizes: [[1284, 2778], [1242, 2688]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||||
// 13" iPad display (iPad Pro M4/M5, iPad Air M2+): required when the app runs on iPad. API display type keeps the old name.
|
// 13" iPad display (iPad Pro M4/M5, iPad Air M2+): required when the app runs on iPad. API display type keeps the old name.
|
||||||
ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'App Store iPad 13"', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false },
|
ipad13: { store: 'apple', type: 'APP_IPAD_PRO_3GEN_129', label: 'App Store iPad 13"', sizes: [[2064, 2752], [2048, 2732]], min: 1, max: 10, maxBytes: null, alpha: false },
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
---
|
---
|
||||||
name: store-screenshots
|
name: store-screenshots
|
||||||
description: Produce store screenshots and the Play feature graphic for a mobile game in every required size and many languages - capture raw frames from the running game (web build via playwright, iOS Simulator via simctl, Android emulator or device via adb, or a folder of frames from any engine), write per-language marketing copy (incl. RTL Arabic and CJK fonts), render captioned scenes with device frames, review them in a local gallery, A/B variants for store listing experiments / Product Page Optimization, JPEG copies for Play, and upload to both stores. Use when the user needs App Store / Google Play screenshots, preview images, a feature graphic, localized screenshot captions, or screenshot sizes.
|
description: Produce store screenshots and the Play feature graphic for a mobile game in every required size and many languages - capture raw frames from the running game (web build via playwright, iOS Simulator via simctl, Android emulator or device via adb, or a folder of frames from any engine), write per-language marketing copy (incl. RTL Arabic and CJK fonts), render captioned scenes with device frames, review them in a local gallery, A/B variants for Play store listing experiments and App Store Product Page Optimization tests (created and filled through the API), JPEG copies for Play, and upload to both stores. Use when the user needs App Store / Google Play screenshots, preview images, a feature graphic, localized screenshot captions, or screenshot sizes.
|
||||||
---
|
---
|
||||||
|
|
||||||
# Store screenshots
|
# Store screenshots
|
||||||
|
|
@ -27,8 +27,10 @@ Sources (re-check before a release; Apple renames display classes often):
|
||||||
As of October 2026 the page marks "iPhone with Dynamic Island (medium display)" (1206 x 2622 / 1179 x 2556) as the
|
As of October 2026 the page marks "iPhone with Dynamic Island (medium display)" (1206 x 2622 / 1179 x 2556) as the
|
||||||
required iPhone class, and says a class without its own screenshots gets scaled ones from the next class: medium
|
required iPhone class, and says a class without its own screenshots gets scaled ones from the next class: medium
|
||||||
from Face ID large (6.5"), 6.5" from Dynamic Island large (6.9"). So the 6.9" (+ 6.5") set still covers every
|
from Face ID large (6.5"), 6.5" from Dynamic Island large (6.9"). So the 6.9" (+ 6.5") set still covers every
|
||||||
iPhone, and storebound keeps 6.9" as the default. If App Store Connect ever refuses to submit without the medium
|
iPhone, and storebound keeps 6.9" as the default. For pixel-exact art on 6.3" phones (or if App Store Connect ever
|
||||||
set, render a 1206 x 2622 copy by hand (and open an issue so it becomes a size key).
|
refuses to submit without the medium set), set `apple.iphoneMedium: true`: the `iphone-medium` size key (1206 x 2622,
|
||||||
|
uploaded as `APP_IPHONE_61`, a mapping inferred from the enum, not verified) is then rendered, checked and uploaded
|
||||||
|
like the others. It stays off by default.
|
||||||
- ASC API `ScreenshotDisplayType`: https://developer.apple.com/documentation/appstoreconnectapi/screenshotdisplaytype
|
- ASC API `ScreenshotDisplayType`: https://developer.apple.com/documentation/appstoreconnectapi/screenshotdisplaytype
|
||||||
(there is no "13 inch" value: the 13" iPad set is `APP_IPAD_PRO_3GEN_129`).
|
(there is no "13 inch" value: the 13" iPad set is `APP_IPAD_PRO_3GEN_129`).
|
||||||
- Play graphics: https://support.google.com/googleplay/android-developer/answer/9866151 (screenshots 320-3840 px, at
|
- Play graphics: https://support.google.com/googleplay/android-developer/answer/9866151 (screenshots 320-3840 px, at
|
||||||
|
|
@ -104,9 +106,19 @@ images there), so only do that after a test has a winner. What the stores offer,
|
||||||
treatments against the original, one test at a time, up to 90 days; icon, screenshots and app previews, localized
|
treatments against the original, one test at a time, up to 90 days; icon, screenshots and app previews, localized
|
||||||
per treatment; new metadata goes through App Review; alternate icons must ship in the binary; shown on iOS / iPadOS
|
per treatment; new metadata goes through App Review; alternate icons must ship in the binary; shown on iOS / iPadOS
|
||||||
15+. https://developer.apple.com/app-store/product-page-optimization/
|
15+. https://developer.apple.com/app-store/product-page-optimization/
|
||||||
The App Store Connect API has the resources (`appStoreVersionExperiments` v2, treatments, treatment localizations
|
Through the API with storebound (`appStoreVersionExperiments` v2, treatments, treatment localizations and their
|
||||||
with their own screenshot / preview sets), but storebound does not create or start experiments: set the test up in
|
screenshot sets):
|
||||||
App Store Connect and upload the treatment screenshots there (the variant folder already has the right sizes).
|
1. `storebound shots render --variant b` (and `shots check --variants`, `shots gallery`).
|
||||||
|
2. `storebound apple ppo create --variant b --traffic 30 --dry`, then without `--dry`: one test with one treatment
|
||||||
|
per variant (named after it, max 3 variants: `--variant b,c`) and a localization per App Store locale.
|
||||||
|
3. `storebound apple ppo upload --variant b --dry`, then for real: the variant renders into the treatment's
|
||||||
|
screenshot sets per locale (MD5-idempotent). MCP: `apple_ppo_create`, `apple_ppo_upload`, `apple_ppo_status`.
|
||||||
|
4. **Owner**: `storebound apple ppo start` (asks first; treatments that are not approved yet go to App Review with
|
||||||
|
the next version), later `storebound apple ppo stop` (final: a stopped test cannot be restarted; stopping through
|
||||||
|
the API is `started: false`, not verified against a live test, so if it is refused stop it in App Store
|
||||||
|
Connect). Neither is an MCP tool. Results per treatment are only in App Store Connect.
|
||||||
|
The app must be live (Ready for Distribution); alternate icons and app preview treatments stay App Store Connect
|
||||||
|
clicks.
|
||||||
- Test one thing per experiment (usually the first screenshot or the order), give it real traffic, and do not read
|
- Test one thing per experiment (usually the first screenshot or the order), give it real traffic, and do not read
|
||||||
results early.
|
results early.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ const config: ShipConfig = {
|
||||||
// the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.
|
// the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.
|
||||||
// usesNonExemptEncryption: false,
|
// usesNonExemptEncryption: false,
|
||||||
// ipad: true, // the app runs on iPad: 13" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded
|
// ipad: true, // the app runs on iPad: 13" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded
|
||||||
|
// iphoneMedium: true, // optional 6.3" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5" set down
|
||||||
// screenshotTypes: ['APP_IPHONE_67'], // what `storebound validate` requires per locale (default; + APP_IPAD_PRO_3GEN_129 with ipad)
|
// screenshotTypes: ['APP_IPHONE_67'], // what `storebound validate` requires per locale (default; + APP_IPAD_PRO_3GEN_129 with ipad)
|
||||||
// TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.
|
// TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.
|
||||||
testflight: {
|
testflight: {
|
||||||
|
|
|
||||||
76
tests/ppo.test.ts
Normal file
76
tests/ppo.test.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
import { displaySets } from '../cli/apple/screenshots';
|
||||||
|
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 } from '../cli/shots/spec';
|
||||||
|
import { png } from './fixtures';
|
||||||
|
|
||||||
|
describe('product page optimization', () => {
|
||||||
|
test('create options', () => {
|
||||||
|
expect(checkCreate(['b'], 30, ['b', 'c'])).toEqual([]);
|
||||||
|
expect(checkCreate([], 30, ['b']).join()).toContain('--variant');
|
||||||
|
expect(checkCreate(['b', 'c', 'd', 'e'], 30, ['b', 'c', 'd', 'e']).join()).toContain('at most 3');
|
||||||
|
expect(checkCreate(['z'], 30, ['b']).join()).toContain('unknown variant "z"');
|
||||||
|
expect(checkCreate(['b'], 0, ['b']).join()).toContain('--traffic');
|
||||||
|
expect(defaultName(['b', 'c'])).toBe('storebound b + c');
|
||||||
|
expect(createExperimentReq('app1', 'T', 30).body).toEqual({ data: { type: 'appStoreVersionExperiments', attributes: { name: 'T', platform: 'IOS', trafficProportion: 30 }, relationships: { app: { data: { type: 'apps', id: 'app1' } } } } });
|
||||||
|
});
|
||||||
|
test('picking a test', () => {
|
||||||
|
const list = [
|
||||||
|
experimentFromApi({ id: 'e1', attributes: { name: 'old', state: 'COMPLETED' } }),
|
||||||
|
experimentFromApi({ id: 'e2', attributes: { name: 'new', state: 'PREPARE_FOR_SUBMISSION', trafficProportion: 30 } }),
|
||||||
|
];
|
||||||
|
expect(pickExperiment(list)!.id).toBe('e2');
|
||||||
|
expect(pickExperiment(list, 'new')!.trafficProportion).toBe(30);
|
||||||
|
expect(pickExperiment(list, 'missing')).toBeNull();
|
||||||
|
expect(() => pickExperiment(list, 'old')).toThrow('cannot be restarted');
|
||||||
|
expect(() => pickExperiment([...list, experimentFromApi({ id: 'e3', attributes: { name: 'x', state: 'READY_FOR_REVIEW' } })])).toThrow('--experiment');
|
||||||
|
});
|
||||||
|
test('treatments and localizations', () => {
|
||||||
|
const t = planTreatments('e2', [{ id: 't1', name: 'b' }], ['b', 'c']);
|
||||||
|
expect(t).toHaveLength(1);
|
||||||
|
expect(t[0]!.body).toEqual({ data: { type: 'appStoreVersionExperimentTreatments', attributes: { name: 'c' }, relationships: { appStoreVersionExperimentV2: { data: { type: 'appStoreVersionExperiments', id: 'e2' } } } } });
|
||||||
|
expect(() => planTreatments('e2', [{ id: '1', name: 'a' }, { id: '2', name: 'b' }, { id: '3', name: 'c' }], ['d'])).toThrow('at most 3');
|
||||||
|
const l = planTreatmentLocales('t1', ['en-US'], ['en-US', 'tr']);
|
||||||
|
expect(l.map(r => (r.body as { data: { attributes: { locale: string } } }).data.attributes.locale)).toEqual(['tr']);
|
||||||
|
expect((l[0]!.body as { data: { relationships: unknown } }).data.relationships).toEqual({ appStoreVersionExperimentTreatment: { data: { type: 'appStoreVersionExperimentTreatments', id: 't1' } } });
|
||||||
|
});
|
||||||
|
test('start / stop', () => {
|
||||||
|
const e = experimentFromApi({ id: 'e2', attributes: { name: 'new', state: 'PREPARE_FOR_SUBMISSION' } });
|
||||||
|
expect(startStopReq(e, true)).toMatchObject({ method: 'PATCH', path: '/v2/appStoreVersionExperiments/e2', body: { data: { type: 'appStoreVersionExperiments', id: 'e2', attributes: { started: true } } } });
|
||||||
|
expect(() => startStopReq({ ...e, state: 'STOPPED' }, false)).toThrow('already STOPPED');
|
||||||
|
expect(() => startStopReq({ ...e, state: 'COMPLETED' }, true)).toThrow('cannot start');
|
||||||
|
});
|
||||||
|
test('mcp: status / create / upload, never start or stop', () => {
|
||||||
|
const t = (n: string) => TOOLS.find(x => x.name === n)!;
|
||||||
|
expect(t('apple_ppo_status').write).toBe(false);
|
||||||
|
expect(toArgv(t('apple_ppo_create'), { variants: 'b,c', traffic: 20 }).argv).toEqual(['apple', 'ppo', 'create', '--variant', 'b,c', '--traffic', '20', '--dry']);
|
||||||
|
expect(toArgv(t('apple_ppo_upload'), { variant: 'b', only: 'en-US' }).argv).toEqual(['apple', 'ppo', 'upload', '--variant', 'b', '--only', 'en-US', '--dry']);
|
||||||
|
expect(validateInput(t('apple_ppo_create'), { variants: 'a,b,c,d' })[0]).toContain('does not match');
|
||||||
|
expect(FORBIDDEN_ARGS).toEqual(expect.arrayContaining(['start', 'stop']));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('optional 6.3" iPhone size (iphone-medium)', () => {
|
||||||
|
const base = { app: { name: 'X', privacyPolicyUrl: 'https://example.com/p', supportUrl: 'https://example.com', website: 'https://example.com', contactEmail: 'a@example.com' }, languages: [{ id: 'en', apple: 'en-US' }] };
|
||||||
|
test('off by default, on with apple.iphoneMedium', () => {
|
||||||
|
expect(SIZES['iphone-medium']).toEqual({ w: 1206, h: 2622 });
|
||||||
|
expect(defaultSizes({ apple: { bundleId: 'x' } })).not.toContain('iphone-medium');
|
||||||
|
expect(defaultSizes({ apple: { bundleId: 'x', iphoneMedium: true } })).toContain('iphone-medium');
|
||||||
|
expect(defaultSizes({ play: { packageName: 'x' } })).not.toContain('iphone-medium');
|
||||||
|
expect(Object.keys(displaySets({}))).toEqual(['ios69', 'ios65']);
|
||||||
|
expect(displaySets({ iphoneMedium: true, ipad: true })).toEqual({ ios69: 'APP_IPHONE_67', ios65: 'APP_IPHONE_65', 'iphone-medium': 'APP_IPHONE_61', ipad13: 'APP_IPAD_PRO_3GEN_129' });
|
||||||
|
expect(requiredSets({ apple: {} })['iphone-medium']).toBeUndefined();
|
||||||
|
expect(requiredSets({ apple: { iphoneMedium: true } })['iphone-medium']).toBe(true);
|
||||||
|
expect(validateConfig({ ...base, apple: { bundleId: 'com.example.game', iphoneMedium: true } })).toEqual([]);
|
||||||
|
});
|
||||||
|
test('accepted pixel sizes', () => {
|
||||||
|
expect(IMAGE_SPECS['iphone-medium']!.type).toBe('APP_IPHONE_61');
|
||||||
|
expect(checkImage(imageInfo(png(1206, 2622)), 'iphone-medium')).toEqual([]);
|
||||||
|
expect(checkImage(imageInfo(png(1179, 2556)), 'iphone-medium')).toEqual([]);
|
||||||
|
expect(checkImage(imageInfo(png(1320, 2868)), 'iphone-medium').map(i => i.code)).toEqual(['size']);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue