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.
77 lines
6.2 KiB
TypeScript
77 lines
6.2 KiB
TypeScript
// 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 } } } };
|
|
}
|