// 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 }): 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 [,]: 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 `); 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 } } } }; }