Play edits under a machine-wide per-package lock (parallel reads of one app no longer delete each other's edits; a read whose edit was deleted is redone once, writes are not); a store that failed to read is 'Couldn't read Google Play' and keeps its last good releases with 'couldn't refresh'; stores on different versions: neutral 'Current · Play x · App Store y' header and each lane its own state, 'Not on this store' only without a record; Play status decided from the public store page's live version (Live / In review / In review (live: x) / managed publishing / Rolling out / Halted / Draft; only a network failure says 'Couldn't check the store page', with the last known answer and its time); tests/play-edit-lock.test.ts, recorded page fixture, boot check auto-read cases (pitfall 58)
This commit is contained in:
parent
e59f439101
commit
714352ec18
24 changed files with 570 additions and 103 deletions
14
CHANGELOG.md
14
CHANGELOG.md
|
|
@ -2,6 +2,20 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
- **Parallel Play reads no longer break each other.** Google Play keeps one open edit per app, so two reads of one
|
||||
app at once (the desktop's auto-read) deleted each other's edits ("This Edit has been deleted") and the Releases tab
|
||||
then said "not on Google Play yet". Every Play edit of a package now runs under a machine-wide per-package lock
|
||||
(`~/.storebound/locks`, wait with timeout and heartbeat; other packages stay parallel), and a read whose edit was
|
||||
deleted is redone once with a new edit (writes are not). A store that failed to read shows "Couldn't read Google
|
||||
Play" and keeps its last good releases with "couldn't refresh". When the stores' current versions differ, the card
|
||||
header says "Current · Play 4.1.9 · App Store 4.1.2" and each lane shows its own state ("4.1.2 · Live (4.1.9 not
|
||||
submitted)"); "Not on this store" only for an app with no record there (pitfall 58).
|
||||
- **Google Play status is decided, never "maybe".** `play status`, the portfolio and the desktop read the live version
|
||||
from the public store page ("Version" in About this app): the same version = Live, 404 = In review (managed
|
||||
publishing: In review / ready to publish), an older one = In review (live: 1.0.0), Rolling out N% / Halted / Draft
|
||||
from the API. Only a network failure says "Couldn't check the store page", and it keeps the last known answer with
|
||||
its time. "In review or not yet published" and "Google may still be reviewing" are gone.
|
||||
|
||||
- **Desktop reads everything by itself.** No more "Not read yet" and a Read button on every page: on start, when the
|
||||
window shows again and every 15 minutes while it is open (Settings: auto-read on / off, interval), every app's
|
||||
status, releases and submission state, TestFlight, catalog, prices, listings, health, reviews, analytics and checks
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ const STORE_APPS: Record<string, StoreApp[]> = {
|
|||
droid('com.example.nightnotes.free', 'Nightnotes Free', G.night), droid('com.example.moodgrid', 'Mood Grid', G.mood),
|
||||
],
|
||||
[A1]: [
|
||||
ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.1.0', 'READY_FOR_SALE']),
|
||||
ios('6400000001', 'com.example.prismpuddle', 'Prism Puddle', null, ['2.4.0', 'WAITING_FOR_REVIEW']), ios('6400000011', 'com.example.orbithop', 'Orbit Hop', G.orbit, ['3.0.4', 'READY_FOR_SALE']),
|
||||
ios('6400000012', 'com.example.blockgarden.ios', 'Block Garden', G.block, ['1.2.0', 'IN_REVIEW']), ios('6400000013', 'com.example.wordlantern', 'Word Lantern', G.lantern, ['2.0.1', 'READY_FOR_SALE']),
|
||||
ios('6400000014', 'com.example.oldracer', 'Old Racer', G.racer, ['1.9.3', 'REMOVED_FROM_SALE']),
|
||||
],
|
||||
|
|
@ -166,7 +166,8 @@ function status(id: string, live: boolean): AppStatus {
|
|||
}
|
||||
/** Store-only app releases (fixture), by bundle id / package name. */
|
||||
const STORE_RELEASES: Record<string, StoreRelease[]> = {
|
||||
'com.example.orbithop': [rel('ios', '3.1.0', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })],
|
||||
// the stores do not always move together: Play is on 3.1.0, the App Store still on 3.0.4
|
||||
'com.example.orbithop': [rel('ios', '3.0.4', 'live', { build: '88', state: 'READY_FOR_SALE', date: iso(60 * 24 * 6) }), rel('android', '3.1.0', 'rollout', { build: '310', state: 'inProgress', fraction: 0.5 })],
|
||||
'com.example.blockgarden.ios': [rel('ios', '1.2.0', 'review', { build: '17', state: 'IN_REVIEW', date: iso(60 * 5) }), rel('ios', '1.1.2', 'live', { build: '15', state: 'READY_FOR_SALE', date: iso(60 * 24 * 40) })],
|
||||
'com.example.blockgarden': [rel('android', '1.2.0', 'live', { build: '120', state: 'completed' })],
|
||||
'com.example.wordlantern': [rel('ios', '2.0.1', 'live', { build: '41', state: 'READY_FOR_SALE', date: iso(60 * 24 * 18) })],
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ export const PLAY_STATUS = {
|
|||
|
||||
// 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.
|
||||
// store page check (publicPage): 404 = not public yet, 200 = published with the page's live version.
|
||||
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')],
|
||||
|
|
@ -119,7 +119,7 @@ const playFirst = (state: 'public' | 'notPublic') => ({
|
|||
{ 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' },
|
||||
publicPage: { state, http: state === 'public' ? 200 : 404, versionName: state === 'public' ? '1.0.0' : 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');
|
||||
|
|
|
|||
|
|
@ -118,9 +118,13 @@ 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.sent': 'In review',
|
||||
'phase.released': "Couldn't check the store page",
|
||||
'phase.releasedUnknown': "Couldn't check the store page",
|
||||
'phase.pageUnknown': "Couldn't check the store page",
|
||||
'phase.pageStale': "couldn't check the store page, as of {when}",
|
||||
'phase.reviewLive': 'In review (live: {version})',
|
||||
'phase.reviewManaged': 'In review / ready to publish (managed publishing)',
|
||||
'phase.rejected': 'Rejected',
|
||||
'phase.testing': 'Testing',
|
||||
'phase.draft': 'Draft',
|
||||
|
|
@ -236,6 +240,12 @@ export const en = {
|
|||
'tl.store': 'Store',
|
||||
'tl.created': 'Created {date}',
|
||||
'tl.notOnStore': 'Not on this store',
|
||||
'tl.unknown': 'Unknown',
|
||||
'tl.unreadable': "Couldn't read {store}: state unknown",
|
||||
'tl.otherVersion': '{now} · {phase} ({version} not submitted)',
|
||||
'tl.notThisVersion': 'Not this version',
|
||||
'tl.current': 'Current',
|
||||
'tl.bothVersions': 'Play {play} · App Store {ios}',
|
||||
'tl.noVersion': '{version} is not on {store} yet',
|
||||
'tl.everyone': 'Everyone',
|
||||
'tl.day': 'day {day} of 7',
|
||||
|
|
@ -245,6 +255,7 @@ export const en = {
|
|||
'releases.notReadText': 'The timeline shows each version on both stores: build, TestFlight or internal testing, review, live and the rollout share. Read the stores once to fill it.',
|
||||
'releases.noAccounts': 'Tip: map store accounts to this app in App folders first.',
|
||||
'releases.storeUnreadable': '{store} could not be read, so its lane shows as unknown: {error}',
|
||||
'releases.storeStale': "Couldn't refresh {store}: showing the read from {when}. {error}",
|
||||
'releases.noVersions': 'No versions on the stores yet',
|
||||
'releases.noVersionsText': 'When the first build is uploaded it shows here.',
|
||||
'releases.history': 'Versions on the stores',
|
||||
|
|
|
|||
|
|
@ -120,9 +120,13 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'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.sent': 'İncelemede',
|
||||
'phase.released': 'Mağaza sayfası kontrol edilemedi',
|
||||
'phase.releasedUnknown': 'Mağaza sayfası kontrol edilemedi',
|
||||
'phase.pageUnknown': 'Mağaza sayfası kontrol edilemedi',
|
||||
'phase.pageStale': 'mağaza sayfası kontrol edilemedi, {when} itibarıyla',
|
||||
'phase.reviewLive': 'İncelemede (yayında: {version})',
|
||||
'phase.reviewManaged': 'İncelemede / yayına hazır (yönetilen yayınlama)',
|
||||
'phase.rejected': 'Reddedildi',
|
||||
'phase.testing': 'Testte',
|
||||
'phase.draft': 'Taslak',
|
||||
|
|
@ -238,6 +242,12 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'tl.store': 'Mağaza',
|
||||
'tl.created': 'Oluşturma: {date}',
|
||||
'tl.notOnStore': 'Bu mağazada yok',
|
||||
'tl.unknown': 'Bilinmiyor',
|
||||
'tl.unreadable': '{store} okunamadı: durum bilinmiyor',
|
||||
'tl.otherVersion': '{now} · {phase} ({version} gönderilmedi)',
|
||||
'tl.notThisVersion': 'Bu sürüm değil',
|
||||
'tl.current': 'Güncel',
|
||||
'tl.bothVersions': 'Play {play} · App Store {ios}',
|
||||
'tl.noVersion': '{version} henüz {store} mağazasında yok',
|
||||
'tl.everyone': 'Herkes',
|
||||
'tl.day': '7 günün {day}. günü',
|
||||
|
|
@ -247,6 +257,7 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'releases.notReadText': 'Zaman çizelgesi her sürümü iki mağazada birlikte gösterir: derleme, TestFlight ya da dahili test, inceleme, yayın ve dağıtım payı. Doldurmak için mağazaları bir kez okuyun.',
|
||||
'releases.noAccounts': 'İpucu: önce Uygulama klasörleri ekranında bu uygulamaya mağaza hesaplarını bağlayın.',
|
||||
'releases.storeUnreadable': '{store} okunamadı, bu yüzden şeridi bilinmiyor olarak gösteriliyor: {error}',
|
||||
'releases.storeStale': '{store} yenilenemedi: {when} okunan durum gösteriliyor. {error}',
|
||||
'releases.noVersions': 'Mağazalarda henüz sürüm yok',
|
||||
'releases.noVersionsText': 'İlk derleme yüklendiğinde burada görünür.',
|
||||
'releases.history': 'Mağazalardaki sürümler',
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@
|
|||
// as console plans. Releasing an approved version (manual release) stays in App Store Connect.
|
||||
import { plainError } from '../../../../../cli/lib/google-errors';
|
||||
import { useState } from 'react';
|
||||
import type { ReleaseTrain, StoreRelease } from '../../api';
|
||||
import type { AppStatus, ReleaseTrain, StoreRelease } from '../../api';
|
||||
import { laneGap, storesDiffer } from '../../../../../cli/lib/train';
|
||||
import type { UnifiedApp } from '../../lib/identity';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { type Key, t } from '../../i18n';
|
||||
|
|
@ -13,7 +14,7 @@ import { type Lane, STAGES, type TestFlightStatus, trainLanes, versionHistory }
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { Icon, StoreGlyph } from '../../ui/icons';
|
||||
import { RolloutBar } from '../../ui/charts';
|
||||
import { Card, Chip, Empty, Notice, PhaseChip, Skeleton, storeName } from '../../ui/kit';
|
||||
import { Card, Chip, Empty, Notice, PhaseChip, phaseLabel, Skeleton, storeName } from '../../ui/kit';
|
||||
import { AppleSubmitCard, PlayProductionCard } from './Submit';
|
||||
|
||||
const STAGE_KEY: Record<(typeof STAGES)[number], Key> = { build: 'stage.build', testing: 'stage.testing', review: 'stage.review', live: 'stage.live', rollout: 'stage.rollout' };
|
||||
|
|
@ -22,23 +23,35 @@ function stageCaption(l: Lane, i: number): string {
|
|||
if (i !== l.at || !l.release) return '';
|
||||
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');
|
||||
if (i === 2) return r.store === 'android' ? phaseLabel(r).text : t(`phase.${r.phase}` as Key);
|
||||
if (i === 3 && r.phase === 'released') return phaseLabel(r).text;
|
||||
return '';
|
||||
}
|
||||
|
||||
function LaneRow({ lane, version }: { lane: Lane; version: string }) {
|
||||
/** The text of a lane that does not carry this version: couldn't read, the store's own current version, not there yet,
|
||||
* or (only without any record of the app on that store) "Not on this store". */
|
||||
function gapText(lane: Lane, version: string, st: AppStatus): { who: string; line: string; kind: string } {
|
||||
const g = laneGap(lane.store, st.release.all ?? [], st.storeErrors[lane.store]);
|
||||
const store = storeName(lane.store);
|
||||
if (g.kind === 'unreadable') return { kind: g.kind, who: t('tl.unknown'), line: t('tl.unreadable', { store }) };
|
||||
if (g.kind === 'other') return { kind: g.kind, who: g.now.version, line: t('tl.otherVersion', { now: g.now.version, phase: t(`phase.${g.now.phase}` as Key), version }) };
|
||||
if (g.kind === 'missing') return { kind: g.kind, who: t('tl.notThisVersion'), line: t('tl.noVersion', { version, store }) };
|
||||
return { kind: g.kind, who: t('tl.notOnStore'), line: t('tl.notOnStore') };
|
||||
}
|
||||
|
||||
function LaneRow({ lane, version, st }: { lane: Lane; version: string; st: AppStatus }) {
|
||||
const r = lane.release;
|
||||
const gap = lane.at < 0 ? gapText(lane, version, st) : null;
|
||||
return (
|
||||
<div className="tl-lane">
|
||||
<div className="tl-who">
|
||||
<StoreGlyph store={lane.store} />
|
||||
<div>
|
||||
<div className="w1">{storeName(lane.store)}</div>
|
||||
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : t('tl.notOnStore')}</div>
|
||||
<div className="w2">{r ? `${r.version ?? version}${r.build ? ` (${r.build})` : ''}` : gap!.who}</div>
|
||||
</div>
|
||||
</div>
|
||||
{lane.at < 0 ? <div className="tl-none">{t('tl.noVersion', { version, store: storeName(lane.store) })}</div> : STAGES.map((s, i) => {
|
||||
{gap ? <div className="tl-none" data-gap={gap.kind}>{gap.line}</div> : STAGES.map((s, i) => {
|
||||
const cls = i < lane.at ? 'done' : i === lane.at ? `cur ${lane.tone}` : '';
|
||||
const last = s === 'rollout';
|
||||
return (
|
||||
|
|
@ -102,16 +115,19 @@ function RolloutControls({ app, r }: { app: UnifiedApp; r: StoreRelease }) {
|
|||
return null;
|
||||
}
|
||||
|
||||
function Train({ title, train, tf, tone, app }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp }) {
|
||||
function Train({ title, train, tf, tone, app, st }: { title: string; train: ReleaseTrain; tf: TestFlightStatus | null; tone: 'accent' | 'ok'; app: UnifiedApp; st: AppStatus }) {
|
||||
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;
|
||||
// the stores' current versions differ (normal: they do not always move together): a neutral "Current" header
|
||||
const differ = tone === 'ok' ? storesDiffer(st.release.all ?? []) : null;
|
||||
// "Live" only when a store has it live or rolling out; a Play release whose store page could not be checked is not
|
||||
const label = differ ? t('tl.current') : tone === 'ok' && !lanes.some(l => l.release && ['live', 'rollout', 'halted'].includes(l.release.phase)) ? t('tl.released') : title;
|
||||
const ver = differ ? t('tl.bothVersions', { play: differ.android, ios: differ.ios }) : train.version;
|
||||
return (
|
||||
<Card pad={false} title={<span className="train-head"><span className="ver">{train.version}</span><Chip tone={tone === 'ok' && label !== title ? 'info' : tone}>{label}</Chip></span>}
|
||||
<Card pad={false} title={<span className="train-head"><span className="ver">{ver}</span><Chip tone={differ ? 'neutral' : tone === 'ok' && label !== title ? 'info' : tone}>{label}</Chip></span>}
|
||||
sub={[train.ios?.date ? t('tl.created', { date: date(train.ios.date) }) : null].filter(Boolean).join(' · ') || undefined}>
|
||||
<div className="tl" style={{ paddingTop: 12 }}>
|
||||
<div className="tl-head"><span>{t('tl.store')}</span>{STAGES.map(s => <span key={s}>{t(STAGE_KEY[s])}</span>)}</div>
|
||||
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} />)}
|
||||
{lanes.map(l => <LaneRow key={l.store} lane={l} version={train.version} st={st} />)}
|
||||
</div>
|
||||
{lanes.some(l => l.release) && app.project && (
|
||||
<div className="card-foot roll-foot">
|
||||
|
|
@ -157,9 +173,12 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) {
|
|||
const errs = Object.entries(l.storeErrors);
|
||||
return (
|
||||
<>
|
||||
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>)}
|
||||
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} />}
|
||||
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} />}
|
||||
{errs.map(([s, msg]) => {
|
||||
const when = l.staleStores?.[s as 'ios'];
|
||||
return <Notice key={s} tone="warn">{when ? t('releases.storeStale', { store: storeName(s as 'ios'), when: ago(when), error: plainError(msg) }) : t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>;
|
||||
})}
|
||||
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} st={l} />}
|
||||
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} st={l} />}
|
||||
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
|
||||
|
||||
<Card title={t('releases.history')} icon="layers" sub={t('releases.historySub', { when: ago(d.live?.at ?? d.portfolio?.at) })} pad={false}>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import { keepLines, progressOf } from './lib/progress';
|
|||
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor } from './lib/freshness';
|
||||
import { type Outcome, type Priority, ReadScheduler, type ReadTask, type SchedulerStats } from './lib/scheduler';
|
||||
import { fixHint } from './lib/views';
|
||||
import { withLastGood } from '../../../cli/lib/train';
|
||||
import { failureText, usable } from './lib/results';
|
||||
|
||||
export type ToastKind = 'job' | 'ok' | 'bad' | 'info';
|
||||
|
|
@ -201,7 +202,10 @@ export function DataProvider({ children }: { children: ReactNode }) {
|
|||
const tid = liveRead && !quiet ? toast({ id: job, kind: 'job', title: t('toast.readingStores'), detail: t('toast.readingStoresDetail'), progress: null, job }) : null;
|
||||
try {
|
||||
const stores = liveRead ? appsRef.current.filter(a => !a.project && !a.ignored).map(a => a.target) : [];
|
||||
const r = await api.portfolio(liveRead, job, stores);
|
||||
let r = await api.portfolio(liveRead, job, stores);
|
||||
// a store that failed this time keeps its last good releases ("couldn't refresh"), never turns empty
|
||||
const prevLive = liveRef.current;
|
||||
if (liveRead && prevLive) r = { ...r, apps: r.apps.map(a => withLastGood(prevLive.value.apps.find(x => x.project === a.project), a, prevLive.at)) };
|
||||
setPortfolio(r);
|
||||
if (liveRead) {
|
||||
const changes = releaseChanges(liveRef.current?.value, r);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,11 @@ 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' };
|
||||
const stale = r.pageStaleAt ? ` · ${t('phase.pageStale', { when: ago(r.pageStaleAt) })}` : '';
|
||||
if (r.store === 'android' && r.phase === 'released') return { text: t('phase.pageUnknown') + stale, tone: 'neutral' };
|
||||
if (r.store === 'android' && (r.phase === 'review' || r.phase === 'sent'))
|
||||
return { text: (r.managed ? t('phase.reviewManaged') : r.liveVersion ? t('phase.reviewLive', { version: r.liveVersion }) : t('phase.review')) + stale, tone: 'warn' };
|
||||
if (r.store === 'android' && r.phase === 'live' && stale) return { text: t('phase.live') + stale, tone: 'ok' };
|
||||
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] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -159,9 +159,9 @@ export interface AppIdentity {
|
|||
}
|
||||
|
||||
/** Where a version is on one store. */
|
||||
/** `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). */
|
||||
/** Google Play production (completed in the API) is decided by the public store page: its version = `live`, 404 or an
|
||||
* older live version = `review` (with `liveVersion` / `managed`), page never read (network) = `released` with
|
||||
* publicPage unknown ("Couldn't check the store page"). `sent` is no longer produced (older cached reads). */
|
||||
export type ReleasePhase = 'live' | 'rollout' | 'halted' | 'released' | 'approved' | 'review' | 'sent' | 'rejected' | 'testing' | 'draft' | 'other';
|
||||
|
||||
export interface StoreRelease {
|
||||
|
|
@ -180,6 +180,12 @@ export interface StoreRelease {
|
|||
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';
|
||||
/** Google Play production in review while the store page shows an older live version: that version. */
|
||||
liveVersion?: string | null;
|
||||
/** Google Play production in review with managed publishing on (play.managedPublishing): approval is not readable. */
|
||||
managed?: boolean;
|
||||
/** The store page could not be read now: the phase is from the last known page read at this time. */
|
||||
pageStaleAt?: string | null;
|
||||
/** When the store record was made (iOS version createdDate), if the store says. */
|
||||
date?: string | null;
|
||||
}
|
||||
|
|
@ -216,6 +222,8 @@ export interface AppStatus {
|
|||
virtual?: boolean;
|
||||
/** live only: per-platform read errors ("unknown, never false"). */
|
||||
storeErrors: { ios?: string; android?: string };
|
||||
/** A store that could not be read this time, shown from the last good read (its time): couldn't refresh. */
|
||||
staleStores?: { ios?: string; android?: string };
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,9 +3,10 @@
|
|||
// same app at once and race each other into duplicates. Dry runs and reads take no lock.
|
||||
// A lock whose process is gone (same machine) is taken over; a live one stops the run with who holds it. A lock
|
||||
// left by a crash on another machine (shared folder) stays until --force-unlock or 12 hours.
|
||||
import { hostname } from 'node:os';
|
||||
import { mkdirSync, openSync, readFileSync, rmSync, writeSync, closeSync } from 'node:fs';
|
||||
import { homedir, hostname } from 'node:os';
|
||||
import { mkdirSync, openSync, readFileSync, rmSync, statSync, utimesSync, writeSync, closeSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
import type { ShipConfig } from './config';
|
||||
import type { Args } from './args';
|
||||
|
||||
|
|
@ -58,6 +59,66 @@ export function acquireLock(root: string, key: string, command: string, o: { for
|
|||
return () => { rmSync(path, { force: true }); held.delete(path); };
|
||||
}
|
||||
|
||||
// ---- Machine-wide short locks (Play edits) ----
|
||||
// Google Play keeps one open edit per app: a second edits.insert for the same package invalidates the first ("This
|
||||
// Edit has been deleted"). Every storebound process that opens an edit (reads included) takes
|
||||
// ~/.storebound/locks/<key>.lock first, so parallel reads of one package wait for each other; other packages run in
|
||||
// parallel. The holder touches the file every few seconds (heartbeat); a file whose process is gone or that was not
|
||||
// touched for STALE_SHORT_MS is taken over. Waiting has a timeout and logs who it waits for.
|
||||
|
||||
export const machineLockDir = (): string => process.env.STOREBOUND_LOCK_DIR || join(homedir(), '.storebound', 'locks');
|
||||
const STALE_SHORT_MS = 60_000;
|
||||
|
||||
/** Whether a short lock may be taken over: unreadable, its process gone (same host) or not touched for a minute. */
|
||||
export function staleShortLock(l: LockInfo | null, touchedAt: number, now = Date.now(), host = hostname(), isAlive = alive): boolean {
|
||||
if (!l) return now - touchedAt > 2000; // half written: give the writer a moment
|
||||
if (now - touchedAt > STALE_SHORT_MS) return true;
|
||||
return l.host === host && !isAlive(l.pid);
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
||||
/** The machine locks held by the current async call chain: a nested take of the same key runs inside the outer one
|
||||
* (it would otherwise wait for itself), while a parallel caller in the same process still waits. */
|
||||
const inside = new AsyncLocalStorage<Set<string>>();
|
||||
|
||||
/**
|
||||
* Runs fn while holding the machine-wide lock `key` (e.g. `play-edit-<package>`). Waits up to `timeoutMs` for another
|
||||
* holder (polling, a heartbeat line every `heartbeatMs` through `onWait`), then throws with the holder.
|
||||
*/
|
||||
export async function withMachineLock<T>(key: string, command: string, fn: () => Promise<T>,
|
||||
o: { timeoutMs?: number; pollMs?: number; heartbeatMs?: number; onWait?: (msg: string) => void } = {}): Promise<T> {
|
||||
if (inside.getStore()?.has(key)) return fn();
|
||||
const dir = machineLockDir();
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const path = join(dir, `${key.replace(/[^A-Za-z0-9._-]+/g, '_')}.lock`);
|
||||
const timeout = o.timeoutMs ?? 90_000, poll = o.pollMs ?? 100, beat = o.heartbeatMs ?? 10_000;
|
||||
const start = Date.now();
|
||||
let lastBeat = start;
|
||||
for (;;) {
|
||||
try {
|
||||
const fd = openSync(path, 'wx', 0o600);
|
||||
writeSync(fd, JSON.stringify({ pid: process.pid, host: hostname(), command, startedAt: new Date().toISOString() } satisfies LockInfo) + '\n');
|
||||
closeSync(fd);
|
||||
break;
|
||||
} catch (e) {
|
||||
if ((e as NodeJS.ErrnoException).code !== 'EEXIST') throw e;
|
||||
let cur: LockInfo | null = null, touched = Date.now();
|
||||
try { touched = statSync(path).mtimeMs; cur = JSON.parse(readFileSync(path, 'utf8')) as LockInfo; } catch { /* gone or half written */ }
|
||||
if (staleShortLock(cur, touched)) { rmSync(path, { force: true }); continue; }
|
||||
const waited = Date.now() - start;
|
||||
const who = cur ? `\`storebound ${cur.command}\` (pid ${cur.pid})` : 'another storebound run';
|
||||
if (waited > timeout) throw new Error(`timed out after ${Math.round(waited / 1000)} s waiting for ${key}: ${who} still holds it (Google Play allows one open edit per app). Try again when it is done.`);
|
||||
if (Date.now() - lastBeat >= beat) { lastBeat = Date.now(); o.onWait?.(`waiting ${Math.round(waited / 1000)} s for ${key}, held by ${who}`); }
|
||||
await sleep(poll);
|
||||
}
|
||||
}
|
||||
held.add(path);
|
||||
if (!hooked) { hooked = true; process.on('exit', () => { for (const p of held) rmSync(p, { force: true }); }); }
|
||||
const hb = setInterval(() => { try { const t = new Date(); utimesSync(path, t, t); } catch { /* removed */ } }, 5000);
|
||||
(hb as { unref?: () => void }).unref?.();
|
||||
try { return await inside.run(new Set([...(inside.getStore() ?? []), key]), fn); } finally { clearInterval(hb); rmSync(path, { force: true }); held.delete(path); }
|
||||
}
|
||||
|
||||
/** Read-only subcommands and flags: no lock. */
|
||||
const READ_SUBS = new Set(['status', 'list', 'devices', 'diff', 'export', 'pull', 'wait', 'options', 'coverage', 'report', 'campaigns', 'check', 'help']);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
// 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.
|
||||
// "completed" from the moment it is sent, also while Google reviews it. The public store page answers it: 404 = not
|
||||
// public yet (a first release in review), 200 = published, and the page's "Version" (About this app) is the live
|
||||
// versionName, so a completed release whose versionName is not the page's is in review. No auth, a fast timeout,
|
||||
// cached for a few minutes (in memory and in the temp folder, so short CLI runs share it). Only a network failure
|
||||
// gives "unknown"; it then returns the last known answer (any age) marked `stale` with the time it was read.
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
|
@ -11,25 +13,45 @@ 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). */
|
||||
/** The live versionName the page shows ("Version" in About this app); null when it shows none. */
|
||||
versionName: string | null;
|
||||
/** The page says "Varies with device" instead of a version (several APKs): the version cannot be compared. */
|
||||
varies?: boolean;
|
||||
/** A network failure: this is the last known answer from `checkedAt`, not a fresh one. */
|
||||
stale?: boolean;
|
||||
checkedAt: string;
|
||||
url: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en`;
|
||||
export const playPageUrl = (pkg: string): string => `https://play.google.com/store/apps/details?id=${encodeURIComponent(pkg)}&hl=en&gl=US`;
|
||||
|
||||
const TTL_MS = 5 * 60_000;
|
||||
const mem = new Map<string, PlayPublicPage>();
|
||||
const cacheFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.json`);
|
||||
const lastFile = (pkg: string): string => join(tmpdir(), 'storebound-play-public', `${pkg.replace(/[^\w.-]/g, '_')}.last.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;
|
||||
/**
|
||||
* The live versionName of a Play store page. The page embeds "About this app" in its AF_initDataCallback data blobs
|
||||
* (ds:5 in 2026-10) as `[[["0.8.1"]],[[[36]],[[[24,"7.0"]]]]]`: the version, then the target and minimum SDK with the
|
||||
* Android version. "Varies with device" stands in for the version when the app ships several APKs. Older pages had
|
||||
* schema.org "softwareVersion". Recorded shape: tests/fixtures/play-page-version.html.
|
||||
*/
|
||||
export function pageVersionInfo(html: string): { versionName: string | null; varies: boolean } {
|
||||
const blobs = [...html.matchAll(/AF_initDataCallback\(\{key: '(ds:\d+)'[\s\S]*?<\/script>/g)].map(m => m[0]);
|
||||
const about = /\[\[\["([^"\\]{1,60})"\]\],\[\[\[\d+\]\],\[\[\[\d+,"[^"\\]{0,20}"\]\]\]\]/;
|
||||
for (const b of blobs.length ? blobs : [html]) {
|
||||
const m = about.exec(b);
|
||||
if (!m) continue;
|
||||
if (/varies with device/i.test(m[1]!)) return { versionName: null, varies: true };
|
||||
if (/\d/.test(m[1]!)) return { versionName: m[1]!, varies: false };
|
||||
}
|
||||
const ld = html.match(/"softwareVersion"\s*:\s*"([^"]{1,40})"/);
|
||||
if (ld && /varies with device/i.test(ld[1]!)) return { versionName: null, varies: true };
|
||||
return { versionName: ld && /\d/.test(ld[1]!) ? ld[1]! : null, varies: false };
|
||||
}
|
||||
export const pageVersion = (html: string): string | null => pageVersionInfo(html).versionName;
|
||||
|
||||
/** 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<PlayPublicPage> {
|
||||
|
|
@ -46,7 +68,7 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac
|
|||
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 };
|
||||
if (http === 200) { const v = pageVersionInfo(await r.text()); out = { state: 'public', http, versionName: v.versionName, ...(v.varies ? { varies: true } : {}), 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) {
|
||||
|
|
@ -54,8 +76,14 @@ export async function playPublicPage(pkg: string, o: { timeoutMs?: number; noCac
|
|||
}
|
||||
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 */ }
|
||||
try { mkdirSync(join(tmpdir(), 'storebound-play-public'), { recursive: true }); writeFileSync(cacheFile(pkg), JSON.stringify(out)); writeFileSync(lastFile(pkg), JSON.stringify(out)); } catch { /* cache is optional */ }
|
||||
return out;
|
||||
}
|
||||
// a network failure: the last known answer with its time, never a guess
|
||||
try {
|
||||
const last = JSON.parse(readFileSync(lastFile(pkg), 'utf8')) as PlayPublicPage;
|
||||
if (last.url === url && last.state !== 'unknown') return { ...last, stale: true, error: out.error };
|
||||
} catch { /* none known */ }
|
||||
return out;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -105,20 +105,22 @@ export function testflightReleases(builds: J[], buildVersions: Record<string, st
|
|||
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.
|
||||
* Play tracks (`play status --json`) -> releases. The Developer API has no review state, so a completed production
|
||||
* release is decided by the public store page (`publicPage` in the status JSON): the page's version is this versionName
|
||||
* (or newer, or "Varies with device") = live; 404 = in review (with managed publishing: in review or ready to publish);
|
||||
* an older version on the page = in review, with the live one named. Only a network failure without any earlier page
|
||||
* read leaves it undecided ("released", publicPage unknown: "Couldn't check the store page"); with an earlier read the
|
||||
* phase comes from that read and `pageStaleAt` says when.
|
||||
*/
|
||||
export function playReleases(data: unknown): StoreRelease[] {
|
||||
const out: StoreRelease[] = [];
|
||||
const page = (data as J)?.publicPage as { state?: string; versionName?: string | null } | undefined;
|
||||
const page = (data as J)?.publicPage as { state?: string; versionName?: string | null; varies?: boolean; stale?: boolean; checkedAt?: string } | undefined;
|
||||
const managed = (data as J)?.managedPublishing === true;
|
||||
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);
|
||||
|
|
@ -129,12 +131,17 @@ export function playReleases(data: unknown): StoreRelease[] {
|
|||
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';
|
||||
const pv = page?.versionName ?? null;
|
||||
if (pageState === 'notPublic') { rel.phase = 'review'; if (managed) rel.managed = true; }
|
||||
else if (pageState === 'public') {
|
||||
// its version (or newer: this is an earlier one), or "Varies with device" (nothing to compare) = live; no
|
||||
// version found on a public page means the page changed shape: not checked, never guessed
|
||||
if (page?.varies || (pv && version && cmpVersion(pv, version) >= 0)) rel.phase = 'live';
|
||||
else if (!pv || !version) rel.phase = 'released';
|
||||
else { rel.phase = 'review'; rel.liveVersion = pv; if (managed) rel.managed = true; }
|
||||
} else rel.phase = 'released';
|
||||
rel.publicPage = pageState;
|
||||
if (page?.stale && pageState !== 'unknown') rel.pageStaleAt = page.checkedAt ?? null;
|
||||
}
|
||||
out.push(rel);
|
||||
}
|
||||
|
|
@ -142,12 +149,19 @@ export function playReleases(data: unknown): StoreRelease[] {
|
|||
return out;
|
||||
}
|
||||
|
||||
/** Plain words for a Play production release's phase (CLI text; the desktop has the same words in i18n). */
|
||||
/** Plain words for a Play release's phase, decided from the store page (CLI text; the desktop has the same words in
|
||||
* i18n, apps/desktop/src/ui/kit.tsx phaseLabel). */
|
||||
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;
|
||||
const base = ((): string => {
|
||||
if (r.phase === 'review' || r.phase === 'sent') return r.managed ? 'In review / ready to publish (managed publishing)' : r.liveVersion ? `In review (live: ${r.liveVersion})` : 'In review';
|
||||
if (r.phase === 'released') return "Couldn't check the store page";
|
||||
if (r.phase === 'live') return 'Live';
|
||||
if (r.phase === 'rollout') return `Rolling out ${Math.round((r.fraction ?? 0) * 1000) / 10}%`;
|
||||
if (r.phase === 'halted') return 'Halted';
|
||||
if (r.phase === 'draft') return 'Draft';
|
||||
return r.state;
|
||||
})();
|
||||
return r.pageStaleAt ? `${base} (couldn't check the store page now; as of ${r.pageStaleAt.slice(0, 16).replace('T', ' ')} UTC)` : base;
|
||||
}
|
||||
|
||||
export const PHASE_RANK: Record<ReleasePhase, number> = { live: 0, rollout: 1, halted: 2, released: 3, approved: 4, review: 5, sent: 6, rejected: 7, testing: 8, draft: 9, other: 10 };
|
||||
|
|
|
|||
|
|
@ -3,8 +3,8 @@
|
|||
// re-exports these) and `storebound release status | plan` (cli/release.ts) share this file, so both show the same
|
||||
// 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, displayVersion, PHASE_RANK, REACHING, sameVersion, versionKey } from './portfolio';
|
||||
import type { AppStatus, ReleaseTrain, StoreRelease } from './desktop-protocol';
|
||||
import { cmpVersion, displayVersion, PHASE_RANK, playReleaseLabel, REACHING, releaseTrains, sameVersion, versionKey } from './portfolio';
|
||||
import type { Finding } from './validate-rules';
|
||||
|
||||
export { cmpVersion, displayVersion, sameVersion, versionKey };
|
||||
|
|
@ -75,6 +75,54 @@ export function storeNow(all: StoreRelease[], store: 'ios' | 'android'): { now:
|
|||
return { now, next };
|
||||
}
|
||||
|
||||
/**
|
||||
* What a lane without this version shows, so a store is never called empty when it was not read or carries another
|
||||
* version: `unreadable` (the read failed and nothing is known: "couldn't read Google Play"), `other` (the store's own
|
||||
* current release is a different version: "4.1.2 · Live (4.1.9 not submitted)"), `missing` (the app is on the store,
|
||||
* this version is not yet) and `none` (no record of the app on that store at all: "Not on this store"). Pure.
|
||||
*/
|
||||
export type LaneGap = { kind: 'unreadable' } | { kind: 'other'; now: StoreRelease & { version: string } } | { kind: 'missing' } | { kind: 'none' };
|
||||
export function laneGap(store: 'ios' | 'android', all: StoreRelease[], storeError?: string | null): LaneGap {
|
||||
const mine = all.filter(r => r.store === store);
|
||||
if (!mine.length) return storeError ? { kind: 'unreadable' } : { kind: 'none' };
|
||||
const { now } = storeNow(all, store);
|
||||
if (now) return { kind: 'other', now: now as StoreRelease & { version: string } };
|
||||
return { kind: 'missing' };
|
||||
}
|
||||
|
||||
/** The stores' current (reaching users) versions when they differ: "Play 4.1.9 · App Store 4.1.2" for a neutral
|
||||
* header. Null when only one store has one or both have the same version. Pure. */
|
||||
export function storesDiffer(all: StoreRelease[]): { ios: string; android: string } | null {
|
||||
const i = storeNow(all, 'ios').now?.version, a = storeNow(all, 'android').now?.version;
|
||||
return i && a && !sameVersion(i, a) ? { ios: i, android: a } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* After a refresh in which a store could not be read, keep the store's releases from the previous good read (marked
|
||||
* by `staleStores`), so the lanes show the last known state with "couldn't refresh" instead of turning empty. Pure.
|
||||
*/
|
||||
export function keepLastGood(prev: StoreRelease[] | undefined, next: StoreRelease[], errors: { ios?: string; android?: string }): { all: StoreRelease[]; stale: ('ios' | 'android')[] } {
|
||||
const stale: ('ios' | 'android')[] = [];
|
||||
let all = next;
|
||||
for (const s of ['ios', 'android'] as const) {
|
||||
if (!errors[s] || next.some(r => r.store === s)) continue;
|
||||
const old = (prev ?? []).filter(r => r.store === s);
|
||||
if (old.length) { all = [...all, ...old]; stale.push(s); }
|
||||
}
|
||||
return { all, stale };
|
||||
}
|
||||
|
||||
/** keepLastGood over a whole status: the app's trains are rebuilt from the merged releases and `staleStores` names
|
||||
* each store shown from the earlier read (with that read's time). Returns `next` itself when nothing is kept. */
|
||||
export function withLastGood(prev: AppStatus | undefined, next: AppStatus, prevAt: string): AppStatus {
|
||||
if (next.release.source !== 'live' || !prev?.release.all?.length) return next;
|
||||
const { all, stale } = keepLastGood(prev.release.all, next.release.all ?? [], next.storeErrors);
|
||||
if (!stale.length) return next;
|
||||
const staleStores = { ...next.staleStores };
|
||||
for (const s of stale) staleStores[s] = prev.staleStores?.[s] ?? prevAt;
|
||||
return { ...next, release: { source: 'live', ...releaseTrains(all.filter(r => r.store === 'ios'), all.filter(r => r.store === 'android')), all }, staleStores };
|
||||
}
|
||||
|
||||
// ---- Terminal view of a lane (release status) ----
|
||||
|
||||
const pctText = (x: number | null): string => (x === null ? '' : `${Math.round(x * 1000) / 10}%`);
|
||||
|
|
@ -85,8 +133,7 @@ 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)';
|
||||
if (l.release.store === 'android' && (p === 'sent' || p === 'released' || (p === 'review' && l.release.track === 'production'))) return playReleaseLabel(l.release).replace(/^\w/, c => c.toLowerCase());
|
||||
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;
|
||||
|
|
@ -184,12 +231,14 @@ function storeSteps(store: 'ios' | 'android', v: string, r: StoreRelease | null,
|
|||
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' });
|
||||
steps.push({ kind: 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: 'storebound play status (reads the public store page)' });
|
||||
break;
|
||||
case 'review':
|
||||
if (store === 'android' && r!.track === 'production') {
|
||||
steps.push({ kind: r!.managed ? 'owner' : 'wait', text: `${v}: ${playReleaseLabel(r!)}`, fix: r!.managed ? 'Play Console > Publishing overview (publish once approved)' : 'storebound play status' });
|
||||
break;
|
||||
}
|
||||
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;
|
||||
case 'approved':
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@ import { errorBody } from '../lib/http';
|
|||
import { noteGoogle } from '../lib/quota';
|
||||
import { AmbiguousWriteError, isAmbiguous, retrySafe } from '../lib/upsert';
|
||||
import * as rt from '../lib/rt';
|
||||
import { withMachineLock } from '../lib/lock';
|
||||
|
||||
/** A read's edit is gone: replaced by a newer edit of the app, committed elsewhere or expired. */
|
||||
export const EDIT_GONE = /Edit has been deleted|editAlreadyCommitted|already been committed|edit.*(expired|not found)|outside of this Edit/i;
|
||||
|
||||
export type PlayClient = Awaited<ReturnType<typeof playClient>>;
|
||||
|
||||
|
|
@ -101,6 +105,11 @@ export async function playClient(root: string, packageName: string) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Google keeps one open edit per app; every edit (read or write) of this package, in any storebound process on
|
||||
* this machine, runs under one lock (cli/lib/lock.ts), so parallel reads never delete each other's edits. */
|
||||
const locked = <T>(what: string, fn: () => Promise<T>, timeoutMs: number): Promise<T> =>
|
||||
withMachineLock(`play-edit-${packageName}`, `play ${what} ${packageName}`, fn, { timeoutMs, onWait: m => log('play', m) });
|
||||
|
||||
return {
|
||||
API, UPLOAD, packageName, call,
|
||||
/**
|
||||
|
|
@ -108,8 +117,16 @@ export async function playClient(root: string, packageName: string) {
|
|||
* A failed delete is reported (Play drops an unused edit on its own after a while, but it should not linger).
|
||||
*/
|
||||
async read<T>(fn: (id: string) => Promise<T>): Promise<T> {
|
||||
const id = await openOne();
|
||||
try { return await fn(id); } finally { await closeOne(id, 'read-only'); }
|
||||
return locked('read', async () => {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
const id = await openOne();
|
||||
try { return await fn(id); } catch (err) {
|
||||
// the edit was replaced by another writer (Console, another tool) or expired: a read is safe to redo once
|
||||
if (attempt === 0 && EDIT_GONE.test(String(err))) { log('play', `read edit ${id} was invalidated, reading again with a new edit`); continue; }
|
||||
throw err;
|
||||
} finally { await closeOne(id, 'read-only'); }
|
||||
}
|
||||
}, 90_000);
|
||||
},
|
||||
/**
|
||||
* Opens an edit, runs fn, then commits (or validates and discards when dry). Any Console save or other edit commit
|
||||
|
|
@ -117,6 +134,7 @@ export async function playClient(root: string, packageName: string) {
|
|||
* redone, up to `retries` times. Keep edits small (e.g. one per language for images) so a retry is cheap.
|
||||
*/
|
||||
async edit<T>(fn: (id: string, ctl: EditCtl) => Promise<T>, o: { dry?: boolean; commitQuery?: string; retries?: number } = {}): Promise<T> {
|
||||
return locked(o.dry ? 'dry edit' : 'edit', async () => {
|
||||
for (let left = o.retries ?? 3; ; left--) {
|
||||
try { return await editOnce(fn, o); } catch (err) {
|
||||
// redo only what never went live: an edit invalidated before or at its commit, or a request inside the edit
|
||||
|
|
@ -127,6 +145,7 @@ export async function playClient(root: string, packageName: string) {
|
|||
log('play', `${outside ? 'app changed outside the edit (Console?)' : 'a request got no clear answer; the edit was discarded'}, redoing it (${left} retries left)`);
|
||||
}
|
||||
}
|
||||
}, 10 * 60_000);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
// 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, publicPage }); return; }
|
||||
if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products, publicPage, managedPublishing: config.play?.managedPublishing ?? null }); 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(', ') || '-'}`);
|
||||
|
|
@ -30,8 +30,8 @@ export async function run(ctx: Ctx): Promise<void> {
|
|||
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(` public store page: ${publicPage.state === 'public' ? `published (200), live version ${publicPage.versionName ?? (publicPage.varies ? 'varies with device' : 'not shown')}` : publicPage.state === 'notPublic' ? 'not public yet (404)' : `couldn't check it (${publicPage.error ?? 'not read'})`}${publicPage.stale ? ` (last known, ${publicPage.checkedAt.slice(0, 16).replace('T', ' ')} UTC)` : ''}`);
|
||||
for (const r of playReleases({ tracks, publicPage, managedPublishing: config.play?.managedPublishing ?? null }).filter(x => x.track === 'production')) 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(' ')}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -653,9 +653,11 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
## 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
|
||||
public store page (no auth, 4 s timeout, cached 5 minutes) and decides, never "maybe": the page's "Version" (About
|
||||
this app, read from its AF_initDataCallback data) equals the release's versionName = "Live"; 404 = "In review" (with
|
||||
play.managedPublishing: "In review / ready to publish (managed publishing)"); an older version on the page = "In
|
||||
review (live: <older>)"; "Varies with device" = live. Only a network failure gives "Couldn't check the store page",
|
||||
and then the last known page answer is used with its time. 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
|
||||
|
|
@ -671,3 +673,18 @@ that fails without the fix (see CONTRIBUTING.md).
|
|||
findings; a real failure shows the command's own error line, never a bare exit code.
|
||||
- **Guard:** `cli/lib/google-errors.ts`, `outcomeOf` / `errorLine` (`cli/lib/desktop-protocol.ts`),
|
||||
`tests/google-errors.test.ts`, the desktop boot check's `errors` cases (api-disabled, scan-findings, scan-failed).
|
||||
|
||||
## 58. Parallel Play reads deleted each other's edits, and a failed read said "not on Google Play yet"
|
||||
- **Do:** Google Play keeps one open edit per app: a second `edits.insert` for the same package deletes the first, so a
|
||||
read in it answers 400 "This Edit has been deleted". Every edit (read or write) of a package takes the machine-wide
|
||||
lock `~/.storebound/locks/play-edit-<package>.lock` (short wait with a heartbeat log, 90 s timeout for reads, 10 min
|
||||
for writes; stale when its process is gone or untouched for a minute); other packages stay parallel. A read whose
|
||||
edit was deleted, committed elsewhere or expired is redone once with a new edit; a write is never redone that way
|
||||
(it keeps its own invalidation retry). A store that could not be read is "couldn't read Google Play" (unknown),
|
||||
never "not on Google Play yet"; its last good releases stay with "couldn't refresh". When the stores' current
|
||||
versions differ (normal), each lane shows its own ("4.1.2 · Live (4.1.9 not submitted)") under a neutral "Current"
|
||||
header; "Not on this store" only when the store has no record of the app.
|
||||
- **Guard:** `withMachineLock` (`cli/lib/lock.ts`), `read()` / `edit()` (`cli/play/client.ts`),
|
||||
`laneGap` / `storesDiffer` / `withLastGood` (`cli/lib/train.ts`), `tests/play-edit-lock.test.ts` (a fake Play where a
|
||||
second insert invalidates the first), the desktop boot check's auto-read case (two Play reads of one app at once,
|
||||
Orbit Hop on different versions per store).
|
||||
|
|
|
|||
7
tests/fixtures/play-page-version.html
vendored
Normal file
7
tests/fixtures/play-page-version.html
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<!doctype html><html lang="en-US"><head><title>Example Game - Apps on Google Play</title></head><body>
|
||||
<!-- Recorded 2026-10-13 from the store page of a live app (version 0.8.1),
|
||||
trimmed: only the AF_initDataCallback blocks' shape around "About this app"; image URLs, other blobs and ids removed. -->
|
||||
<script nonce="n">AF_initDataCallback({key: 'ds:0', hash: '1', data:[[["en-US"]],null,[["Varies"]]], sideChannel: {}});</script>
|
||||
<script nonce="n">AF_initDataCallback({key: 'ds:3', hash: '7', data:[[[["1.0"]]],[null,[["Recent changes"]]]], sideChannel: {}});</script>
|
||||
<script nonce="n">AF_initDataCallback({key: 'ds:5', hash: '12', data:[[[[]]],[null,null,[["Example Game"],null,null,null,[null,[null,"You can request that data be deleted"]],1],null,null,null,[[["0.8.1"]],[[[36]],[[[24,"7.0"]]]]],null,null,null,[null,[null,"Example Game 0.8.1 (Alpha): bug fixes and stability improvements."]],[["Sep 30, 2026",[1790776977,376000000]]],null,null,[0,4,0,0],null,null,null,null,null,[null,[[],null,11]]],{"12":[["com.example.demo"]]}], sideChannel: {}});</script>
|
||||
</body></html>
|
||||
|
|
@ -13,6 +13,7 @@ process.env.STOREBOUND_LIST_SETTLE_MS ??= '0';
|
|||
const machine = mkdtempSync(join(tmpdir(), 'storebound-machine-'));
|
||||
process.env.STOREBOUND_CREDENTIALS_DIR = join(machine, 'credentials');
|
||||
process.env.STOREBOUND_REGISTRY = join(machine, 'projects.json');
|
||||
process.env.STOREBOUND_LOCK_DIR = join(machine, 'locks');
|
||||
|
||||
// Commands run in-process set process.exitCode on purpose (a check with problems, a declined confirmation:
|
||||
// DECLINED_EXIT in cli/lib/ctx.ts). Tests assert it where it matters; it must not leak into bun test's own exit code.
|
||||
|
|
|
|||
135
tests/play-edit-lock.test.ts
Normal file
135
tests/play-edit-lock.test.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
// Google Play keeps one open edit per app: a second edits.insert for the same package deletes the first, and a read in
|
||||
// the first edit then answers 400 "This Edit has been deleted." (seen live when the desktop's auto-read ran several Play
|
||||
// reads of one app at once). The fake below does exactly that; parallel reads of one package must all succeed, other
|
||||
// packages must still run in parallel, and a read whose edit was replaced is redone once with a new edit.
|
||||
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
||||
import { generateKeyPairSync } from 'node:crypto';
|
||||
import { mkdtempSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { playClient } from '../cli/play/client';
|
||||
import { withMachineLock } from '../cli/lib/lock';
|
||||
import { laneGap, storesDiffer, withLastGood } from '../cli/lib/train';
|
||||
import type { AppStatus, StoreRelease } from '../cli/lib/desktop-protocol';
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
const saved = process.env.PLAY_SERVICE_ACCOUNT;
|
||||
afterEach(() => { globalThis.fetch = realFetch; if (saved === undefined) delete process.env.PLAY_SERVICE_ACCOUNT; else process.env.PLAY_SERVICE_ACCOUNT = saved; });
|
||||
const { privateKey } = generateKeyPairSync('rsa', { modulusLength: 2048 });
|
||||
const KEY = JSON.stringify({ client_email: 'sb@example.com', private_key: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(), token_uri: 'https://oauth2.googleapis.com/token' });
|
||||
|
||||
beforeEach(() => { process.env.PLAY_SERVICE_ACCOUNT = KEY; });
|
||||
const json = (o: unknown, status = 200) => new Response(JSON.stringify(o), { status, headers: { 'content-type': 'application/json' } });
|
||||
const DELETED = { error: { code: 400, message: 'This Edit has been deleted.', status: 'INVALID_ARGUMENT' } };
|
||||
const wait = (ms: number) => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
/** Fake androidpublisher: one open edit per package; an insert replaces it. Counts the edits open at once per package. */
|
||||
function fakePlay(o: { delay?: number; replaceOnce?: string } = {}) {
|
||||
const open = new Map<string, string>();
|
||||
let n = 0;
|
||||
const peak = { total: 0, now: 0 };
|
||||
let replaced = false;
|
||||
globalThis.fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
const method = init?.method ?? 'GET';
|
||||
if (url.includes('oauth2')) return json({ access_token: 't', expires_in: 3600 });
|
||||
const m = /applications\/([^/]+)\/edits(?:\/([^/:]+))?(\/.*)?$/.exec(url);
|
||||
if (!m) return json({}, 404);
|
||||
const [, pkg, id, rest] = m as unknown as [string, string, string | undefined, string | undefined];
|
||||
if (method === 'POST' && !id) {
|
||||
const e = `e${++n}`; open.set(pkg, e);
|
||||
peak.now++; peak.total = Math.max(peak.total, peak.now);
|
||||
return json({ id: e });
|
||||
}
|
||||
if (method === 'DELETE') { peak.now--; if (open.get(pkg) === id) open.delete(pkg); return json({}); }
|
||||
await wait(o.delay ?? 15);
|
||||
if (o.replaceOnce === pkg && !replaced) { replaced = true; open.set(pkg, `e${++n}`); } // e.g. a Console save
|
||||
if (open.get(pkg) !== id) return json(DELETED, 400);
|
||||
return json({ ok: rest ?? '' });
|
||||
}) as typeof fetch;
|
||||
return peak;
|
||||
}
|
||||
|
||||
describe('Play edits: one open edit per package', () => {
|
||||
test('the fake is faithful: two edits of one package without the lock kill the first', async () => {
|
||||
fakePlay();
|
||||
const ids: string[] = [];
|
||||
const call = async (pkg: string) => { const r = await realFetchless(pkg); ids.push(r); };
|
||||
async function realFetchless(pkg: string): Promise<string> {
|
||||
const e = await (await fetch(`https://x/applications/${pkg}/edits`, { method: 'POST' })).json() as { id: string };
|
||||
await wait(5);
|
||||
const r = await fetch(`https://x/applications/${pkg}/edits/${e.id}/details`);
|
||||
return String(r.status);
|
||||
}
|
||||
await Promise.all([call('com.example.a'), call('com.example.a')]);
|
||||
expect(ids.sort()).toEqual(['200', '400']);
|
||||
});
|
||||
|
||||
test('parallel reads of one package all succeed (serialized)', async () => {
|
||||
const peak = fakePlay();
|
||||
const play = [await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo'), await playClient('/nonexistent', 'com.example.demo')];
|
||||
const out = await Promise.all(play.map(p => p.read(async id => {
|
||||
const a = await p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`);
|
||||
const b = await p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/tracks`);
|
||||
return [a.ok, b.ok];
|
||||
})));
|
||||
expect(out).toEqual(Array(4).fill(['/details', '/tracks']));
|
||||
expect(peak.total).toBe(1);
|
||||
});
|
||||
|
||||
test('different packages still read in parallel', async () => {
|
||||
const peak = fakePlay({ delay: 40 });
|
||||
const pkgs = ['com.example.a', 'com.example.b', 'com.example.c'];
|
||||
const started = Date.now();
|
||||
const out = await Promise.all(pkgs.map(async pkg => { const p = await playClient('/nonexistent', pkg); return p.read(id => p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`)); }));
|
||||
expect(out.map(o => o.ok)).toEqual(['/details', '/details', '/details']);
|
||||
expect(peak.total).toBe(3);
|
||||
expect(Date.now() - started).toBeLessThan(40 * 3);
|
||||
});
|
||||
|
||||
test('a read whose edit was deleted is redone once with a new edit', async () => {
|
||||
fakePlay({ replaceOnce: 'com.example.demo' });
|
||||
const p = await playClient('/nonexistent', 'com.example.demo');
|
||||
const seen: string[] = [];
|
||||
const r = await p.read(async id => { seen.push(id); return p.call<{ ok: string }>('GET', `${p.API}/edits/${id}/details`); });
|
||||
expect(r.ok).toBe('/details');
|
||||
expect(seen.length).toBe(2);
|
||||
expect(seen[0]).not.toBe(seen[1]);
|
||||
});
|
||||
|
||||
test('a lock held by another holder times out with who holds it', async () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'sb-lock-'));
|
||||
const prev = process.env.STOREBOUND_LOCK_DIR;
|
||||
process.env.STOREBOUND_LOCK_DIR = dir;
|
||||
try {
|
||||
const beats: string[] = [];
|
||||
let release!: () => void;
|
||||
const first = withMachineLock('play-edit-x', 'play read x', () => new Promise<void>(r => { release = r; }));
|
||||
await wait(10);
|
||||
await expect(withMachineLock('play-edit-x', 'play read x', async () => 1, { timeoutMs: 60, pollMs: 10, heartbeatMs: 20, onWait: m => beats.push(m) })).rejects.toThrow(/timed out .*play read x/);
|
||||
expect(beats.length).toBeGreaterThan(0);
|
||||
release(); await first;
|
||||
expect(await withMachineLock('play-edit-x', 'play read x', async () => 2, { timeoutMs: 100 })).toBe(2);
|
||||
} finally { if (prev === undefined) delete process.env.STOREBOUND_LOCK_DIR; else process.env.STOREBOUND_LOCK_DIR = prev; }
|
||||
});
|
||||
});
|
||||
|
||||
describe('a store that could not be read is unknown, never empty', () => {
|
||||
const r = (store: 'ios' | 'android', version: string, phase: StoreRelease['phase']): StoreRelease => ({ store, version, build: '1', state: 's', phase, fraction: null, track: store === 'android' ? 'production' : null });
|
||||
test("laneGap: unreadable, the store's own other version, missing, none", () => {
|
||||
expect(laneGap('android', [r('ios', '0.8.1', 'live')], '400 This Edit has been deleted.').kind).toBe('unreadable');
|
||||
expect(laneGap('ios', [r('ios', '4.1.2', 'live'), r('android', '4.1.9', 'live')])).toMatchObject({ kind: 'other', now: { version: '4.1.2' } });
|
||||
expect(laneGap('ios', [r('ios', '1.0', 'review')]).kind).toBe('missing');
|
||||
expect(laneGap('ios', [r('android', '1.0', 'live')]).kind).toBe('none');
|
||||
expect(storesDiffer([r('ios', '4.1.2', 'live'), r('android', '4.1.9', 'live')])).toEqual({ ios: '4.1.2', android: '4.1.9' });
|
||||
expect(storesDiffer([r('ios', '4.1', 'live'), r('android', '4.1.0', 'live')])).toBeNull();
|
||||
});
|
||||
test('withLastGood keeps the last good Play releases with their time after a failed refresh', () => {
|
||||
const prev = { release: { source: 'live', current: null, next: null, all: [r('ios', '0.8.1', 'live'), r('android', '0.8.1', 'live')] }, storeErrors: {} } as unknown as AppStatus;
|
||||
const next = { release: { source: 'live', current: null, next: null, all: [r('ios', '0.8.1', 'live')] }, storeErrors: { android: 'GET /edits/x/details -> 400 This Edit has been deleted.' } } as unknown as AppStatus;
|
||||
const m = withLastGood(prev, next, '2026-10-13T10:00:00Z');
|
||||
expect(m.staleStores).toEqual({ android: '2026-10-13T10:00:00Z' });
|
||||
expect(m.release.current).toMatchObject({ version: '0.8.1', android: { phase: 'live' } });
|
||||
expect(withLastGood(undefined, next, 'x')).toBe(next);
|
||||
});
|
||||
});
|
||||
|
|
@ -4,7 +4,9 @@
|
|||
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 { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { clearPlayPublicCache, pageVersion, pageVersionInfo, 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';
|
||||
|
||||
|
|
@ -31,10 +33,10 @@ describe('version matching across the stores', () => {
|
|||
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' });
|
||||
expect(h[0]!.android).toMatchObject({ version: '1.0.0', build: '3', phase: 'review', 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' } });
|
||||
expect(t.next).toMatchObject({ version: '1.0.0', ios: { version: '1.0', phase: 'review' }, android: { version: '1.0.0', phase: 'review' } });
|
||||
// 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']]);
|
||||
|
|
@ -43,15 +45,15 @@ describe('version matching across the stores', () => {
|
|||
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');
|
||||
expect(p.android!.steps.map(x => x.text).join(' ')).toContain('1.0: In review');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Google Play "Live" needs the public store page', () => {
|
||||
test('completed production + page 404 = sent to Google, never live', () => {
|
||||
test('completed production + page 404 = in review, 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)');
|
||||
expect(prod).toMatchObject({ phase: 'review', state: 'completed', publicPage: 'notPublic', fraction: 1 });
|
||||
expect(playReleaseLabel(prod!)).toBe('In review');
|
||||
const lane = laneOf('android', prod!);
|
||||
expect(lane.at).toBe(2); // the review stage, not live / rollout
|
||||
expect(lane.share).toBeNull();
|
||||
|
|
@ -65,22 +67,46 @@ describe('Google Play "Live" needs the public store page', () => {
|
|||
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');
|
||||
const prodRel = (page: Record<string, unknown> | undefined, name = '1.1.0', status = 'completed', extra: Record<string, unknown> = {}) => playReleases({ ...extra, publicPage: page, tracks: [
|
||||
{ track: 'production', releases: [{ name, versionCodes: ['5'], status, userFraction: status === 'completed' ? undefined : 0.2 }] },
|
||||
{ track: 'internal', releases: [{ name: '1.0.0', versionCodes: ['3'], status: 'completed' }] },
|
||||
] })[0]!;
|
||||
test('rule: page version = the release version: Live', () => {
|
||||
const r = prodRel({ state: 'public', versionName: '1.1' });
|
||||
expect(r.phase).toBe('live');
|
||||
expect(playReleaseLabel(r)).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');
|
||||
test('rule: page 404, managed publishing off: In review; on: In review / ready to publish', () => {
|
||||
expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }))).toBe('In review');
|
||||
expect(playReleaseLabel(prodRel({ state: 'notPublic', versionName: null }, '1.1.0', 'completed', { managedPublishing: true }))).toBe('In review / ready to publish (managed publishing)');
|
||||
});
|
||||
test('rule: page live with an older version: In review (live: <older>)', () => {
|
||||
const r = prodRel({ state: 'public', versionName: '1.0.0' });
|
||||
expect(r).toMatchObject({ phase: 'review', liveVersion: '1.0.0' });
|
||||
expect(playReleaseLabel(r)).toBe('In review (live: 1.0.0)');
|
||||
expect(laneText(laneOf('android', r))).toBe('in review (live: 1.0.0)');
|
||||
expect(storeNow([r], 'android').now).toBeNull();
|
||||
});
|
||||
test('rule: rollout, halted, draft', () => {
|
||||
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'inProgress'))).toBe('Rolling out 20%');
|
||||
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'halted'))).toBe('Halted');
|
||||
expect(playReleaseLabel(prodRel({ state: 'public', versionName: '1.0.0' }, '1.1.0', 'draft'))).toBe('Draft');
|
||||
});
|
||||
test('rule: "Varies with device" = live (nothing to compare)', () => {
|
||||
expect(prodRel({ state: 'public', versionName: null, varies: true }).phase).toBe('live');
|
||||
});
|
||||
test('rule: only a network failure is "Couldn\'t check the store page"; with a last known read it keeps that state and its time', () => {
|
||||
const r = prodRel(undefined);
|
||||
expect(r).toMatchObject({ phase: 'released', publicPage: 'unknown' });
|
||||
expect(playReleaseLabel(r)).toBe("Couldn't check the store page");
|
||||
expect(laneOf('android', r).tone).toBe('neutral');
|
||||
const s = prodRel({ state: 'public', versionName: '1.1.0', stale: true, checkedAt: '2026-10-12T09:30:00Z' });
|
||||
expect(s).toMatchObject({ phase: 'live', pageStaleAt: '2026-10-12T09:30:00Z' });
|
||||
expect(playReleaseLabel(s)).toBe("Live (couldn't check the store page now; as of 2026-10-12 09:30 UTC)");
|
||||
});
|
||||
test('no uncertain wording is left', () => {
|
||||
for (const r of [prodRel(undefined), prodRel({ state: 'notPublic' }), prodRel({ state: 'public', versionName: '1.0' })])
|
||||
expect(`${playReleaseLabel(r)} ${laneText(laneOf('android', r))}`).not.toMatch(/not yet published|may still be reviewing|Sent to Google/);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -89,10 +115,19 @@ describe('public store page read', () => {
|
|||
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');
|
||||
expect(urls[0]).toBe('https://play.google.com/store/apps/details?id=com.example.first404&hl=en&gl=US');
|
||||
answer(200, '<script type="application/ld+json">{"softwareVersion":"1.0.0"}</script>');
|
||||
expect(await playPublicPage('com.example.first200', { noCache: true })).toMatchObject({ state: 'public', http: 200, versionName: '1.0.0' });
|
||||
});
|
||||
test('a network failure returns the last known answer with its time', async () => {
|
||||
const pkg = `com.example.lastknown${Date.now()}`;
|
||||
answer(200, readFileSync(join(import.meta.dir, 'fixtures/play-page-version.html'), 'utf8'));
|
||||
const ok = await playPublicPage(pkg, { noCache: true });
|
||||
expect(ok).toMatchObject({ state: 'public', versionName: '0.8.1' });
|
||||
globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch;
|
||||
const r = await playPublicPage(pkg, { noCache: true });
|
||||
expect(r).toMatchObject({ state: 'public', versionName: '0.8.1', stale: true, checkedAt: ok.checkedAt });
|
||||
});
|
||||
test('offline or a server error = unknown; unknown is never cached', async () => {
|
||||
const off = `com.example.offline${Date.now()}`;
|
||||
globalThis.fetch = (async () => { throw new Error('getaddrinfo ENOTFOUND play.google.com'); }) as unknown as typeof fetch;
|
||||
|
|
@ -112,9 +147,12 @@ describe('public store page read', () => {
|
|||
expect((await playPublicPage(pkg)).state).toBe('notPublic');
|
||||
expect(urls).toHaveLength(1);
|
||||
});
|
||||
test('the page version is read only from structured data', () => {
|
||||
test('the page version is read from the recorded live page shape (About this app in the ds blobs)', () => {
|
||||
const html = readFileSync(join(import.meta.dir, 'fixtures/play-page-version.html'), 'utf8');
|
||||
expect(pageVersionInfo(html)).toEqual({ versionName: '0.8.1', varies: false });
|
||||
expect(pageVersionInfo(html.replace('[[["0.8.1"]]', '[[["Varies with device"]]'))).toEqual({ versionName: null, varies: true });
|
||||
expect(pageVersion('{"softwareVersion": "2.3.1"}')).toBe('2.3.1');
|
||||
expect(pageVersion('{"softwareVersion":"Varies with device"}')).toBeNull();
|
||||
expect(pageVersionInfo('{"softwareVersion":"Varies with device"}')).toEqual({ versionName: null, varies: true });
|
||||
expect(pageVersion('<div>Version 1.0</div>')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,7 +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' },
|
||||
publicPage: { state: 'public', versionName: '2.2.1' },
|
||||
};
|
||||
|
||||
describe('portfolio (apps, release trains, catalog parity)', () => {
|
||||
|
|
|
|||
|
|
@ -22,7 +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' },
|
||||
publicPage: { state: 'public', versionName: '2.2.0' },
|
||||
};
|
||||
const all: StoreRelease[] = [...appleReleases(apple), ...playReleases(play)];
|
||||
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ describe('serve --stdio: session', () => {
|
|||
const { call, sent } = await session(argv => argv[0] === 'inspect' ? { data: summary }
|
||||
: argv[0] === 'check' ? { data: { ok: true, problems: [], warnings: ['w'] } }
|
||||
: argv[0] === 'apple' ? { code: 1, out: 'error: ASC_KEY_ID and ASC_ISSUER_ID are not set' }
|
||||
: argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }], publicPage: { state: 'public' } } } : {});
|
||||
: argv[0] === 'play' ? { data: { tracks: [{ track: 'production', releases: [{ name: '1.2.0 (12)', versionCodes: ['12'], status: 'completed' }] }, { track: 'beta', releases: [{ name: '1.3.0', versionCodes: ['13'], status: 'completed' }] }], products: [{ productId: 'coins' }], publicPage: { state: 'public', versionName: '1.2.0' } } } : {});
|
||||
const r = resOf(await call('portfolio.status', { projects: [DEMO, '/nope/game'], live: true })) as { apps: J[] };
|
||||
expect(r.apps[0]).toMatchObject({ project: DEMO, app: { id: DEMO, name: 'Demo', platforms: { ios: { bundleId: 'com.example.demo' }, android: { packageName: 'com.example.demo' } } },
|
||||
check: { ok: true, warnings: ['w'] }, storeErrors: { ios: 'ASC_KEY_ID and ASC_ISSUER_ID are not set' }, ok: false });
|
||||
|
|
|
|||
|
|
@ -541,7 +541,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
|
|||
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)';
|
||||
const SENT = 'In review'; // decided from the store page: 404 = in review (no uncertain wording)
|
||||
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)'));
|
||||
|
|
@ -568,6 +568,7 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
|
|||
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 (/not yet published|may still be reviewing|Sent to Google/.test(rel + pf)) problems.push('uncertain Play wording on screen');
|
||||
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) {
|
||||
|
|
@ -609,6 +610,8 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
|
|||
const notes: string[] = [];
|
||||
const logged: string[] = [];
|
||||
let reads = 0;
|
||||
const playOpen = new Map<string, number>();
|
||||
let playPeak = 0;
|
||||
// fixture art is file: URLs (blocked by the CSP): icons are left out
|
||||
const clean = (v: unknown): unknown => (v === undefined ? null : JSON.parse(JSON.stringify(v), (_k, x) => (typeof x === 'string' && x.startsWith('file:') ? null : x)));
|
||||
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
|
||||
|
|
@ -621,6 +624,15 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
|
|||
const method = String(a.method);
|
||||
if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a));
|
||||
reads++;
|
||||
if (method.startsWith('play.') || ['health', 'reviews.list', 'catalog.diff', 'prices.current', 'validate'].includes(method)) {
|
||||
// Play reads of one app may run at once (the scheduler's 4 per store, starts 250 ms apart per key): count the
|
||||
// overlap and hold each read long enough for it to happen
|
||||
const k = String(a.projectId);
|
||||
const n = (playOpen.get(k) ?? 0) + 1;
|
||||
playOpen.set(k, n); playPeak = Math.max(playPeak, n);
|
||||
await new Promise(r => setTimeout(r, 400));
|
||||
playOpen.set(k, playOpen.get(k)! - 1);
|
||||
}
|
||||
return clean(fx.fixtureRead(method, String(a.projectId), ((a.params as { args?: Record<string, unknown> } | null)?.args) ?? {}));
|
||||
}
|
||||
default: return clean(await fx.mockInvoke(cmd, a));
|
||||
|
|
@ -664,10 +676,24 @@ async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
|
|||
await settle(page);
|
||||
await crashCheck(`${appName} ${id}`);
|
||||
const tx = await text();
|
||||
// a Play read that lost its edit to a parallel read (pitfall), or a store that failed called empty
|
||||
for (const bad of [/Edit has been deleted/i, /is not on Google Play yet/i]) if (bad.test(tx)) problems.push(`${appName} ${id}: says "${(bad.exec(tx) ?? [''])[0]}"`);
|
||||
if (/not read yet|Read the stores to see this/i.test(tx)) problems.push(`${appName} ${id}: says "${(/not read yet|Read the stores to see this/i.exec(tx) ?? [''])[0]}" after the background read`);
|
||||
visited++;
|
||||
}
|
||||
}
|
||||
notes.push(`Play reads of one app at once: up to ${playPeak}`);
|
||||
if (playPeak < 2) problems.push(`no two Play reads of one app ran at once (peak ${playPeak}): the case does not exercise parallel reads`);
|
||||
// the stores' current versions differ (Play 3.1.0, App Store 3.0.4): a neutral header, each store its own state
|
||||
await page.locator('nav.side .nav-app', { hasText: 'Orbit Hop' }).first().click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
await page.locator('[role="tab"]#tab-releases').click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
const rel = await text();
|
||||
for (const w of ['Current', 'Play 3.1.0 · App Store 3.0.4', '3.0.4 · Live (3.1.0 not submitted)']) if (!rel.includes(w)) problems.push(`Orbit Hop Releases: missing "${w}"`);
|
||||
for (const w of ['Not on this store', 'is not on App Store yet', '3.1.0 Released']) if (rel.includes(w)) problems.push(`Orbit Hop Releases: says "${w}"`);
|
||||
notes.push(`Orbit Hop releases: ${rel.slice(0, 200)}`);
|
||||
await crashCheck('Orbit Hop Releases');
|
||||
// Health: the first top issue opens with its full title, the sample stack and Copy for AI
|
||||
await page.locator('nav.side .nav-app', { hasText: 'Prism Puddle' }).first().click({ timeout: WAIT_MS });
|
||||
await settle(page);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue