Google API errors as typed findings, never raw JSON: cli/lib/google-errors.ts parses SERVICE_DISABLED (api-disabled: service, Cloud project, console page), PERMISSION_DENIED (no-app-access: which app, Users and permissions), 401 / invalid_grant (auth-revoked) and 429 (quota); health / analytics print one line with the URL, raw text in detail and the log; desktop Health tab: the line, 'Turn it on in Google Cloud' and 'Check again', raw text in a collapsed Details; Portfolio shows a Cloud project blocking several apps once. Findings exit codes are results: the sidecar reports outcome (ok / findings / declined / failed) and the command's own error line; scan / check / validate / health exit 1 with findings show and cache the findings; no bare 'Exit code N' anywhere. tests/google-errors.test.ts, boot check errors cases (api-disabled, scan-findings, scan-failed) (pitfall 57)
This commit is contained in:
parent
f69c10d3a0
commit
89256f1e80
28 changed files with 666 additions and 47 deletions
|
|
@ -23,6 +23,7 @@ import { extname, join, resolve } from 'node:path';
|
|||
import type { Browser, Page } from 'playwright';
|
||||
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';
|
||||
import { EXAMPLE_CLOUD_PROJECT, healthApiDisabled, SCAN_FAILED, SCAN_FAILED_LINE, SCAN_FINDINGS } from '../apps/desktop/src/dev/error-fixtures';
|
||||
|
||||
const ROOT = resolve(import.meta.dir, '..');
|
||||
const APP = join(ROOT, 'apps/desktop');
|
||||
|
|
@ -581,6 +582,113 @@ async function runReleaseStateCase(browser: Browser, sidecar: Sidecar, page404:
|
|||
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,
|
||||
* the Health tab one line with "Turn it on in Google Cloud" and "Check again", and no '{"error"' text anywhere visible.
|
||||
* `scan-findings`: `scan --json` exits 1 with its findings (by design): the Checks tab lists them, no "Exit code".
|
||||
* `scan-failed`: a real failure shows the command's own error line.
|
||||
*/
|
||||
async function runErrorCase(browser: Browser, sidecar: Sidecar, variant: 'api-disabled' | 'scan-findings' | 'scan-failed'): Promise<CaseResult> {
|
||||
const name = `errors (${variant})`;
|
||||
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 base = host(fx, sidecar, unmocked, logged);
|
||||
const opened: string[] = [];
|
||||
const answer = async (cmd: string, a: Record<string, unknown>): Promise<unknown> => {
|
||||
if (variant === 'api-disabled' && cmd === 'cache_get' && a.method === 'health') {
|
||||
const pkg = 'com.example.' + String(a.projectId).replace(/[^a-z0-9]/gi, '').toLowerCase().slice(0, 20);
|
||||
return { value: { method: 'health', ok: true, exitCode: 0, dryRun: false, command: 'storebound health --json', output: '', data: healthApiDisabled(pkg) }, at: new Date(Date.now() - 60_000).toISOString() };
|
||||
}
|
||||
if (cmd === 'open_external') { opened.push(String(a.url)); return null; }
|
||||
if (cmd === 'sidecar_call' && a.method === 'scan' && variant !== 'api-disabled') return variant === 'scan-findings' ? SCAN_FINDINGS : SCAN_FAILED;
|
||||
if (cmd === 'sidecar_call' && a.method === 'health') return { method: 'health', ok: true, exitCode: 0, dryRun: false, command: 'storebound health --json', output: '', data: healthApiDisabled('com.example.app') };
|
||||
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 noRaw = (where: string, s: string) => {
|
||||
if (s.includes('{"error"') || s.includes('"status":') || /SERVICE_DISABLED|PERMISSION_DENIED/.test(s)) problems.push(`${where}: raw API error text is visible`);
|
||||
if (/Exit code \d/.test(s)) problems.push(`${where}: a bare "Exit code N" is visible`);
|
||||
};
|
||||
const LINE = `Crash and ANR data needs the Google Play Developer Reporting API turned on in Google Cloud project ${EXAMPLE_CLOUD_PROJECT}.`;
|
||||
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"]'); }
|
||||
if (variant === 'api-disabled') {
|
||||
await shows(page, '#content', 'needs an API turned on');
|
||||
const pf = await text();
|
||||
const m = /(\d+) Google Cloud projects? needs? an API turned on: affects ([^.]{0,160}?)(?= Project | Crash |$)/.exec(pf);
|
||||
notes.push(`portfolio: ${m ? m[0].slice(0, 200) : 'no project notice'}`);
|
||||
if (!m) problems.push('Portfolio: no "1 Google Cloud project needs an API turned on" notice');
|
||||
else if (m[1] !== '1') problems.push(`Portfolio: ${m[1]} projects for one Cloud project (want 1)`);
|
||||
const count = (pf.match(/needs the Google Play Developer Reporting API turned on/g) ?? []).length;
|
||||
if (count !== 1) problems.push(`Portfolio: the project line shows ${count} times (want once, not per app)`);
|
||||
noRaw('Portfolio', pf);
|
||||
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);
|
||||
if (variant === 'api-disabled') {
|
||||
await page.locator('[role="tab"]#tab-health').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Health tab: ${String(e).split('\n')[0]}`));
|
||||
await shows(page, '#content', 'Turn it on in Google Cloud');
|
||||
await settle(page);
|
||||
const h = await text();
|
||||
if (!h.includes(LINE)) problems.push(`Health: no "${LINE}"`);
|
||||
const turnOn = page.locator('#content button', { hasText: 'Turn it on in Google Cloud' }).first();
|
||||
if (!(await turnOn.count())) problems.push('Health: no "Turn it on in Google Cloud" button');
|
||||
if (!(await page.locator('#content button', { hasText: 'Check again' }).count())) problems.push('Health: no "Check again" button');
|
||||
if (!(await page.locator('#content details.store-error-details:not([open])').count())) problems.push('Health: the raw text is not in a collapsed Details');
|
||||
noRaw('Health', h);
|
||||
await turnOn.click({ timeout: WAIT_MS }).catch(() => {});
|
||||
await settle(page);
|
||||
const want = `https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=${EXAMPLE_CLOUD_PROJECT}`;
|
||||
if (!opened.includes(want)) problems.push(`Health: the button opened ${JSON.stringify(opened)} (want ${want})`);
|
||||
await crashCheck('Health');
|
||||
visited++;
|
||||
} else {
|
||||
await page.locator('[role="tab"]#tab-checks').click({ timeout: WAIT_MS }).catch(e => problems.push(`no Checks tab: ${String(e).split('\n')[0]}`));
|
||||
await shows(page, '#content', variant === 'scan-findings' ? 'Sign-up without in-app account deletion' : SCAN_FAILED_LINE);
|
||||
await settle(page);
|
||||
const c = await text();
|
||||
if (variant === 'scan-findings') {
|
||||
if (!c.includes('5.1.1(v) · Sign-up without in-app account deletion')) problems.push('Checks: the scan findings (exit 1) are not listed');
|
||||
if (!c.includes('lib/auth/sign_up.dart:12')) problems.push('Checks: the finding\'s evidence is not shown');
|
||||
} else if (!c.includes(SCAN_FAILED_LINE)) problems.push(`Checks: the failure does not show the command's error line "${SCAN_FAILED_LINE}"`);
|
||||
noRaw('Checks', c);
|
||||
await crashCheck('Checks');
|
||||
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
|
||||
|
|
@ -615,6 +723,7 @@ export async function bootCheck(): Promise<CaseResult[]> {
|
|||
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));
|
||||
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));
|
||||
const results: CaseResult[] = new Array(cases.length);
|
||||
let next = 0;
|
||||
try {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue