diff --git a/CHANGELOG.md b/CHANGELOG.md index 1000918..c2079f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## Unreleased + +- **Play "Live" only when the store page proves it.** The Play Developer API has no review state: a production release + reads `completed` while Google still reviews it. `play status` now checks the public store page (no auth, fast + timeout, cached 5 minutes) and adds `publicPage` to its JSON: page 404 = "Sent to Google (in review or not yet + published)", 200 = live for the first release, an update is "Released to production (Google may still be + reviewing)" unless the page shows its versionName, offline = unknown. New release phases `sent` and `released` in + the desktop Releases tab, Portfolio, `release status | plan` and the CLI JSON. Pitfall 56. +- **One row per release across the stores.** Versions are matched without trailing .0 parts (App Store "1.0" and Play + "1.0.0" are one row and one timeline); each side still shows the store's own string. + + ## 0.8.1 · 2026-10-13 `apple build` exports each build into its own folder and fails loudly on any export error; `apple upload` refuses a diff --git a/apps/desktop/src/dev/submit-fixtures.ts b/apps/desktop/src/dev/submit-fixtures.ts index 40891c0..cc7cf1f 100644 --- a/apps/desktop/src/dev/submit-fixtures.ts +++ b/apps/desktop/src/dev/submit-fixtures.ts @@ -103,3 +103,23 @@ export const PLAY_STATUS = { ], listings: [], products: [], }; + +// A first release in the shapes the stores really answered (pitfall: App Store "1.0" in review while Google Play's +// production release of "1.0.0" reads "completed" but the app is not public yet). `play status --json` adds the public +// store page check (publicPage): 404 = not public yet, 200 = published. +export const APPLE_STATUS_FIRST_REVIEW = { + app: { id: '1000000002', attributes: { name: 'Prism Puddle', bundleId: 'com.example.prismpuddle' } }, + versions: [version('ver-10', '1.0', 'WAITING_FOR_REVIEW', 'b-3')], + builds: [build('b-3', '3', 'VALID', '2026-10-10T09:00:00Z', 'pre-10')], attachedBuilds: [], buildVersions: { 'b-3': '1.0' }, + iaps: [], phased: {}, subs: [{ id: 'rs-10', attributes: { state: 'WAITING_FOR_REVIEW', submittedDate: '2026-10-10T10:00:00Z' } }], unresolved: [], emptyDrafts: [], +}; +const playFirst = (state: 'public' | 'notPublic') => ({ + ...PLAY_STATUS, + tracks: [ + { track: 'production', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed', userFraction: 1 }] }, + { track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] }, + ], + publicPage: { state, http: state === 'public' ? 200 : 404, versionName: null, checkedAt: '2026-10-13T08:00:00Z', url: 'https://play.google.com/store/apps/details?id=com.example.prismpuddle&hl=en' }, +}); +export const PLAY_STATUS_FIRST_404 = playFirst('notPublic'); +export const PLAY_STATUS_FIRST_200 = playFirst('public'); diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 5dd63ef..15451be 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -100,6 +100,9 @@ export const en = { 'phase.paused': 'Paused', 'phase.approved': 'Approved', 'phase.review': 'In review', + 'phase.sent': 'Sent to Google (in review or not yet published)', + 'phase.released': 'Released to production (Google may still be reviewing)', + 'phase.releasedUnknown': 'Released to production (live state unknown: the store page could not be read)', 'phase.rejected': 'Rejected', 'phase.testing': 'Testing', 'phase.draft': 'Draft', @@ -211,6 +214,7 @@ export const en = { 'tl.next': 'Next', 'tl.live': 'Live', + 'tl.released': 'Released', 'tl.store': 'Store', 'tl.created': 'Created {date}', 'tl.notOnStore': 'Not on this store', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index 27c519d..f02df46 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -102,6 +102,9 @@ export const tr: Record = { 'phase.paused': 'Duraklatıldı', 'phase.approved': 'Onaylandı', 'phase.review': 'İncelemede', + 'phase.sent': 'Google’a gönderildi (incelemede ya da henüz yayınlanmadı)', + 'phase.released': 'Üretime çıktı (Google hâlâ inceliyor olabilir)', + 'phase.releasedUnknown': 'Üretime çıktı (yayın durumu bilinmiyor: mağaza sayfası okunamadı)', 'phase.rejected': 'Reddedildi', 'phase.testing': 'Testte', 'phase.draft': 'Taslak', @@ -213,6 +216,7 @@ export const tr: Record = { 'tl.next': 'Sonraki', 'tl.live': 'Yayında', + 'tl.released': 'Üretimde', 'tl.store': 'Mağaza', 'tl.created': 'Oluşturma: {date}', 'tl.notOnStore': 'Bu mağazada yok', diff --git a/apps/desktop/src/lib/notify.ts b/apps/desktop/src/lib/notify.ts index 3be6d31..247f8ba 100644 --- a/apps/desktop/src/lib/notify.ts +++ b/apps/desktop/src/lib/notify.ts @@ -1,6 +1,7 @@ // What deserves a desktop notification after a read (Settings > Notifications). Pure: compares the previous read with // the new one and never fires on a first read (nothing to compare with = no flood). import type { PortfolioResult } from '../api'; +import { sameVersion } from '../../../../cli/lib/portfolio'; import { healthSeries, type HealthSnapshot, isError, PLAY_THRESHOLDS, type Review } from './model'; export interface ReleaseChange { project: string; name: string; store: 'ios' | 'android'; version: string; from: string; to: string } @@ -8,14 +9,14 @@ export interface ReleaseChange { project: string; name: string; store: 'ios' | ' /** Phase changes of the same version on the same store that the owner waits for: review in / out, approved, rejected, live, halted. */ export function releaseChanges(prev: PortfolioResult | null | undefined, next: PortfolioResult): ReleaseChange[] { if (!prev) return []; - const WATCH = new Set(['review', 'approved', 'rejected', 'live', 'halted']); + const WATCH = new Set(['review', 'sent', 'released', 'approved', 'rejected', 'live', 'halted']); const out: ReleaseChange[] = []; for (const a of next.apps) { const before = prev.apps.find(p => p.project === a.project); if (!before || before.release.source !== 'live' || a.release.source !== 'live') continue; for (const r of a.release.all ?? []) { if (!r.version) continue; - const old = (before.release.all ?? []).find(x => x.store === r.store && x.version === r.version); + const old = (before.release.all ?? []).find(x => x.store === r.store && (x.version === r.version || sameVersion(x.version, r.version))); if (!old || old.phase === r.phase) continue; if (WATCH.has(r.phase) || WATCH.has(old.phase)) out.push({ project: a.project, name: a.app?.name ?? a.project, store: r.store, version: r.version, from: old.phase, to: r.phase }); } diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index d05f5c5..caab9e7 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -22,6 +22,7 @@ function stageCaption(l: Lane, i: number): string { const r = l.release; if (i === 1) return r.store === 'ios' ? 'TestFlight' : (r.track ?? t('stage.testing')); if (i === 2) return t(`phase.${r.phase}` as Key); + if (i === 3 && r.phase === 'released') return t(r.publicPage === 'unknown' ? 'phase.releasedUnknown' : 'phase.released'); return ''; } @@ -33,7 +34,7 @@ function LaneRow({ lane, version }: { lane: Lane; version: string }) {
{storeName(lane.store)}
-
{r ? `${version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}
+
{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}
{lane.at < 0 ?
{t('tl.noVersion', { version, store: storeName(lane.store) })}
: STAGES.map((s, i) => { @@ -102,8 +103,10 @@ function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) { function Train({ title, train, tf, tone, app }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp }) { const lanes = trainLanes(train, tf); + // "Live" only when a store has it live or rolling out; a Play release Google may still review is "Released" + const label = tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title; return ( - {train.version}{title}} + {train.version}{label}} sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
{t('tl.store')}{STAGES.map(s => {t(STAGE_KEY[s])})}
@@ -165,7 +168,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { {history.map(h => ( - {h.version} + {[...new Set([h.version, h.ios?.version, h.android?.version].filter(Boolean))].join(' / ')} {h.ios ? : –} {h.ios?.build ?? '–'} {h.android ? : –} diff --git a/apps/desktop/src/ui/kit.tsx b/apps/desktop/src/ui/kit.tsx index 2b0989e..0e3283d 100644 --- a/apps/desktop/src/ui/kit.tsx +++ b/apps/desktop/src/ui/kit.tsx @@ -14,7 +14,7 @@ export function Chip({ tone = 'neutral', children, dot = true, title, icon }: { return {icon ? : dot && tone !== 'outline' && }{children}; } -const PHASE_TONE: Record = { live: 'ok', rollout: 'accent', halted: 'bad', approved: 'ok', review: 'warn', rejected: 'bad', testing: 'info', draft: 'neutral', other: 'neutral' }; +const PHASE_TONE: Record = { live: 'ok', rollout: 'accent', halted: 'bad', released: 'info', approved: 'ok', review: 'warn', sent: 'warn', rejected: 'bad', testing: 'info', draft: 'neutral', other: 'neutral' }; /** Chip of a store release: "Live", "Rollout 20%", "In review" ... (iOS phased release: "Phased 50%", "Paused"). */ export function phaseLabel(r: StoreRelease | null | undefined): { text: string; tone: Tone } { @@ -22,6 +22,7 @@ export function phaseLabel(r: StoreRelease | null | undefined): { text: string; const pct = r.fraction !== null && r.fraction !== undefined && r.fraction < 1 ? ` ${share(r.fraction)}` : ''; if (r.phase === 'rollout') return { text: (r.store === 'ios' ? t('phase.phased') : t('phase.rollout')) + pct, tone: 'accent' }; if (r.phase === 'halted') return { text: (r.store === 'ios' ? t('phase.paused') : t('phase.halted')) + pct, tone: 'bad' }; + if (r.phase === 'released' && r.publicPage === 'unknown') return { text: t('phase.releasedUnknown'), tone: 'neutral' }; if (r.phase === 'testing' && r.track) return { text: `${t('phase.testing')} · ${r.track}`, tone: 'info' }; return { text: t(`phase.${r.phase}`), tone: PHASE_TONE[r.phase] }; } diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 57491ea..9818a1f 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -115,7 +115,10 @@ export interface AppIdentity { } /** Where a version is on one store. */ -export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'approved' | 'review' | 'rejected' | 'testing' | 'draft' | 'other'; +/** `sent`: a Google Play production release that is completed in the API while the public store page is 404 (sent to + * Google, in review or not yet published). `released`: completed in production and the app is public, but this + * version is not proven live (an update Google may still be reviewing, or the public page could not be read). */ +export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approved' | 'review' | 'sent' | 'rejected' | 'testing' | 'draft' | 'other'; export interface StoreRelease { store: 'ios' | 'android'; @@ -131,6 +134,8 @@ export interface StoreRelease { track: string | null; /** iOS phased release: day 1-7 and its state (ACTIVE, PAUSED, COMPLETE); null without one. */ phased?: { state: string; day: number | null } | null; + /** Google Play production: the public store page (public = 200, notPublic = 404, unknown = not read). */ + publicPage?: 'public' | 'notPublic' | 'unknown'; /** When the store record was made (iOS version createdDate), if the store says. */ date?: string | null; } diff --git a/cli/lib/play-public.ts b/cli/lib/play-public.ts new file mode 100644 index 0000000..36f0440 --- /dev/null +++ b/cli/lib/play-public.ts @@ -0,0 +1,63 @@ +// Is the app public on Google Play? The Play Developer API has no review state: a production release reads +// "completed" from the moment it is sent, while Google may still be reviewing it (a first release is not public +// until then). The public store page answers it: 200 = published, 404 = not public yet. No auth, a fast timeout, +// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it), "unknown" when offline. +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +export type PublicState = 'public' | 'notPublic' | 'unknown'; +export interface PlayPublicPage { + state: PublicState; + /** HTTP status of the store page (null when it could not be read). */ + http: number | null; + /** The versionName the page shows, when it shows one (usually it does not). */ + versionName: string | null; + checkedAt: string; + url: string; + error?: string; +} + +export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`; + +const TTL_MS = 5 * 60_000; +const mem = new Map(); +const cacheFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.json`); +const fresh = (p: PlayPublicPage | undefined | null): p is PlayPublicPage => !!p && p.state !== 'unknown' && Date.now() - Date.parse(p.checkedAt) < TTL_MS; + +/** The versionName in the page's structured data, when it has one ("softwareVersion"). */ +export function pageVersion(html: string): string | null { + const m = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/); + return m && /\d/.test(m[1]!) ? m[1]! : null; +} + +/** Reads the public page (cached 5 minutes; STOREBOUND_PLAY_PUBLIC=public|notPublic|unknown overrides it for tests). */ +export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCache?: boolean } = {}): Promise { + const url = playPageUrl(pkg); + const forced = process.env.STOREBOUND_PLAY_PUBLIC; + if (forced === 'public' || forced === 'notPublic' || forced === 'unknown') + return { state: forced, http: forced === 'public' ? 200 : forced === 'notPublic' ? 404 : null, versionName: process.env.STOREBOUND_PLAY_PUBLIC_VERSION ?? null, checkedAt: new Date().toISOString(), url }; + if (!o.noCache) { + const m = mem.get(pkg); + if (fresh(m)) return m; + try { const f = JSON.parse(readFileSync(cacheFile(pkg), 'utf8')) as PlayPublicPage; if (fresh(f) && f.url === url) { mem.set(pkg, f); return f; } } catch { /* no cache */ } + } + let out: PlayPublicPage; + try { + const r = await fetch(url, { redirect: 'follow', signal: AbortSignal.timeout(o.timeoutMs ?? 4000), headers: { 'User-Agent': 'Mozilla/5.0 storebound', 'Accept-Language': 'en' } }); + const http = r.status; + if (http === 200) out = { state: 'public', http, versionName: pageVersion(await r.text()), checkedAt: new Date().toISOString(), url }; + else if (http === 404) out = { state: 'notPublic', http, versionName: null, checkedAt: new Date().toISOString(), url }; + else out = { state: 'unknown', http, versionName: null, checkedAt: new Date().toISOString(), url, error: `HTTP ${http}` }; + } catch (e) { + out = { state: 'unknown', http: null, versionName: null, checkedAt: new Date().toISOString(), url, error: (e as Error).message || 'offline' }; + } + if (out.state !== 'unknown') { + mem.set(pkg, out); + try { mkdirSync(join(tmpdir(), 'storebound-play-public'), { recursive: true }); writeFileSync(cacheFile(pkg), JSON.stringify(out)); } catch { /* cache is optional */ } + } + return out; +} + +/** Forget the cached answers (tests). */ +export function clearPlayPublicCache(): void { mem.clear(); } diff --git a/cli/lib/portfolio.ts b/cli/lib/portfolio.ts index 9e53f0c..5995344 100644 --- a/cli/lib/portfolio.ts +++ b/cli/lib/portfolio.ts @@ -23,6 +23,25 @@ export function cmpVersion(a: string, b: string): number { return 0; } +/** Matching key of a version: numeric parts without trailing zero parts, so 1 == 1.0 == 1.0.0 ("1"), 2.10 != 2.1. + * A version with non-numeric parts keeps its own spelling. The stores' strings are shown as they are; this only joins. */ +export function versionKey(v: string): string { + const parts = v.trim().split('.'); + if (!parts.every(p => /^\d+$/.test(p))) return v.trim(); + const n = parts.map(p => String(parseInt(p, 10))); + while (n.length > 1 && n.at(-1) === '0') n.pop(); + return n.join('.'); +} + +/** The same release on both stores (App Store "1.0" and Play "1.0.0"). */ +export const sameVersion = (a: string | null | undefined, b: string | null | undefined): boolean => !!a && !!b && versionKey(a) === versionKey(b); + +/** The spelling a joined row shows: the longest of the stores' strings (1.0.0 over 1.0), the first one on a tie. */ +export function displayVersion(vs: (string | null | undefined)[]): string { + const xs = vs.filter((x): x is string => !!x); + return xs.reduce((a, b) => (b.split('.').length > a.split('.').length ? b : a), xs[0] ?? ''); +} + /** App Store version states (appStoreState / appVersionState) -> phase. */ export function applePhase(state: string): ReleasePhase { if (/^(READY_FOR_SALE|READY_FOR_DISTRIBUTION)$/.test(state)) return 'live'; @@ -85,22 +104,56 @@ export function testflightReleases(builds: J[], buildVersions: Record "2.3.0"). */ export const versionOfName = (name: unknown): string | null => (typeof name === 'string' ? name.match(/\d+(?:\.\d+){0,3}/)?.[0] ?? null : null); +/** + * Play tracks (`play status --json`) -> releases. The Developer API has no review state: a completed production release + * is only "live" when the public store page proves it (`publicPage` in the status JSON): 404 = sent to Google (in + * review or not yet published); 200 = live for the first release or when the page shows this versionName, otherwise + * "released" (an update Google may still be reviewing); page not read = "released" with publicPage unknown. + */ export function playReleases(data: unknown): StoreRelease[] { const out: StoreRelease[] = []; - for (const t of ((data as J)?.tracks ?? []) as J[]) { - for (const r of (t?.releases ?? []) as J[]) { - const codes = ((r.versionCodes ?? []) as unknown[]).map(Number).filter(Number.isFinite); + const page = (data as J)?.publicPage as { state?: string; versionName?: string | null } | undefined; + const pageState = page?.state === 'public' || page?.state === 'notPublic' ? page.state : 'unknown'; + const raw = (data as J)?.tracks; + const tracks: J[] = Array.isArray(raw) ? raw : []; + const codesOf = (r: J): number[] => (Array.isArray(r?.versionCodes) ? (r.versionCodes as unknown[]) : []).map(Number).filter(Number.isFinite); + const rels = (t: J): J[] => (Array.isArray(t?.releases) ? t.releases : []); + const allCodes = tracks.flatMap(t => rels(t).flatMap(codesOf)); + for (const t of tracks) { + for (const r of rels(t)) { + const codes = codesOf(r); const production = t.track === 'production'; const status = String(r.status ?? 'unknown'); - const phase: ReleasePhase = !production ? (status === 'draft' ? 'draft' : 'testing') - : status === 'completed' ? 'live' : status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other'; - out.push({ store: 'android', version: versionOfName(r.name), build: codes.length ? String(Math.max(...codes)) : null, state: status, phase, fraction: typeof r.userFraction === 'number' ? r.userFraction : null, track: String(t.track) }); + const version = versionOfName(r.name); + const rel: StoreRelease = { store: 'android', version, build: codes.length ? String(Math.max(...codes)) : null, state: status, phase: 'other', fraction: typeof r.userFraction === 'number' ? r.userFraction : null, track: String(t.track) }; + rel.phase = !production ? (status === 'draft' ? 'draft' : 'testing') + : status === 'inProgress' ? 'rollout' : status === 'halted' ? 'halted' : status === 'draft' ? 'draft' : 'other'; + if (production && status === 'completed') { + const low = codes.length ? Math.min(...codes) : Infinity; + const first = !allCodes.some(c => c < low); // nothing older on any track: the app's first release + rel.phase = pageState === 'notPublic' ? 'sent' + : pageState === 'public' && (first || sameVersion(page?.versionName, version)) ? 'live' + : 'released'; + rel.publicPage = pageState; + } + out.push(rel); } } return out; } -const PHASE_RANK: Record = { live: 0, rollout: 1, halted: 2, approved: 3, review: 4, rejected: 5, testing: 6, draft: 7, other: 8 }; +/** Plain words for a Play production release's phase (CLI text; the desktop has the same words in i18n). */ +export function playReleaseLabel(r: StoreRelease): string { + if (r.phase === 'sent') return 'Sent to Google (in review or not yet published)'; + if (r.phase === 'released') return r.publicPage === 'unknown' ? 'Released to production (public store page not readable: live state unknown)' : 'Released to production (Google may still be reviewing)'; + if (r.phase === 'live') return 'Live'; + return r.state; +} + +export const PHASE_RANK: Record = { live: 0, rollout: 1, halted: 2, released: 3, approved: 4, review: 5, sent: 6, rejected: 7, testing: 8, draft: 9, other: 10 }; + +/** Phases where the version reaches (or may reach) users. */ +export const REACHING: ReadonlySet = new Set(['live', 'rollout', 'halted', 'released']); const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) => PHASE_RANK[a.phase] - PHASE_RANK[b.phase])[0] ?? null; /** @@ -110,8 +163,11 @@ const pick = (rs: StoreRelease[]): StoreRelease | null => [...rs].sort((a, b) => */ export function releaseTrains(ios: StoreRelease[], android: StoreRelease[]): { current: ReleaseTrain | null; next: ReleaseTrain | null } { const all = [...ios, ...android].filter((r): r is StoreRelease & { version: string } => !!r.version); - const train = (v: string): ReleaseTrain => ({ version: v, ios: pick(all.filter(r => r.store === 'ios' && r.version === v)), android: pick(all.filter(r => r.store === 'android' && r.version === v)) }); - const live = all.filter(r => r.phase === 'live' || r.phase === 'rollout' || r.phase === 'halted').map(r => r.version).sort(cmpVersion).at(-1) ?? null; + const train = (v: string): ReleaseTrain => { + const i = pick(all.filter(r => r.store === 'ios' && sameVersion(r.version, v))), a = pick(all.filter(r => r.store === 'android' && sameVersion(r.version, v))); + return { version: displayVersion([a?.version, i?.version, v]), ios: i, android: a }; + }; + const live = all.filter(r => REACHING.has(r.phase)).map(r => r.version).sort(cmpVersion).at(-1) ?? null; const above = all.filter(r => !live || cmpVersion(r.version, live) > 0).map(r => r.version).sort(cmpVersion).at(-1) ?? null; return { current: live ? train(live) : null, next: above ? train(above) : null }; } diff --git a/cli/lib/train.ts b/cli/lib/train.ts index 549e671..0cb0f3b 100644 --- a/cli/lib/train.ts +++ b/cli/lib/train.ts @@ -4,10 +4,10 @@ // stage for the same store state. Pure; the store reads are `apple status --json` / `play status --json`, turned into // StoreRelease rows by cli/lib/portfolio.ts. import type { ReleaseTrain, StoreRelease } from './desktop-protocol'; -import { cmpVersion } from './portfolio'; +import { cmpVersion, displayVersion, PHASE_RANK, REACHING, sameVersion, versionKey } from './portfolio'; import type { Finding } from './validate-rules'; -export { cmpVersion }; +export { cmpVersion, displayVersion, sameVersion, versionKey }; export type Tone = 'ok' | 'warn' | 'bad' | 'info' | 'neutral' | 'accent'; @@ -36,6 +36,8 @@ export function laneOf(store: 'ios' | 'android', r: StoreRelease | null, testfli case 'testing': return { ...base, at: 1, tone: 'info', share: null }; case 'review': return { ...base, at: 2, tone: 'warn', share: null }; case 'approved': return { ...base, at: 2, tone: 'ok', share: null }; + case 'sent': return { ...base, at: 2, tone: 'warn', share: null }; + case 'released': return { ...base, at: 3, tone: r.publicPage === 'unknown' ? 'neutral' : 'info', share: null }; case 'rejected': return { ...base, at: 2, tone: 'bad', share: null }; case 'live': return { ...base, at: 4, tone: 'ok', share: 1 }; case 'rollout': return { ...base, at: 4, tone: 'accent', share: r.fraction ?? null }; @@ -49,23 +51,26 @@ export interface BetaBuilds { builds: { version: string | null; internal: string /** Both lanes of a train; iOS counts TestFlight as reached when a build of that version is in beta testing. */ export function trainLanes(t: ReleaseTrain, tf?: BetaBuilds | null): Lane[] { - const inBeta = !!tf?.builds.some(b => b.version === t.version && /IN_BETA_TESTING|BETA_APPROVED|READY_FOR_BETA_TESTING/.test(`${b.internal ?? ''} ${b.external ?? ''}`)); + const inBeta = !!tf?.builds.some(b => sameVersion(b.version, t.version) && /IN_BETA_TESTING|BETA_APPROVED|READY_FOR_BETA_TESTING/.test(`${b.internal ?? ''} ${b.external ?? ''}`)); return [laneOf('ios', t.ios, inBeta), laneOf('android', t.android)]; } /** Every distinct version of the store releases, newest first, with both stores' release (best phase per store). */ export function versionHistory(all: StoreRelease[]): ReleaseTrain[] { - const rank: Record = { live: 0, rollout: 1, halted: 2, approved: 3, review: 4, rejected: 5, testing: 6, draft: 7, other: 8 }; - const versions = [...new Set(all.map(r => r.version).filter((v): v is string => !!v))].sort(cmpVersion).reverse(); - const best = (store: 'ios' | 'android', v: string) => all.filter(r => r.store === store && r.version === v).sort((a, b) => (rank[a.phase] ?? 9) - (rank[b.phase] ?? 9))[0] ?? null; - return versions.map(v => ({ version: v, ios: best('ios', v), android: best('android', v) })); + const keys = new Map(); // one row per release: 1.0 (App Store) and 1.0.0 (Play) are the same + for (const v of all.map(r => r.version).filter((x): x is string => !!x)) { const k = versionKey(v); keys.set(k, [...(keys.get(k) ?? []), v]); } + const best = (store: 'ios' | 'android', k: string) => all.filter(r => r.store === store && !!r.version && versionKey(r.version) === k).sort((a, b) => (PHASE_RANK[a.phase] ?? 99) - (PHASE_RANK[b.phase] ?? 99))[0] ?? null; + return [...keys].map(([k, vs]) => { + const ios = best('ios', k), android = best('android', k); + return { version: displayVersion([android?.version, ios?.version, ...vs]), ios, android }; + }).sort((a, b) => cmpVersion(b.version, a.version)); } /** On one store: the highest version reaching users (live or rolling out) and the highest version above it. */ export function storeNow(all: StoreRelease[], store: 'ios' | 'android'): { now: StoreRelease | null; next: StoreRelease | null } { const rs = all.filter((r): r is StoreRelease & { version: string } => r.store === store && !!r.version); const desc = (a: { version: string }, b: { version: string }) => cmpVersion(b.version, a.version); - const now = rs.filter(r => r.phase === 'live' || r.phase === 'rollout' || r.phase === 'halted').sort(desc)[0] ?? null; + const now = rs.filter(r => REACHING.has(r.phase)).sort(desc)[0] ?? null; const next = rs.filter(r => !now || cmpVersion(r.version, now.version) > 0).sort(desc)[0] ?? null; return { now, next }; } @@ -80,6 +85,8 @@ export function laneText(l: Lane): string { const stage = STAGES[l.at] ?? 'build'; const p = l.release.phase; if (p === 'live') return 'live'; // the whole track filled: everyone has it + if (p === 'sent') return 'review (sent to Google: in review or not yet published)'; + if (p === 'released') return l.release.publicPage === 'unknown' ? 'released to production (public page not readable: live unknown)' : 'released to production (Google may still be reviewing)'; const word = p === stage || (p === 'draft' && stage === 'build') || (p === 'other' && stage === 'build') ? stage : `${stage} (${p})`; if (p === 'rollout' || p === 'halted') return `${word} ${pctText(l.share)}`.trim(); return word; @@ -176,6 +183,12 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null, case 'rejected': steps.push({ kind: 'owner', text: `resubmit ${v} after the fix (owner)`, fix: store === 'ios' ? 'App Store Connect > App Review > reply / resubmit' : 'Play Console > Publishing overview' }); break; + case 'sent': + steps.push({ kind: 'wait', text: `${v} is sent to Google (production completed, the store page is not public yet): in review or not yet published`, fix: 'storebound play status (checks the public store page)' }); + break; + case 'released': + steps.push({ kind: 'wait', text: r!.publicPage === 'unknown' ? `${v} is released to production; the public store page could not be read, so live is unknown` : `${v} is released to production; Google may still be reviewing it (the app is public, this version is not proven live)`, fix: 'storebound play status' }); + break; case 'review': steps.push({ kind: 'wait', text: `${v} is in review (${r!.state}); nothing to do until the store answers`, fix: store === 'ios' ? 'storebound apple status --wait-review' : 'storebound play status' }); break; diff --git a/cli/play/status.ts b/cli/play/status.ts index 4b83646..cfba96d 100644 --- a/cli/play/status.ts +++ b/cli/play/status.ts @@ -4,6 +4,8 @@ import { playCategoryOf } from '../lib/config'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { printJson } from '../lib/log'; +import { playPublicPage } from '../lib/play-public'; +import { playReleases, playReleaseLabel } from '../lib/portfolio'; import { playClient } from './client'; import { listOneTimeProducts } from './products'; @@ -16,11 +18,11 @@ export async function run(ctx: Ctx): Promise { const tracks = (await play.call<{ tracks?: Track[] }>('GET', `${play.API}/edits/${id}/tracks`)).tracks ?? []; const listings = (await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? []; const details = await play.call>('GET', `${play.API}/edits/${id}/details`); - const products = await listOneTimeProducts(play); + const [products, publicPage] = await Promise.all([listOneTimeProducts(play), playPublicPage(play.packageName)]); // the App category is not in the API (edits.details has no category field): a console step const cat = playCategoryOf(config); const category = { config: cat?.category ?? null, source: cat?.source ?? null, api: false, plan: 'storebound console plan play-store-settings' }; - if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products }); return; } + if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage }); return; } console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`); console.log(` category: ${cat ? `${cat.category} (${cat.source})` : 'not in the config (play.category)'} · not readable by the API: Store settings is a console step (${category.plan})`); console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`); @@ -28,6 +30,8 @@ export async function run(ctx: Ctx): Promise { const rs = (t.releases ?? []).map(r => `${r.name ?? '?'} [${r.versionCodes?.join(',') ?? '-'}] ${r.status}${r.userFraction ? ' ' + r.userFraction * 100 + '%' : ''}`); console.log(` track ${t.track.padEnd(11)} ${rs.join(' | ') || '-'}`); } + console.log(` public store page: ${publicPage.state === 'public' ? 'published (200)' : publicPage.state === 'notPublic' ? 'not public yet (404)' : `unknown (${publicPage.error ?? 'not read'})`}`); + for (const r of playReleases({ tracks, publicPage }).filter(x => x.track === 'production' && x.state === 'completed')) console.log(` production ${r.version ?? '?'} (${r.build ?? '?'}): ${playReleaseLabel(r)}`); console.log(` products (${products.length}):`); for (const p of products) console.log(` ${p.productId.padEnd(40)} ${(p.purchaseOptions ?? []).map(o => `${o.purchaseOptionId}:${o.state ?? '?'}`).join(' ')}`); }); diff --git a/cli/release.ts b/cli/release.ts index 7b4da70..9644546 100644 --- a/cli/release.ts +++ b/cli/release.ts @@ -17,7 +17,7 @@ import type { AppStatus, ReleaseTrain } from './lib/desktop-protocol'; import { log, printJson, warn } from './lib/log'; import { runCommand, type RunResult } from './lib/runner'; import { textTable } from './lib/table'; -import { laneOf, laneText, type PlanStep, releasePlan, type ReleasePlan, trackText, versionHistory } from './lib/train'; +import { laneOf, laneText, type PlanStep, releasePlan, type ReleasePlan, sameVersion, trackText, versionHistory } from './lib/train'; import type { Finding } from './lib/validate-rules'; import { findWorkspace, loadWorkspace, resolveApps, selectApps } from './lib/workspace'; import { appDir, appEnv, workspaceApps } from './workspace'; @@ -73,7 +73,7 @@ export interface TrainRow { app: string; version: string; which: 'current' | 'ne export function trainsOf(s: AppStatus, all: boolean): { which: TrainRow['which']; train: ReleaseTrain }[] { if (all) { const cur = s.release.current?.version, next = s.release.next?.version; - return versionHistory(s.release.all ?? []).map(t => ({ which: t.version === cur ? 'current' as const : t.version === next ? 'next' as const : 'other' as const, train: t })); + return versionHistory(s.release.all ?? []).map(t => ({ which: sameVersion(t.version, cur) ? 'current' as const : sameVersion(t.version, next) ? 'next' as const : 'other' as const, train: t })); } return [...(s.release.current ? [{ which: 'current' as const, train: s.release.current }] : []), ...(s.release.next ? [{ which: 'next' as const, train: s.release.next }] : [])]; } diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 855cec8..fb256af 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -649,3 +649,14 @@ that fails without the fix (see CONTRIBUTING.md). at render time, `devices` in report.json), and warns when a screenshot's front device runs off a side or the top. - **Guard:** `featureLayout`, `rotatedBox` (`cli/shots/lib.ts`), `deviceIssues` (`cli/shots/report.ts`), `tests/feature-fit.test.ts` (geometry, both templates rendered at 1024 x 500, a bleeding fixture). + +## 56. "Live on Play" while Google was still reviewing, and 1.0 / 1.0.0 shown as two versions +- **Do:** never call a Google Play production release live from the Developer API alone: a release reads `completed` + (userFraction 1) from the moment it is sent, also while Google reviews the first release. `play status` checks the + public store page (no auth, 4 s timeout, cached 5 minutes): 404 = "Sent to Google (in review or not yet + published)"; 200 = live for the first release (or when the page shows the same versionName), otherwise "Released to + production (Google may still be reviewing)"; offline = unknown, shown as unknown. Match versions across the stores + by `versionKey` (trailing .0 parts dropped: 1 == 1.0 == 1.0.0); show each store's own string. +- **Guard:** `playPublicPage` (`cli/lib/play-public.ts`), `playReleases` / `versionKey` / `sameVersion` + (`cli/lib/portfolio.ts`), `versionHistory` (`cli/lib/train.ts`), `tests/play-public-version-match.test.ts`, the + desktop boot check's `release-state` cases (page 404 and 200). diff --git a/tests/play-public-version-match.test.ts b/tests/play-public-version-match.test.ts new file mode 100644 index 0000000..2942134 --- /dev/null +++ b/tests/play-public-version-match.test.ts @@ -0,0 +1,119 @@ +// A first release on both stores in the shapes they really answer: App Store "1.0" (build 3) waiting for review, Google +// Play production "1.0.0" (versionCode 3) completed with userFraction 1 while the app is not public yet (the store page +// answers 404). One row for 1.0 / 1.0.0, never "Live" on Play from the Developer API alone (pitfall 56). +import { afterEach, describe, expect, test } from 'bun:test'; +import { APPLE_STATUS_FIRST_REVIEW, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures'; +import { trainsOf } from '../cli/release'; +import { clearPlayPublicCache, pageVersion, playPublicPage } from '../cli/lib/play-public'; +import { appleReleases, playReleaseLabel, playReleases, releaseTrains, sameVersion, versionKey } from '../cli/lib/portfolio'; +import { laneOf, laneText, releasePlan, storeNow, versionHistory } from '../cli/lib/train'; + +const realFetch = globalThis.fetch; +afterEach(() => { globalThis.fetch = realFetch; delete process.env.STOREBOUND_PLAY_PUBLIC; clearPlayPublicCache(); }); + +describe('version matching across the stores', () => { + test('trailing zero parts do not make a different version', () => { + expect(versionKey('1.0')).toBe('1'); + expect(versionKey('1.0.0')).toBe('1'); + expect(versionKey('1')).toBe('1'); + expect(versionKey('2.10.0')).toBe('2.10'); + expect(versionKey('01.2.0')).toBe('1.2'); + expect(versionKey('1.0-beta')).toBe('1.0-beta'); + expect(sameVersion('1.0', '1.0.0')).toBe(true); + expect(sameVersion('2.1', '2.10')).toBe(false); + expect(sameVersion('1.0.1', '1.0')).toBe(false); + expect(sameVersion(null, '1')).toBe(false); + }); + test('App Store 1.0 and Play 1.0.0 are one row and one train; each side keeps its own string', () => { + const ios = appleReleases(APPLE_STATUS_FIRST_REVIEW), android = playReleases(PLAY_STATUS_FIRST_404); + const all = [...ios, ...android]; + const h = versionHistory(all); + expect(h).toHaveLength(1); + expect(h[0]!.version).toBe('1.0.0'); + expect(h[0]!.ios).toMatchObject({ version: '1.0', build: '3', phase: 'review' }); + expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'sent', track: 'production' }); + const t = releaseTrains(ios, android); + expect(t.current).toBeNull(); // nothing reaches users yet + expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'sent' } }); + // release status --versions: the one row is the next train + const s = { release: { source: 'live' as const, ...t, all }, storeErrors: {}, summary: null } as never; + expect(trainsOf(s, true).map(x => [x.which, x.train.version])).toEqual([['next', '1.0.0']]); + // release plan 1.0 finds the Play 1.0.0 release too + const p = releasePlan({ version: '1.0', platforms: { ios: true, android: true }, check: { problems: [], warnings: [] }, validate: [], releases: all }); + expect(p.ios?.release?.version).toBe('1.0'); + expect(p.android?.release?.version).toBe('1.0.0'); + expect(p.done).toBe(false); + expect(p.android!.steps.map(x => x.text).join(' ')).toContain('sent to Google'); + }); +}); + +describe('Google Play "Live" needs the public store page', () => { + test('completed production + page 404 = sent to Google, never live', () => { + const [prod] = playReleases(PLAY_STATUS_FIRST_404); + expect(prod).toMatchObject({ phase: 'sent', state: 'completed', publicPage: 'notPublic', fraction: 1 }); + expect(playReleaseLabel(prod!)).toBe('Sent to Google (in review or not yet published)'); + const lane = laneOf('android', prod!); + expect(lane.at).toBe(2); // the review stage, not live / rollout + expect(lane.share).toBeNull(); + expect(laneText(lane)).not.toMatch(/^live/); + expect(storeNow(playReleases(PLAY_STATUS_FIRST_404), 'android').now).toBeNull(); + }); + test('first release + page 200 = live', () => { + const [prod] = playReleases(PLAY_STATUS_FIRST_200); + expect(prod).toMatchObject({ phase: 'live', publicPage: 'public' }); + expect(laneText(laneOf('android', prod!))).toBe('live'); + const t = releaseTrains(appleReleases(APPLE_STATUS_FIRST_REVIEW), playReleases(PLAY_STATUS_FIRST_200)); + expect(t.current).toMatchObject({ version: '1.0.0', android: { phase: 'live' }, ios: { phase: 'review' } }); + }); + test('an update after the first release: page 200 is not proof for the new versionCode unless the page shows it', () => { + const update = { publicPage: { state: 'public', versionName: null }, tracks: [ + { track: 'production', releases: [{ name: '1.1.0', versionCodes: ['5'], status: 'completed' }] }, + { track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] }, + ] }; + const [prod] = playReleases(update); + expect(prod!.phase).toBe('released'); + expect(playReleaseLabel(prod!)).toBe('Released to production (Google may still be reviewing)'); + expect(laneText(laneOf('android', prod!))).toContain('Google may still be reviewing'); + expect(playReleases({ ...update, publicPage: { state: 'public', versionName: '1.1' } })[0]!.phase).toBe('live'); + }); + test('page not read = unknown, shown as unknown', () => { + const [prod] = playReleases({ tracks: PLAY_STATUS_FIRST_404.tracks }); + expect(prod).toMatchObject({ phase: 'released', publicPage: 'unknown' }); + expect(playReleaseLabel(prod!)).toContain('unknown'); + expect(laneOf('android', prod!).tone).toBe('neutral'); + }); +}); + +describe('public store page read', () => { + const answer = (status: number, body = '') => { const urls: string[] = []; globalThis.fetch = (async (u: string | URL | Request) => { urls.push(String(u)); return new Response(body, { status }); }) as unknown as typeof fetch; return urls; }; + test('404 = not public yet, 200 = public, the url has no auth and asks for English', async () => { + const urls = answer(404); + expect((await playPublicPage('com.example.first404', { noCache: true })).state).toBe('notPublic'); + expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en'); + answer(200, ''); + expect(await playPublicPage('com.example.first200', { noCache: true })).toMatchObject({ state: 'public', http: 200, versionName: '1.0.0' }); + }); + test('offline or a server error = unknown; unknown is never cached', async () => { + globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch; + const r = await playPublicPage('com.example.offline', { noCache: true }); + expect(r).toMatchObject({ state: 'unknown', http: null }); + expect(r.error).toContain('ENOTFOUND'); + answer(503); + expect((await playPublicPage('com.example.offline')).state).toBe('unknown'); + answer(404); + expect((await playPublicPage('com.example.offline')).state).toBe('notPublic'); + }); + test('answers are cached for a few minutes', async () => { + const urls = answer(404); + const pkg = `com.example.cache${Date.now()}`; + await playPublicPage(pkg); + answer(200); + expect((await playPublicPage(pkg)).state).toBe('notPublic'); + expect(urls).toHaveLength(1); + }); + test('the page version is read only from structured data', () => { + expect(pageVersion('{"softwareVersion": "2.3.1"}')).toBe('2.3.1'); + expect(pageVersion('{"softwareVersion":"Varies with device"}')).toBeNull(); + expect(pageVersion('
Version 1.0
')).toBeNull(); + }); +}); diff --git a/tests/portfolio.test.ts b/tests/portfolio.test.ts index 3d7d06b..c0a52d6 100644 --- a/tests/portfolio.test.ts +++ b/tests/portfolio.test.ts @@ -24,6 +24,7 @@ const play = { { track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] }, ], products: [{ productId: 'coins' }, { productId: 'no_ads' }, { sku: 'android_only' }], + publicPage: { state: 'public' }, }; describe('portfolio (apps, release trains, catalog parity)', () => { @@ -67,7 +68,7 @@ describe('portfolio (apps, release trains, catalog parity)', () => { const t = releaseTrains(appleReleases(apple), playReleases(play)); expect(t.current).toMatchObject({ version: '2.3.0', ios: { phase: 'review', build: '45' }, android: { phase: 'rollout', fraction: 0.2, build: '231' } }); expect(t.next).toMatchObject({ version: '2.4.0', ios: null, android: { phase: 'testing' } }); - const done = releaseTrains(appleReleases(apple), playReleases({ tracks: [play.tracks[0]!.releases[0] && { track: 'production', releases: [play.tracks[0]!.releases[0]] }] })); + const done = releaseTrains(appleReleases(apple), playReleases({ publicPage: play.publicPage, tracks: [play.tracks[0]!.releases[0] && { track: 'production', releases: [play.tracks[0]!.releases[0]] }] })); expect(done.current).toMatchObject({ version: '2.2.1', ios: { phase: 'live', build: '41' }, android: { phase: 'live', build: '220' } }); expect(done.next).toMatchObject({ version: '2.3.0', ios: { phase: 'review' }, android: null }); expect(releaseTrains([], [])).toEqual({ current: null, next: null }); diff --git a/tests/release-train.test.ts b/tests/release-train.test.ts index 01ac36f..b10a034 100644 --- a/tests/release-train.test.ts +++ b/tests/release-train.test.ts @@ -22,6 +22,7 @@ const play = { { track: 'production', releases: [{ name: '2.3.0', versionCodes: ['230'], status: 'inProgress', userFraction: 0.1 }, { name: '2.2.0', versionCodes: ['220'], status: 'completed' }] }, { track: 'internal', releases: [{ name: '2.4.0', versionCodes: ['240'], status: 'completed' }] }, ], + publicPage: { state: 'public' }, }; const all: StoreRelease[] = [...appleReleases(apple), ...playReleases(play)]; diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 86730e3..9210f58 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -21,7 +21,8 @@ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { extname, join, resolve } from 'node:path'; import type { Browser, Page } from 'playwright'; -import { APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS } from '../apps/desktop/src/dev/submit-fixtures'; +import { APPLE_STATUS_FIRST_REVIEW, APPLE_STATUS_READY, APPLE_STATUS_UNRESOLVED, ownerDry, PLAY_STATUS, PLAY_STATUS_FIRST_200, PLAY_STATUS_FIRST_404 } from '../apps/desktop/src/dev/submit-fixtures'; +import { appleReleases, playReleases, releaseTrains } from '../cli/lib/portfolio'; const ROOT = resolve(import.meta.dir, '..'); const APP = join(ROOT, 'apps/desktop'); @@ -494,6 +495,92 @@ async function runSubmitCase(browser: Browser, sidecar: Sidecar, base: 'd3-full' return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes }; } +/** + * First release, live-shape store state (pitfall 56): App Store "1.0" waiting for review, Play production "1.0.0" + * completed with the public store page 404 (not public yet) or 200. The last live portfolio read (cache_get + * portfolio.status) carries that state; the check reads the Releases tab and the portfolio by DOM text: one row for + * 1.0 / 1.0.0, and no "Live" for Play while the page is 404. + */ +async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404: boolean): Promise { + const name = `release-state (Play page ${page404 ? '404' : '200'})`; + const freeze = conf.app.security.freezePrototype === true; + const server = serve(DIST, cspHeader(conf.app.security.csp), false); + const ctx = await browser.newContext({ viewport: { width: 1360, height: 900 }, reducedMotion: 'reduce' }); + const page = await ctx.newPage(); + const problems: string[] = []; + const notes: string[] = []; + const unmocked = new Set(); + const logged: string[] = []; + const fx = liveFixture('d3-full'); + const play = page404 ? PLAY_STATUS_FIRST_404 : PLAY_STATUS_FIRST_200; + const live: LiveStores = { apple: APPLE_STATUS_FIRST_REVIEW, play, calls: [] }; + const base = host(fx, sidecar, unmocked, logged, live); + const ios = appleReleases(APPLE_STATUS_FIRST_REVIEW), android = playReleases(play); + const answer = async (cmd: string, a: Record): Promise => { + if (cmd === 'cache_get' && a.method === 'portfolio.status') { + const off = await base('portfolio_status', { live: false }) as { apps: Record[]; at: string }; + const apps = off.apps.map(x => ({ ...x, storeErrors: {}, release: { source: 'live', ...releaseTrains(ios, android), all: [...ios, ...android] } })); + return { value: { apps, at: new Date().toISOString() }, at: new Date(Date.now() - 60_000).toISOString() }; + } + return base(cmd, a); + }; + await page.exposeFunction('__bootCheckHost', async (cmd: string, args: Record) => { + try { return { ok: (await answer(cmd, args)) ?? null }; } catch (e) { return { err: e }; } + }); + page.on('pageerror', e => problems.push(`uncaught: ${e.stack ?? e.message}`.split('\n').slice(0, 4).join(' | '))); + page.on('console', m => { if (m.type() === 'error') problems.push(`console.error: ${m.text().slice(0, 400)}`); }); + await page.addInitScript(initScript(freeze)); + let visited = 0; + const text = async (sel = '#content') => (await page.locator(sel).first().innerText().catch(() => '')).replace(/\s+/g, ' '); + const crashCheck = async (where: string) => { for (const c of (await state(page)).crash) problems.push(`${where}: error panel ${c}`); }; + const SENT = 'Sent to Google (in review or not yet published)'; + try { + await page.goto(`http://127.0.0.1:${server.port}/`, { waitUntil: 'load' }); + await page.waitForFunction(() => document.documentElement.dataset.booted === '1', null, { timeout: WAIT_MS }).catch(() => problems.push('React never drew the window (no data-booted)')); + await settle(page); + const wizard = page.locator('.wizard[role="dialog"]'); + if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); } + // the portfolio: Play's cell names the state, never "Live" while the page is 404 + await shows(page, '#content', page404 ? SENT : '1.0.0'); + const pf = await text(); + if (page404 && !pf.includes(SENT)) problems.push(`Portfolio: no "${SENT}"`); + await crashCheck('Portfolio'); + visited++; + const item = page.locator('nav.side .nav-app').filter({ hasText: /Prism Puddle|demo-game/ }).first(); + await item.click({ timeout: WAIT_MS }).catch(e => problems.push(`could not open the app: ${String(e).split('\n')[0]}`)); + await settle(page); + await page.locator('[role="tab"]#tab-releases').click({ timeout: WAIT_MS }).catch(() => {}); + await shows(page, '#content', 'Versions on the stores'); + await settle(page); + const rows = await page.locator('#content table tbody tr').evaluateAll(rs => rs.map(r => Array.from(r.querySelectorAll('td')).map(td => (td.textContent ?? '').trim()))); + notes.push(`history: ${JSON.stringify(rows)}`); + if (rows.length !== 1) problems.push(`Releases: want one row for 1.0 / 1.0.0, got ${rows.length}: ${JSON.stringify(rows)}`); + const row = rows[0] ?? []; + if (row[0] !== '1.0.0 / 1.0') problems.push(`Releases: version cell "${row[0]}" (want "1.0.0 / 1.0")`); + if (row[1] !== 'In review') problems.push(`Releases: App Store cell "${row[1]}" (want "In review")`); + if (row[3] !== (page404 ? SENT : 'Live')) problems.push(`Releases: Google Play cell "${row[3]}" (want "${page404 ? SENT : 'Live'}")`); + const rel = await text(); + if (/is not on (App Store|Google Play) yet/.test(rel)) problems.push('Releases: a lane says the version is not on a store (1.0 and 1.0.0 not joined)'); + for (const w of ['1.0 (3)', '1.0.0 (3)']) if (!rel.includes(w)) problems.push(`Releases: the lane does not show the store's own "${w}"`); + if (page404) { + if (!rel.includes(SENT)) problems.push(`Releases: no "${SENT}"`); + const heads = await page.locator('#content .train-head').evaluateAll(hs => hs.map(h => (h.textContent ?? '').trim())); + notes.push(`trains: ${JSON.stringify(heads)}`); + if (heads.some(h => /Live/.test(h))) problems.push(`Releases: a "Live" train with Play page 404: ${JSON.stringify(heads)}`); + } + await crashCheck('Releases'); + visited++; + } catch (e) { + problems.push(`case stopped: ${String(e).split('\n')[0]}`); + } finally { + await ctx.close(); + server.stop(true); + } + for (const l of logged) (l.startsWith('error') ? problems : notes).push(`webview.log: ${l}`); + if (unmocked.size) notes.push(`not answered (the window handled the rejection): ${[...unmocked].join(', ')}`); + return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes }; +} + /** * One headless browser for every case, as a browser server that is KILLED at the end (a throwaway profile, nothing to * flush): Google Chrome's graceful close takes 10-30 s after http pages (tests/helpers/browser.ts). Playwright's @@ -527,6 +614,7 @@ export async function bootCheck(): Promise { if (!ONLY.length || ONLY.includes('missing-bundle')) cases.push(() => runCase(browser, 'missing-bundle', sidecar, { breakBundle: true })); for (const base of ['d3-full', 'd1-missing-folders'] as const) for (const v of ['ready', 'unresolved'] as const) if (!ONLY.length || ONLY.includes('submit') || ONLY.includes(`submit-${v}`)) cases.push(() => runSubmitCase(browser, sidecar, base, v)); + for (const p404 of [true, false]) if (!ONLY.length || ONLY.includes('release-state')) cases.push(() => runReleaseStateCase(browser, sidecar, p404)); const results: CaseResult[] = new Array(cases.length); let next = 0; try {