Play 'Live' only when the public store page proves it: play status checks https://play.google.com/store/apps/details?id=<pkg> (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)

This commit is contained in:
CRTkafa 2026-10-13 20:46:48 +08:00
parent aa72204a06
commit be9ff8b788
18 changed files with 436 additions and 30 deletions

View file

@ -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<CaseResult> {
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<string>();
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<string, unknown>): Promise<unknown> => {
if (cmd === 'cache_get' && a.method === 'portfolio.status') {
const off = await base('portfolio_status', { live: false }) as { apps: Record<string, unknown>[]; 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<string, unknown>) => {
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<CaseResult[]> {
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 {