Nine scenes of 6-7 s, one slow camera move each, soft dissolves, one plain-English caption per scene at the top (on screen at least 4 s): the stores' to-do list and one rejection mail, `init` in an empty folder, the agent prompt and `release plan` (25 steps), store text and renders in 7 sizes x 4 languages, `play products --dry` and `catalog diff`, the scan's declarations proposal with the ask-owner line, three findings with plain reasons turning green, the desktop release timeline and Store readiness "No blockers", Submit and the end card. Every CLI line is real output: tests/teaser.test.ts runs each command (store answers from tests/helpers/teaser-store-mock.ts, preloaded through bunfig.toml, never the network) and checks scene lengths, caption holds, the 55-65 s total and 16:9 only. The vertical output is gone; the web version targets 4.5 MB. Calmer music (D major pads, soft pluck, bells on scene changes). desktop.ts adds the Store readiness frame.
90 lines
7.3 KiB
TypeScript
90 lines
7.3 KiB
TypeScript
// Store double for the teaser's CLI scenes (tests/teaser.test.ts): loaded with `preload` from a bunfig.toml in a temp
|
|
// project, so every `bun cli/index.ts …` started there (and the children `release plan` starts) answers from here and
|
|
// never reaches the network. It answers in the shapes App Store Connect and Google Play use (sanitized: placeholder
|
|
// ids only) for a brand-new app record of the placeholder project:
|
|
// TEASER_STORE=new the app record exists, nothing else: no version, no build, no listings, no products
|
|
// TEASER_STORE=catalog the two products of the teaser project exist on both stores, priced in every region
|
|
// Regions: the App Store's 175 storefronts (ALPHA3 in cli/lib/money.ts); Google Play the same list without mainland
|
|
// China (Play is not offered there), 174 regions, the count Play answered in a recorded read-only run
|
|
// (tests/play-post-reads.test.ts). Pages under https://example.com/ (the template's privacy policy and
|
|
// delete-account URLs) answer 200; any other request answers 599 with its URL.
|
|
import { ALPHA3, toMoney } from '../../cli/lib/money';
|
|
import { CURRENCY } from '../../cli/lib/ppp';
|
|
|
|
const STATE = process.env.TEASER_STORE ?? 'new';
|
|
const APP = '6400000101';
|
|
const ASC = 'https://api.appstoreconnect.apple.com';
|
|
const json = (o: unknown, status = 200): Response => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
|
const list = (data: unknown[], included: unknown[] = []): Response => json({ data, included, links: {}, meta: { paging: { total: data.length, limit: 200 } } });
|
|
|
|
/** The teaser project's products (the config in tests/teaser.test.ts): id, Apple type, US price. */
|
|
const PRODUCTS = [
|
|
{ id: 'com.example.app.no_ads', type: 'NON_CONSUMABLE', usd: 2.99 },
|
|
{ id: 'com.example.app.gems_100', type: 'CONSUMABLE', usd: 0.99 },
|
|
];
|
|
const APPLE_REGIONS = Object.keys(ALPHA3);
|
|
const PLAY_REGIONS = APPLE_REGIONS.filter(r => r !== 'CN');
|
|
/** One price per product everywhere (the values are not shown; the same price on both stores, so no parity gaps). */
|
|
const currency = (r: string): string => CURRENCY[r] ?? 'USD';
|
|
|
|
function apple(m: string, p: string): Response {
|
|
if (/^\/v1\/apps\?filter\[bundleId\]=/.test(p)) return list([{ type: 'apps', id: APP, attributes: { name: 'Example App', bundleId: decodeURIComponent(p.split('=')[1]!), primaryLocale: 'en-US', sku: 'EXAMPLE' } }]);
|
|
if (p.startsWith(`/v1/apps/${APP}/appInfos`)) return list([{ type: 'appInfos', id: 'info-1', attributes: { appStoreState: 'PREPARE_FOR_SUBMISSION', state: 'PREPARE_FOR_SUBMISSION' } }]);
|
|
if (STATE === 'catalog') {
|
|
if (p.startsWith(`/v1/apps/${APP}/inAppPurchasesV2`)) return list(PRODUCTS.map((x, i) => ({ type: 'inAppPurchases', id: `iap-${i}`, attributes: { productId: x.id, name: x.id.split('.').pop(), inAppPurchaseType: x.type, state: 'READY_TO_SUBMIT' } })));
|
|
const loc = p.match(/^\/v2\/inAppPurchases\/iap-\d\/inAppPurchaseLocalizations/);
|
|
if (loc) return list(['en-US', 'tr'].map((locale, i) => ({ type: 'inAppPurchaseLocalizations', id: `loc-${i}`, attributes: { locale, name: 'Example', description: 'Example.' } })));
|
|
const sched = p.match(/^\/v2\/inAppPurchases\/(iap-\d)\/iapPriceSchedule/);
|
|
if (sched) return json({ data: { type: 'inAppPurchasePriceSchedules', id: sched[1] } });
|
|
const prices = p.match(/^\/v1\/inAppPurchasePriceSchedules\/iap-(\d)\/(manualPrices|automaticPrices)/);
|
|
if (prices) {
|
|
const x = PRODUCTS[Number(prices[1])]!;
|
|
// US is the base territory (manual); every other storefront is equalized by Apple (automatic)
|
|
const regions = prices[2] === 'manualPrices' ? ['US'] : APPLE_REGIONS.filter(r => r !== 'US');
|
|
const data = regions.map(r => ({ type: 'inAppPurchasePrices', id: `price-${r}`, attributes: { startDate: null, endDate: null, manual: r === 'US' },
|
|
relationships: { inAppPurchasePricePoint: { data: { type: 'inAppPurchasePricePoints', id: `point-${r}` } }, territory: { data: { type: 'territories', id: ALPHA3[r] } } } }));
|
|
const included = regions.flatMap(r => [
|
|
{ type: 'inAppPurchasePricePoints', id: `point-${r}`, attributes: { customerPrice: String(x.usd), proceeds: String(x.usd * 0.85) } },
|
|
{ type: 'territories', id: ALPHA3[r], attributes: { currency: currency(r) } },
|
|
]);
|
|
return list(data, included);
|
|
}
|
|
if (/^\/v1\/inAppPurchasePriceSchedules\/iap-\d\/baseTerritory/.test(p)) return json({ data: { type: 'territories', id: 'USA', attributes: { currency: 'USD' } } });
|
|
}
|
|
if (m === 'GET') return list([]);
|
|
return json({ errors: [{ status: '599', detail: `teaser store double: no route for ${m} ${p}` }] }, 599);
|
|
}
|
|
|
|
function play(m: string, url: string): Response {
|
|
if (m === 'POST' && /\/edits$/.test(url)) return json({ id: 'edit-1', expiryTimeSeconds: '9999999999' });
|
|
if (m === 'DELETE') return new Response(null, { status: 204 });
|
|
if (/\/edits\/edit-1\/tracks$/.test(url)) return json({ kind: 'androidpublisher#tracksListResponse', tracks: [] });
|
|
if (/\/edits\/edit-1\/listings$/.test(url)) return json({ kind: 'androidpublisher#listingsListResponse' });
|
|
if (m === 'POST' && /\/pricing:convertRegionPrices$/.test(url)) return json({
|
|
convertedRegionPrices: Object.fromEntries(PLAY_REGIONS.map(r => [r, { regionCode: r, price: toMoney(currency(r), 1.99), taxAmount: toMoney(currency(r), 0) }])),
|
|
convertedOtherRegionsPrice: { usdPrice: toMoney('USD', 1.99), eurPrice: toMoney('EUR', 1.99) },
|
|
regionVersion: { version: '2022/02' },
|
|
});
|
|
const product = (x: (typeof PRODUCTS)[number]) => ({
|
|
packageName: 'com.example.app', productId: x.id,
|
|
listings: [{ languageCode: 'en-US', title: 'Example', description: 'Example.' }, { languageCode: 'tr-TR', title: 'Example', description: 'Example.' }],
|
|
purchaseOptions: [{ purchaseOptionId: 'buy', state: 'ACTIVE', buyOption: { legacyCompatible: true },
|
|
regionalPricingAndAvailabilityConfigs: PLAY_REGIONS.map(r => ({ regionCode: r, availability: 'AVAILABLE', price: toMoney(currency(r), x.usd) })) }],
|
|
});
|
|
if (/\/oneTimeProducts\?pageSize=/.test(url)) return json(STATE === 'catalog' ? { oneTimeProducts: PRODUCTS.map(product) } : {});
|
|
const one = url.match(/\/oneTimeProducts\/([^/?]+)$/);
|
|
if (one) { const x = PRODUCTS.find(p => p.id === decodeURIComponent(one[1]!)); return x && STATE === 'catalog' ? json(product(x)) : json({ error: { code: 404, message: 'not found', status: 'NOT_FOUND' } }, 404); }
|
|
if (/\/subscriptions\?pageSize=/.test(url)) return json({});
|
|
if (m === 'GET') return json({});
|
|
return json({ error: { code: 599, message: `teaser store double: no route for ${m} ${url}` } }, 599);
|
|
}
|
|
|
|
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
const url = String(input instanceof Request ? input.url : input);
|
|
const m = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
|
|
if (url.startsWith('https://oauth2.googleapis.com/token')) return json({ access_token: 'ya29.example', expires_in: 3600 });
|
|
if (url.startsWith(ASC)) return apple(m, url.slice(ASC.length));
|
|
if (url.startsWith('https://androidpublisher.googleapis.com/')) return play(m, url);
|
|
if (url.startsWith('https://example.com/')) return new Response('<!doctype html><title>Example</title><p>Example page.</p>', { status: 200, headers: { 'content-type': 'text/html' } });
|
|
return new Response(`teaser store double: no route for ${m} ${url}`, { status: 599 });
|
|
}) as typeof fetch;
|