desktop reads everything by itself: one background read scheduler (4 per store, starts spaced per account, App Store Connect X-Rate-Limit slows it below 20 % and pauses at 100 left, backoff with jitter on 429 / RESOURCE_EXHAUSTED, visible app first, one read per key), per-method TTLs over the saved reads, Settings auto-read (on) and interval (15 min, paused while hidden), Refresh all, a refresh icon and 'read 3 min ago' / 'couldn't refresh' per card, writes invalidate, status line with progress and quota; quota reaches the window through ToolResult.quota (cli/lib/quota.ts); Health top issues open with versions, sample stack (health --issue / --signature, read health_issue), Open in Play Console, Copy for AI and likely-cause hints (cli/lib/crash-hints.ts); tests/scheduler.test.ts, tests/quota-crash-hints.test.ts, boot check auto-read case

This commit is contained in:
CRTkafa 2026-10-13 23:36:30 +08:00
parent 89256f1e80
commit 0fb4ce0001
46 changed files with 1531 additions and 141 deletions

View file

@ -118,7 +118,8 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
case 'app_info': return { version: conf.version, identifier: conf.identifier, platform: 'macos', debug: false, home: HOME };
case 'projects_list': return projects();
case 'accounts_list': return fx.accounts_list;
case 'workspace_get': return fx.workspace_get;
// background store reads are the auto-read case's subject (runAutoReadCase); the saved-state cases check drawing
case 'workspace_get': return withAutoRead(fx.workspace_get, false);
case 'store_apps_cached': return fx.store_apps_cached ?? fx.accounts_list.map(x => ({ account: x.id, entry: null }));
case 'cache_get': {
const st = live && STORE_READS[String(a.method)];
@ -169,6 +170,12 @@ function host(fx: HostFixture, sidecar: Sidecar, unmocked: Set<string>, logged:
};
}
/** The saved workspace with Settings > Reading > auto-read set (older states have no such field: on by default). */
function withAutoRead(w: HostFixture['workspace_get'], on: boolean): HostFixture['workspace_get'] {
const settings = (w.settings ?? {}) as { refresh?: Record<string, unknown> };
return { ...w, settings: { ...settings, refresh: { ...settings.refresh, autoRead: on } } };
}
/** Before any page script, as Tauri: the frozen prototype (tauri.conf.json freezePrototype), then the IPC object. */
const initScript = (freeze: boolean) => `(() => {
${freeze ? 'Object.freeze(Object.prototype);' : ''}
@ -582,6 +589,106 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
/** Longest wait for the background reader to read everything of every app (a loaded machine). */
const READ_ALL_MS = 120_000;
/**
* Auto-read: the dev fixture state (src/dev/fixtures.ts: 2 app folders and the store-only apps of 5 accounts, at least 8
* apps) with an empty cache. Without any click the background reader must read everything: the status line shows
* "Reading n of m" and the App Store quota, then "Everything read"; then no tab of any app says "not read yet". The
* Health tab's first top issue opens and shows its sample stack. Zero uncaught errors.
*/
async function runAutoReadCase(browser: Browser): Promise<CaseResult> {
const name = 'auto-read (fixture state, cold cache)';
const fx = await import('../apps/desktop/src/dev/fixtures');
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 logged: string[] = [];
let reads = 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> => {
switch (cmd) {
case 'plugin:event|listen': return Math.floor(Math.random() * 1e9);
case 'plugin:event|unlisten': return null;
case 'log_webview': logged.push(`${String(a.level)}: ${String(a.message).slice(0, 300)}`); return null;
case 'cache_get': return null;
case 'sidecar_call': {
const method = String(a.method);
if (method === 'system.tools') return clean(await fx.mockInvoke(cmd, a));
reads++;
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));
}
};
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}`); };
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)'));
const wizard = page.locator('.wizard[role="dialog"]');
if (await wizard.count()) { await wizard.press('Escape').catch(() => {}); await gone(page, '.wizard[role="dialog"]'); }
const apps = await page.locator('nav.side .nav-app').count();
if (apps < 8) problems.push(`want at least 8 apps in the sidebar, got ${apps}`);
// no click: the status line shows the reader's progress, then that everything is read
const line = '[data-testid="reader-line"]';
if (!(await page.waitForFunction(s => /Reading \d+ of \d+/.test(document.querySelector(s)?.textContent ?? ''), line, { timeout: WAIT_MS, polling: 20 }).then(() => true, () => false)))
problems.push('status line: no "Reading n of m" while reading');
else notes.push(`status line while reading: ${await text(line)}`);
if (!(await shows(page, line, 'Everything read', READ_ALL_MS))) problems.push(`status line: not "Everything read" after ${READ_ALL_MS / 1000} s: ${await text(line)}`);
const done = await text(line);
notes.push(`status line after: ${done}; ${reads} reads, ${apps} apps`);
if (!/App Store limit 3,412\/3,600 left this hour/.test(done)) problems.push(`status line: no App Store quota (${done})`);
await settle(page);
// every tab of every app: nothing says "not read yet" (the reads were done in the background)
const shown = await page.locator('nav.side .nav-app:visible').count();
for (let i = 0; i < shown; i++) {
await page.locator('nav.side .nav-app:visible').nth(i).click({ timeout: WAIT_MS }).catch(e => problems.push(`app ${i}: ${String(e).split('\n')[0]}`));
await settle(page);
const appName = (await page.locator('nav.side .nav-app:visible').nth(i).innerText().catch(() => `app ${i}`)).split('\n')[0]!.trim();
const tabs = await page.locator('[role="tab"]').evaluateAll(ts => ts.map(x => x.id));
for (const id of tabs) {
await page.locator(`[role="tab"]#${id}`).click({ timeout: WAIT_MS }).catch(e => problems.push(`${appName} ${id}: ${String(e).split('\n')[0]}`));
await settle(page);
await crashCheck(`${appName} ${id}`);
const tx = await text();
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++;
}
}
// 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);
await page.locator('[role="tab"]#tab-health').click({ timeout: WAIT_MS });
await settle(page);
await page.locator('.issue-row').first().click({ timeout: WAIT_MS }).catch(() => problems.push('Health: no issue row to open'));
if (!(await shows(page, '[data-testid="issue-stack"]', 'ComboMeter.update(ComboMeter.kt:88)'))) problems.push('Health: the opened issue shows no sample stack');
const det = await text('[data-testid="issue-detail"]');
for (const w of ['ComboMeter.update called after the level ended', 'Copy for AI', 'Open in Play Console', '224 – 231']) if (!det.includes(w)) problems.push(`Health issue detail: missing "${w}"`);
notes.push(`issue detail: ${det.slice(0, 160)}`);
await crashCheck('Health');
} 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}`);
return { name, ok: problems.length === 0, problems: [...new Set(problems)], visited, notes };
}
/**
* Errors the window must say in words (pitfall 57). `api-disabled`: the last health read of the app is Google's live
* 403 SERVICE_DISABLED (Reporting API off in Cloud project 123456789000): the portfolio shows it once for the project,
@ -724,6 +831,7 @@ export async function bootCheck(): Promise<CaseResult[]> {
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));
for (const v of ['api-disabled', 'scan-findings', 'scan-failed'] as const) if (!ONLY.length || ONLY.includes('errors') || ONLY.includes(`errors-${v}`)) cases.push(() => runErrorCase(browser, sidecar, v));
if (!ONLY.length || ONLY.includes('auto-read')) cases.unshift(() => runAutoReadCase(browser));
const results: CaseResult[] = new Array(cases.length);
let next = 0;
try {