From be9ff8b78800a81d9069af8c9128cde128aba5e2 Mon Sep 17 00:00:00 2001 From: CRT Date: Tue, 13 Oct 2026 20:46:48 +0800 Subject: [PATCH] Play 'Live' only when the public store page proves it: play status checks https://play.google.com/store/apps/details?id= (no auth, 4 s timeout, cached 5 min; publicPage in the JSON), a completed production release with a 404 page is 'Sent to Google (in review or not yet published)', 200 is live for the first release or when the page shows the versionName, otherwise 'Released to production (Google may still be reviewing)', offline is unknown; new phases sent / released in the desktop Releases tab, Portfolio, release status | plan; versions matched across the stores without trailing .0 parts (App Store 1.0 and Play 1.0.0 are one row and one train, each side shows its own string); tests/play-public-version-match.test.ts, desktop boot check release-state cases (page 404 and 200) (pitfall 56) --- CHANGELOG.md | 12 +++ apps/desktop/src/dev/submit-fixtures.ts | 20 ++++ apps/desktop/src/i18n/en.ts | 4 + apps/desktop/src/i18n/tr.ts | 4 + apps/desktop/src/lib/notify.ts | 5 +- apps/desktop/src/screens/tabs/Releases.tsx | 9 +- apps/desktop/src/ui/kit.tsx | 3 +- cli/lib/desktop-protocol.ts | 7 +- cli/lib/play-public.ts | 63 +++++++++++ cli/lib/portfolio.ts | 74 +++++++++++-- cli/lib/train.ts | 29 +++-- cli/play/status.ts | 8 +- cli/release.ts | 4 +- skills/store-pitfalls/SKILL.md | 11 ++ tests/play-public-version-match.test.ts | 119 +++++++++++++++++++++ tests/portfolio.test.ts | 3 +- tests/release-train.test.ts | 1 + tools/desktop-boot-check.ts | 90 +++++++++++++++- 18 files changed, 436 insertions(+), 30 deletions(-) create mode 100644 cli/lib/play-public.ts create mode 100644 tests/play-public-version-match.test.ts 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 {