diff --git a/CHANGELOG.md b/CHANGELOG.md index c2079f3..327617c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- **Google API errors in words, not JSON.** Google's error bodies are parsed centrally (`cli/lib/google-errors.ts`) + into typed findings: `api-disabled` (403 SERVICE_DISABLED: service, Cloud project id, the console page to turn it + on), `no-app-access` (403 PERMISSION_DENIED: which app, the Users and permissions step), `auth-revoked` (401, + invalid_grant) and `quota` (429, RESOURCE_EXHAUSTED). `health`, `analytics` and the health JSON say one line with the + URL ("Crash and ANR data needs the Google Play Developer Reporting API turned on in Google Cloud project . Turn + it on: ..."); the raw text stays in `detail` and the log. The desktop Health tab shows that line with "Turn it on in + Google Cloud" and "Check again", the raw text only in a collapsed Details; the Portfolio shows a Cloud project that + blocks several apps once ("1 Google Cloud project needs an API turned on: affects ..."). Pitfall 57. +- **Findings are results, not errors, in the desktop.** `scan`, `check`, `validate` and `health` exit 1 when they find + problems (by design); the sidecar now reports an `outcome` (ok / findings / declined / failed) and, for a real + failure, the command's own `error` line (stderr's last "error: ..." line). The Checks tab lists the findings instead + of "Exit code 1", findings results are cached, and every place that showed a bare "Exit code N" shows the error line. - **Play "Live" only when the store page proves it.** The Play Developer API has no review state: a production release reads `completed` while Google still reviews it. `play status` now checks the public store page (no auth, fast timeout, cached 5 minutes) and adds `publicPage` to its JSON: page 404 = "Sent to Google (in review or not yet diff --git a/apps/desktop/src-tauri/src/commands.rs b/apps/desktop/src-tauri/src/commands.rs index 5310907..71c31bb 100644 --- a/apps/desktop/src-tauri/src/commands.rs +++ b/apps/desktop/src-tauri/src/commands.rs @@ -205,7 +205,7 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_ o.insert("credentials".into(), Value::Object(creds)); } let res = r.call_job(&method, p, job.as_deref()).await?; - if res["ok"] == Value::Bool(true) && !res["data"].is_null() { + if readable(&res) { let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso()); } return Ok(res); @@ -223,13 +223,19 @@ pub async fn sidecar_call(window: WebviewWindow, app: AppHandle, state: State<'_ let res = r.call_job(&method, p, job.as_deref()).await?; if let Some(id) = cache_project { // only results that read something: a failed store read must not hide the last good one - if res["ok"] == Value::Bool(true) && !res["data"].is_null() { + if readable(&res) { let _ = state.cache.put(&id, &method, &args, &res, &store::now_iso()); } } Ok(res) } +/// A result worth caching: it carries data and is ok, or "findings" (scan / check / validate / health exit 1 with their +/// result printed: by design, not a failure). A failed read must not hide the last good one. +fn readable(res: &Value) -> bool { + !res["data"].is_null() && (res["ok"] == Value::Bool(true) || res["outcome"] == Value::String("findings".into())) +} + /// The last cached result of a read (`project_id` "*" for the portfolio), with its time; null when never read. #[tauri::command] pub fn cache_get(window: WebviewWindow, state: State<'_, AppState>, method: String, project_id: String, args: Option) -> Res> { diff --git a/apps/desktop/src/dev/error-fixtures.ts b/apps/desktop/src/dev/error-fixtures.ts new file mode 100644 index 0000000..377519d --- /dev/null +++ b/apps/desktop/src/dev/error-fixtures.ts @@ -0,0 +1,64 @@ +// Live-shape failures for the boot check and tests: Google's 403 SERVICE_DISABLED body exactly as the Play Developer +// Reporting API answers it (the project id is an example), the health snapshot `storebound health --json` writes for +// it, and `storebound scan --json` results: findings with exit 1 (by design) and a real failure with its error line. +import { errorBody } from '../../../../cli/lib/http'; +import { storeReadError } from '../../../../cli/lib/google-errors'; +import { errorLine } from '../../../../cli/lib/desktop-protocol'; + +export const EXAMPLE_CLOUD_PROJECT = '123456789000'; +export const SERVICE_DISABLED_403 = `{ + "error": { + "code": 403, + "message": "Google Play Developer Reporting API has not been used in project ${EXAMPLE_CLOUD_PROJECT} before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=${EXAMPLE_CLOUD_PROJECT} then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry.", + "status": "PERMISSION_DENIED", + "details": [ + { + "@type": "type.googleapis.com/google.rpc.ErrorInfo", + "reason": "SERVICE_DISABLED", + "domain": "googleapis.com", + "metadata": { + "activationUrl": "https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=${EXAMPLE_CLOUD_PROJECT}", + "service": "playdeveloperreporting.googleapis.com", + "consumer": "projects/${EXAMPLE_CLOUD_PROJECT}", + "serviceTitle": "Google Play Developer Reporting API", + "containerInfo": "${EXAMPLE_CLOUD_PROJECT}" + } + }, + { + "@type": "type.googleapis.com/google.rpc.LocalizedMessage", + "locale": "en-US", + "message": "Google Play Developer Reporting API has not been used in project ${EXAMPLE_CLOUD_PROJECT} before or it is disabled. Enable it by visiting https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=${EXAMPLE_CLOUD_PROJECT} then retry. If you enabled this API recently, wait a few minutes for the action to propagate to our systems and retry." + }, + { + "@type": "type.googleapis.com/google.rpc.Help", + "links": [{ "description": "Google developers console API activation", "url": "https://console.developers.google.com/apis/api/playdeveloperreporting.googleapis.com/overview?project=${EXAMPLE_CLOUD_PROJECT}" }] + } + ] + } +}`; + +/** The Error message storebound's Google client throws for it (play/google.ts). */ +export const serviceDisabledMessage = (pkg: string): string => `GET /v1beta1/apps/${pkg}/crashRateMetricSet -> 403 ${errorBody(SERVICE_DISABLED_403, 800)}`; + +/** `storebound health --json` when the Reporting API is off. */ +export const healthApiDisabled = (pkg: string) => ({ date: '2026-10-13', play: storeReadError(serviceDisabledMessage(pkg), { app: pkg }) }); + +/** `storebound scan --json` that found problems: exit 1 by design, the result still printed. */ +export const SCAN_FINDINGS = { + method: 'scan', ok: false, exitCode: 1, dryRun: false, command: 'storebound scan --json', outcome: 'findings' as const, + output: '12:00:00 [scan] 41 files\n12:00:00 [scan] 1 blocker, 0 warnings', + data: { + stacks: ['flutter'], sdks: [], counts: { blocker: 1, high: 0, medium: 0, low: 0, accepted: 0 }, + declarations: { changed: false, items: [] }, + findings: [{ rule: 'account-deletion', store: 'apple', severity: 'blocker', guideline: '5.1.1(v)', title: 'Sign-up without in-app account deletion', + evidence: [{ file: 'lib/auth/sign_up.dart', line: 12, text: 'createUserWithEmailAndPassword' }], fix: 'Add a "Delete account" action in the app settings', skill: 'account-deletion' }], + }, +}; + +/** A real failure: the config does not load; the sidecar sends the command's own error line. */ +const SCAN_STDERR = '\nerror: storebound.config.ts: app.name is missing'; +export const SCAN_FAILED = { + method: 'scan', ok: false, exitCode: 1, dryRun: false, command: 'storebound scan --json', outcome: 'failed' as const, + output: `12:00:00 [scan] loading the config${SCAN_STDERR}`, error: errorLine(SCAN_STDERR, ''), +}; +export const SCAN_FAILED_LINE = 'storebound.config.ts: app.name is missing'; diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 15451be..b400165 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -66,7 +66,25 @@ export const en = { 'common.unknown': 'Unknown', 'common.cancel': 'Cancel', 'common.close': 'Close', - 'common.exit': 'Exit code {code}', + 'common.exitNoMessage': 'The command stopped without an error message (exit {code}); the log has the details', + 'gerr.apiDisabled': '{what} needs the {service} turned on in Google Cloud project {project}.', + 'gerr.what.reporting': 'Crash and ANR data', + 'gerr.what.publisher': 'Reading the app on Google Play', + 'gerr.what.storage': 'Play statistics', + 'gerr.what.other': 'This read', + 'gerr.projectUnknown': '(the service account\'s project)', + 'gerr.turnOn': 'Turn it on in Google Cloud', + 'gerr.checkAgain': 'Check again', + 'gerr.noAccess': 'The service account has no access to {app} in Play Console: add it under Users and permissions.', + 'gerr.thisApp': 'this app', + 'gerr.openUsers': 'Open Users and permissions', + 'gerr.revoked': 'Google refused the service account key: it was revoked, deleted or has expired. Add a new JSON key in Accounts.', + 'gerr.quota': 'The Google API quota is used up for now. Try again later.', + 'gerr.details': 'Details', + 'gerr.openFailed': 'Could not open the page', + 'gerr.portfolio': '{count} Google Cloud projects need an API turned on: affects {apps}', + 'gerr.portfolio_one': '1 Google Cloud project needs an API turned on: affects {apps}', + 'gerr.portfolioProject': 'Project {project}: {service}', 'common.cachedFrom': 'Last read {when} (saved copy)', 'common.readWhen': 'Read {when}', 'common.readFailedKeep': 'The last read failed, so this shows the previous one: {error}', diff --git a/apps/desktop/src/i18n/tr.ts b/apps/desktop/src/i18n/tr.ts index f02df46..617b88a 100644 --- a/apps/desktop/src/i18n/tr.ts +++ b/apps/desktop/src/i18n/tr.ts @@ -68,7 +68,25 @@ export const tr: Record = { 'common.unknown': 'Bilinmiyor', 'common.cancel': 'İptal', 'common.close': 'Kapat', - 'common.exit': 'Çıkış kodu {code}', + 'common.exitNoMessage': 'Komut bir hata mesajı vermeden durdu (çıkış {code}); ayrıntılar günlükte', + 'gerr.apiDisabled': '{what} için Google Cloud projesi {project} içinde {service} açılmalı.', + 'gerr.what.reporting': 'Çökme ve ANR verileri', + 'gerr.what.publisher': 'Uygulamayı Google Play\'den okumak', + 'gerr.what.storage': 'Play istatistikleri', + 'gerr.what.other': 'Bu okuma', + 'gerr.projectUnknown': '(hizmet hesabının projesi)', + 'gerr.turnOn': 'Google Cloud\'da aç', + 'gerr.checkAgain': 'Tekrar kontrol et', + 'gerr.noAccess': 'Hizmet hesabının Play Console\'da {app} için erişimi yok: Kullanıcılar ve izinler bölümünden ekleyin.', + 'gerr.thisApp': 'bu uygulama', + 'gerr.openUsers': 'Kullanıcılar ve izinler\'i aç', + 'gerr.revoked': 'Google hizmet hesabı anahtarını reddetti: iptal edilmiş, silinmiş ya da süresi dolmuş. Hesaplar\'da yeni bir JSON anahtarı ekleyin.', + 'gerr.quota': 'Google API kotası şimdilik doldu. Daha sonra tekrar deneyin.', + 'gerr.details': 'Ayrıntılar', + 'gerr.openFailed': 'Sayfa açılamadı', + 'gerr.portfolio': '{count} Google Cloud projesinde bir API açılmalı: etkilenenler {apps}', + 'gerr.portfolio_one': '1 Google Cloud projesinde bir API açılmalı: etkilenenler {apps}', + 'gerr.portfolioProject': 'Proje {project}: {service}', 'common.cachedFrom': 'Son okuma {when} (kayıtlı kopya)', 'common.readWhen': '{when} okundu', 'common.readFailedKeep': 'Son okuma başarısız oldu, bu yüzden bir öncekini gösteriyor: {error}', diff --git a/apps/desktop/src/lib/model.ts b/apps/desktop/src/lib/model.ts index ef209a5..7ee1677 100644 --- a/apps/desktop/src/lib/model.ts +++ b/apps/desktop/src/lib/model.ts @@ -1,6 +1,7 @@ // Pure view models for the screens: release timeline stages, health series against the store thresholds, review // inbox stats, catalog parity matrix, the portfolio's "needs attention" list, and the command palette matcher. // "Unknown, never false": every derivation keeps "not read" apart from "none" / "ok". +import { plainError } from '../../../../cli/lib/google-errors'; import type { AppStatus, ProjectSummary } from '../api'; import { t } from '../i18n'; @@ -23,7 +24,8 @@ export interface AppleHealth { signatures: { build: string; type: string; signature: string; weight: number }[]; betaCrashes: { build: string; count: number }[]; } -export interface HealthSnapshot { date: string; play?: PlayHealth | { error: string }; apple?: AppleHealth | { error: string } } +export type StoreReadError = import('../../../../cli/lib/health').StoreReadError; +export interface HealthSnapshot { date: string; play?: PlayHealth | StoreReadError; apple?: AppleHealth | StoreReadError } export interface Review { store: 'apple' | 'play'; id: string; rating: number; title?: string; body: string; where?: string; date: string; appVersion?: string; reply?: { text: string; date?: string; state?: string } } export interface TfGroup { id: string; name: string; internal: boolean; testers: number; builds: number | 'all'; publicLink: boolean } export interface TfBuild { version: string | null; build: string | null; processing: string | null; internal: string | null; external: string | null; uploaded: string | null } @@ -47,7 +49,7 @@ export interface ScanResult { declarations: { changed: boolean; items: { area: string; what: string; value: string; status: 'detected' | 'ask'; evidence: { file: string; line: number }[] }[] }; } -export const isError = (x: unknown): x is { error: string } => !!x && typeof x === 'object' && typeof (x as { error?: unknown }).error === 'string'; +export const isError = (x: unknown): x is StoreReadError => !!x && typeof x === 'object' && typeof (x as { error?: unknown }).error === 'string'; // ---- Release timeline ---- // The stages and lanes live in cli/lib/train.ts, shared with `storebound release status | plan`. @@ -139,8 +141,8 @@ export function attention(projectId: string, name: string, e: AppStatus | undefi for (const w of (e.check?.warnings ?? []).slice(0, 3)) out.push({ ...base, tone: 'warn', text: w, tab: 'checks' }); const l = live ?? (e.release.source === 'live' ? e : undefined); if (l) { - if (l.storeErrors.ios) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'App Store', error: l.storeErrors.ios }), tab: 'releases' }); - if (l.storeErrors.android) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'Google Play', error: l.storeErrors.android }), tab: 'releases' }); + if (l.storeErrors.ios) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'App Store', error: plainError(l.storeErrors.ios) }), tab: 'releases' }); + if (l.storeErrors.android) out.push({ ...base, tone: 'warn', text: t('att.storeError', { store: 'Google Play', error: plainError(l.storeErrors.android) }), tab: 'releases' }); for (const r of l.release.all ?? []) { if (r.phase === 'rejected') out.push({ ...base, tone: 'bad', text: t('att.rejected', { store: r.store === 'ios' ? 'App Store' : 'Google Play', version: r.version ?? '' }), tab: 'releases' }); if (r.phase === 'halted') out.push({ ...base, tone: 'bad', text: t('att.halted', { store: r.store === 'ios' ? 'App Store' : 'Google Play', version: r.version ?? '' }), tab: 'releases' }); diff --git a/apps/desktop/src/lib/results.ts b/apps/desktop/src/lib/results.ts new file mode 100644 index 0000000..d40bd90 --- /dev/null +++ b/apps/desktop/src/lib/results.ts @@ -0,0 +1,20 @@ +// What a command's result means for the window: exit 1 of scan / check / validate / health with the --json result +// printed is "found problems" (shown as the findings), 3 is "declined"; only a real failure is an error, and then in +// the command's own words (its error line, Google errors as one plain line), never a bare "Exit code N". +import { errorLine, type Outcome, outcomeOf, type ToolResult } from '../../../../cli/lib/desktop-protocol'; +import { plainError } from '../../../../cli/lib/google-errors'; +import { t } from '../i18n'; + +export function outcome(method: string, r: Pick): Outcome { + return r.outcome ?? outcomeOf(method, r.exitCode, r.data !== undefined); +} + +/** The result carries data the screen can show (ok, or findings). */ +export const usable = (method: string, r: Pick): boolean => + r.data !== undefined && ['ok', 'findings'].includes(outcome(method, r)); + +/** A failed run in words: the command's error line (Google JSON bodies as one plain line). */ +export function failureText(r: Pick): string { + const line = r.error || errorLine('', r.output ?? ''); + return line ? plainError(line) : t('common.exitNoMessage', { code: r.exitCode }); +} diff --git a/apps/desktop/src/screens/AppFolders.tsx b/apps/desktop/src/screens/AppFolders.tsx index 5e9a832..1162a6f 100644 --- a/apps/desktop/src/screens/AppFolders.tsx +++ b/apps/desktop/src/screens/AppFolders.tsx @@ -1,5 +1,6 @@ // Workspace: app folders (one storebound.config.ts per app), which store accounts each app uses, and starting a new // folder from the templates (`storebound init` with a profile, confirmed in a native dialog). +import { failureText } from '../lib/results'; import { useEffect, useRef, useState } from 'react'; import { api, errorText, newJob, onProgress, type Project, type ToolResult, type WriteOutcome } from '../api'; import { devSwitch } from '../dev/switches'; @@ -69,7 +70,7 @@ export function AppFolders() { const apple = d.accounts.filter(a => a.kind === 'apple'); const status = !run ? '' : run.error ? t('run.failed') : !run.result ? t('run.running') : run.outcome ? (run.outcome.confirmed ? t('run.applied') : run.outcome.stage === 'dialog' ? t('run.cancelled') : t('run.dryFailed')) - : run.result.ok ? t('run.done') : t('common.exit', { code: run.result.exitCode }); + : run.result.ok ? t('run.done') : failureText(run.result); return (
diff --git a/apps/desktop/src/screens/Portfolio.tsx b/apps/desktop/src/screens/Portfolio.tsx index 3078349..1048b5c 100644 --- a/apps/desktop/src/screens/Portfolio.tsx +++ b/apps/desktop/src/screens/Portfolio.tsx @@ -1,8 +1,10 @@ // Portfolio: every app of every studio on both stores. KPIs, a toolbar (search, studio chips with counts, sort, // grouping), one row per app (live version and state per store with its place on the release track, next version, // crash rate, catalog parity, config check), grouped by studio; then what needs attention and the workspace state. +import { plainError } from '../../../../cli/lib/google-errors'; +import { BlockedProjectsNotice, blockedProjects } from '../ui/StoreError'; import { useEffect, useMemo, useState } from 'react'; -import { api, type AppStatus, type StoreRelease, type ToolResult } from '../api'; +import { api, type AppStatus, newJob, type StoreRelease, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; import { ago, pct, share } from '../lib/format'; import { filterSort, groupByStudio, type ListRow, type SortBy, studioCounts, type UnifiedApp } from '../lib/identity'; @@ -37,7 +39,7 @@ function StoreCell({ e, store, on }: { e: AppStatus | undefined; store: 'ios' | if (!on) return {t('app.notOnStore')}; if (!e) return {t('common.unknown')}; const err = e.storeErrors[store]; - if (err) return {t('portfolio.unreadable')}; + if (err) return {t('portfolio.unreadable')}; const all = e.release.all ?? [e.release.current?.ios, e.release.current?.android, e.release.next?.ios, e.release.next?.android].filter((x): x is StoreRelease => !!x); const { now, next } = storeNow(all, store); const r = now ?? next; @@ -67,9 +69,9 @@ function NextCell({ e }: { e: AppStatus | undefined }) { } /** Last cached health read of every app (the Health tab reads it; the portfolio only shows what is saved). */ -function useCachedHealth(targets: string[]): Record { +function useCachedHealth(targets: string[], round = 0): Record { const [m, set] = useState>({}); - const key = targets.join(); + const key = targets.join() + '#' + round; useEffect(() => { for (const id of targets) { api.cached('health', id).then(c => set(x => ({ ...x, [id]: c?.value.data ? { h: c.value.data as HealthSnapshot, at: c.at } : null })), () => {}); @@ -131,7 +133,21 @@ export function Portfolio() { const [query, setQuery] = useState(''); const setView = (v: Partial) => setViewState(x => { const n = { ...x, ...v }; try { localStorage.setItem(VIEW_KEY, JSON.stringify(n)); } catch { /* private mode */ } return n; }); const visible = useMemo(() => d.apps.filter(a => !a.ignored), [d.apps]); - const health = useCachedHealth(visible.map(a => a.target)); + const [round, setRound] = useState(0); + const [rechecking, setRechecking] = useState(false); + const health = useCachedHealth(visible.map(a => a.target), round); + const blocked = useMemo(() => blockedProjects(visible.map(a => { + const h = health[a.target]?.h, l = d.liveFor(a); + return { app: a.name, errors: [h?.play && isError(h.play) ? h.play : null, l?.storeErrors.android] }; + })), [visible, health, d]); + // "Check again": read health of the blocked apps again (one at a time: the same Cloud project's quota), then the cache + const recheck = async () => { + setRechecking(true); + const names = new Set(blocked.flatMap(b => b.apps)); + for (const a of visible.filter(x => names.has(x.name))) await api.call('health', a.target, { args: {} }, newJob('health')).catch(() => null); + setRechecking(false); + setRound(n => n + 1); + }; const open = (a: UnifiedApp) => nav.go({ screen: 'app', appId: a.key, tab: 'releases' }); const all = useMemo(() => visible.map(app => { @@ -222,6 +238,7 @@ export function Portfolio() {
{d.portfolioError && {d.portfolioError}} + {neverImported > 0 && d.importApps()} disabled={d.importBusy}>{t('action.importApps')}}>{t('portfolio.notImported', { count: neverImported })}} {!known && d.ready && d.refreshPortfolio(true)}>{t('action.readStores')}}>{t('portfolio.notReadNotice')}} diff --git a/apps/desktop/src/screens/tabs/Health.tsx b/apps/desktop/src/screens/tabs/Health.tsx index 86f180f..d89aa2b 100644 --- a/apps/desktop/src/screens/tabs/Health.tsx +++ b/apps/desktop/src/screens/tabs/Health.tsx @@ -9,6 +9,7 @@ import { Icon } from '../../ui/icons'; import { Bar, Card, Chip, Empty, ErrorFoot, Notice, Skeleton, Sparkline } from '../../ui/kit'; import { exportName, healthMd } from '../../lib/exports'; import { useExports } from '../../ui/Exports'; +import { StoreReadNotice } from '../../ui/StoreError'; function Metric({ title, s }: { title: string; s: Series }) { return ( @@ -146,9 +147,9 @@ export function HealthTab({ app }: { app: UnifiedApp }) { {r.error && {t('common.readFailedKeep', { error: r.error })}}

Google Play

- {h.play === undefined ? {t('health.noPlay')} : isError(h.play) ? {t('health.unreadable', { store: 'Google Play', error: h.play.error })} : } + {h.play === undefined ? {t('health.noPlay')} : isError(h.play) ? : }

App Store

- {h.apple === undefined ? {t('health.noApple')} : isError(h.apple) ? {t('health.unreadable', { store: 'App Store', error: h.apple.error })} : } + {h.apple === undefined ? {t('health.noApple')} : isError(h.apple) ? : } ); } diff --git a/apps/desktop/src/screens/tabs/Releases.tsx b/apps/desktop/src/screens/tabs/Releases.tsx index caab9e7..0fcc87a 100644 --- a/apps/desktop/src/screens/tabs/Releases.tsx +++ b/apps/desktop/src/screens/tabs/Releases.tsx @@ -2,6 +2,7 @@ // rollout %), then every version read from the stores, then sending for review (Submit.tsx): App Store submit / // resubmit / in-app purchases and the Google Play production release as owner actions, the Play steps without an API // 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 { UnifiedApp } from '../../lib/identity'; @@ -155,7 +156,7 @@ export function ReleasesTab({ app }: { app: UnifiedApp }) { const errs = Object.entries(l.storeErrors); return ( <> - {errs.map(([s, msg]) => {t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: msg })})} + {errs.map(([s, msg]) => {t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })})} {l.release.next && } {l.release.current && } {!l.release.next && !l.release.current && {t('releases.noVersionsText')}} diff --git a/apps/desktop/src/store.tsx b/apps/desktop/src/store.tsx index c9b32fb..c1935a4 100644 --- a/apps/desktop/src/store.tsx +++ b/apps/desktop/src/store.tsx @@ -14,6 +14,7 @@ import { setCrashContext } from './lib/crash'; import { normalizeAccounts, normalizeProjects, normalizeWorkspace } from './lib/normalize'; import { keepLines, progressOf } from './lib/progress'; import { fixHint } from './lib/views'; +import { failureText, usable } from './lib/results'; export type ToastKind = 'job' | 'ok' | 'bad' | 'info'; export interface Toast { @@ -336,13 +337,13 @@ export function useWrite(projectId: string | null) { const tid = d.toast({ id: job, kind: 'job', title: w.title, detail: t('write.dryRunning'), progress: null, job }); try { const o = await api.writeWithConfirm(w.method, projectId, w.args, job); - if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(lastLine(o.dryRun.output) || undefined), progress: undefined, job: undefined }); + if (o.stage === 'dry-run') d.updateToast(tid, { kind: 'bad', title: t('write.dryFailed'), detail: withHint(failureText(o.dryRun)), progress: undefined, job: undefined }); else if (o.stage === 'dialog') d.updateToast(tid, { kind: 'info', title: t('write.notApplied'), detail: t('write.notAppliedDetail'), progress: undefined, job: undefined }); else if (o.result?.ok) { d.updateToast(tid, { kind: 'ok', title: w.done, detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); } // exit 3: the command stopped for an owner decision it did not get (a price change of a live product: the // Catalog tab's owner action applies it); everything else it could do was done else if (o.result?.exitCode === 3) { d.updateToast(tid, { kind: 'info', title: t('write.declined'), detail: lastLine(o.result.output) || undefined, progress: undefined, job: undefined }); w.after?.(); } - else d.updateToast(tid, { kind: 'bad', title: t('write.failed'), detail: withHint(lastLine(o.result?.output ?? '') || undefined), progress: undefined, job: undefined }); + else d.updateToast(tid, { kind: 'bad', title: t('write.failed'), detail: withHint(o.result ? failureText(o.result) : undefined), progress: undefined, job: undefined }); return o; } catch (e) { const msg = errorText(e); @@ -387,12 +388,12 @@ export function useRead(target: string, method: string, o: { network: boolean try { const r = await api.call(method, target, { args: JSON.parse(argsKey) as Record }, job); if (my !== seq.current) return; - if (r.ok && r.data !== undefined) { + if (usable(method, r)) { notifyAfterRead(method, dataRef.current, r.data, d.settings, o.appName); set({ data: r.data as T, at: new Date().toISOString(), fromCache: false, loading: false, error: null, command: r.command }); if (tid) d.updateToast(tid, { kind: 'ok', title: t('toast.readOk', { what: o.title ?? method }), detail: undefined, progress: undefined, job: undefined }); } else { - const msg = lastLine(r.output) || t('common.exit', { code: r.exitCode }); + const msg = failureText(r); set(x => ({ ...x, loading: false, error: msg, command: r.command })); if (tid) d.updateToast(tid, { kind: 'bad', title: t('toast.readFailedWhat', { what: o.title ?? method }), detail: withHint(msg), progress: undefined, job: undefined }); } diff --git a/apps/desktop/src/ui/ConsoleFill.tsx b/apps/desktop/src/ui/ConsoleFill.tsx index 9a7a1f3..b7b59ce 100644 --- a/apps/desktop/src/ui/ConsoleFill.tsx +++ b/apps/desktop/src/ui/ConsoleFill.tsx @@ -4,6 +4,7 @@ // write_with_confirm (the host shows the answers again in a native dialog and mints the token only on its button) -> // a visible browser the owner signs in to; storebound fills the fields, moves through the pages and never submits or publishes. The run // lasts until the owner closes that browser; Cancel here stops the command, which closes it. +import { failureText } from '../lib/results'; import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { api, type ConsoleFillResult, type ConsoleForm, errorText, newJob, onProgress, type Project, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; @@ -34,7 +35,7 @@ export function ConsoleFillButton({ project, form, small = true, primary }: { pr setSt({ s: 'dry' }); try { const r = await api.call('console.fill', project.id, { args: { form } }, newJob('console.fill-dry')); - if (!r.ok || !r.data) { setSt({ s: 'idle' }); d.toast({ kind: 'bad', title: t('cf.dryFailed', { form: name }), detail: tail(r.output) || undefined }); return; } + if (!r.ok || !r.data) { setSt({ s: 'idle' }); d.toast({ kind: 'bad', title: t('cf.dryFailed', { form: name }), detail: failureText(r) }); return; } setSt({ s: 'review', data: r.data }); } catch (e) { setSt({ s: 'idle' }); @@ -55,7 +56,7 @@ export function ConsoleFillButton({ project, form, small = true, primary }: { pr else if (o.stage === 'dry-run') setSt({ s: 'done', data, result: null, ok: false, detail: tail(o.dryRun.output) }); else { const res = (o.result?.data ?? null) as ConsoleFillResult | null; - setSt({ s: 'done', data, result: res, ok: !!o.result?.ok, detail: o.result?.ok ? undefined : tail(o.result?.output ?? '') }); + setSt({ s: 'done', data, result: res, ok: !!o.result?.ok, detail: o.result?.ok ? undefined : o.result ? failureText(o.result) : undefined }); } } catch (e) { const msg = errorText(e); diff --git a/apps/desktop/src/ui/ConsolePlan.tsx b/apps/desktop/src/ui/ConsolePlan.tsx index 3de0bb8..d4fb356 100644 --- a/apps/desktop/src/ui/ConsolePlan.tsx +++ b/apps/desktop/src/ui/ConsolePlan.tsx @@ -5,6 +5,7 @@ // record and the age rating). Nothing here clicks in a console: the owner's AI agent (or the owner) drives the page // with this plan, `Copy plan` hands it over as JSON; "Fill in console" (ui/ConsoleFill.tsx) is the scripted fast path // where it exists. Every form of `console plan`, including the Play pages added for the first production release. +import { failureText } from '../lib/results'; import { type KeyboardEvent, useEffect, useRef, useState } from 'react'; import { api, errorText, newJob, type Project, type ToolResult } from '../api'; import { type Key, t } from '../i18n'; @@ -61,7 +62,7 @@ export function ConsolePlanButton({ project, form, primary, label }: { project: setBusy(true); try { const r = await api.call('console.plan', project.id, { args: { form } }, newJob('console.plan')); - if (!r.ok || !r.data) d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: r.output.trim().split('\n').at(-1) }); + if (!r.ok || !r.data) d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: failureText(r) }); else setPlan(r.data); } catch (e) { d.toast({ kind: 'bad', title: t('cp.failed', { form: nameOf(form) }), detail: errorText(e) }); } finally { setBusy(false); } }; @@ -88,7 +89,7 @@ function PlanPanel({ project, plan, onClose }: { project: Project; plan: Plan; o try { const r = await api.call('console.verify', project.id, { args: { form: plan.form, ...(text.trim() ? { text: text.trim() } : {}) } }, newJob('console.verify')); if (r.data) setVerify({ busy: false, result: r.data }); - else setVerify({ busy: false, error: r.output.trim().split('\n').at(-1)?.replace(/^\S+ \[[^\]]+\] /, '') ?? t('common.exit', { code: r.exitCode }) }); + else setVerify({ busy: false, error: failureText(r) }); } catch (e) { setVerify({ busy: false, error: errorText(e) }); } }; const copy = () => { navigator.clipboard?.writeText(JSON.stringify(plan, null, 2)).then(() => d.toast({ kind: 'ok', title: t('cp.copied') }), () => {}); }; diff --git a/apps/desktop/src/ui/OwnerAction.tsx b/apps/desktop/src/ui/OwnerAction.tsx index d8bf460..6e64690 100644 --- a/apps/desktop/src/ui/OwnerAction.tsx +++ b/apps/desktop/src/ui/OwnerAction.tsx @@ -4,6 +4,7 @@ // the dialog's own button. While the real run goes (a submit retries up to 10 minutes while Apple says "not ready // yet"), its progress lines stream into the panel; Cancel stops the command. Used for submit for review, resubmit, // in-app purchases to review, the Play production release and price changes of existing products. +import { failureText } from '../lib/results'; import { type KeyboardEvent, type ReactNode, useEffect, useRef, useState } from 'react'; import { api, errorText, newJob, onProgress, type Project, type ToolResult } from '../api'; import { t } from '../i18n'; @@ -72,7 +73,7 @@ export function OwnerAction(p: OwnerActionProps) { const r = o.result!; const declined = r.exitCode === DECLINED_EXIT; setSt({ s: 'done', dry, ok: r.ok, declined, lines: lines.slice(-200), detail: tail(r.output) }); - d.toast({ kind: r.ok ? 'ok' : declined ? 'info' : 'bad', title: r.ok ? p.done : declined ? t('write.declined') : t('write.failed'), detail: tail(r.output) || undefined }); + d.toast({ kind: r.ok ? 'ok' : declined ? 'info' : 'bad', title: r.ok ? p.done : declined ? t('write.declined') : t('write.failed'), detail: (r.ok || declined ? tail(r.output) : failureText(r)) || undefined }); if (r.ok) p.after?.(); } } catch (e) { diff --git a/apps/desktop/src/ui/StoreError.tsx b/apps/desktop/src/ui/StoreError.tsx new file mode 100644 index 0000000..6d21915 --- /dev/null +++ b/apps/desktop/src/ui/StoreError.tsx @@ -0,0 +1,102 @@ +// A store that could not be read, in one plain line: Google's typed errors (an API turned off in the Cloud project, no +// access to the app, a revoked key, a used-up quota) with the button that fixes them, and the raw API text only in a +// collapsed "Details" and the log (never a JSON dump in the window). +import { useEffect } from 'react'; +import { api, errorText } from '../api'; +import { type GoogleFinding, parseGoogleError, plainError } from '../../../../cli/lib/google-errors'; +import { t, type Key } from '../i18n'; +import { useData } from '../store'; +import { Icon } from './icons'; +import { Notice } from './kit'; + +const WHAT: Record = { + 'playdeveloperreporting.googleapis.com': 'gerr.what.reporting', + 'androidpublisher.googleapis.com': 'gerr.what.publisher', + 'storage.googleapis.com': 'gerr.what.storage', + 'storage-api.googleapis.com': 'gerr.what.storage', +}; + +/** The typed finding of a store read error (from the snapshot, else parsed from its text). */ +export function findingOf(e: { error: string; finding?: GoogleFinding; detail?: string }, app?: string | null): GoogleFinding | null { + return e.finding ?? parseGoogleError(e.detail ?? e.error, { app }); +} + +/** The one line for a finding, in the window's language. */ +export function findingLine(f: GoogleFinding): string { + switch (f.kind) { + case 'api-disabled': return t('gerr.apiDisabled', { what: t(WHAT[f.service] ?? 'gerr.what.other'), service: f.serviceTitle, project: f.projectId ?? t('gerr.projectUnknown') }); + case 'no-app-access': return t('gerr.noAccess', { app: f.app ?? t('gerr.thisApp') }); + case 'auth-revoked': return t('gerr.revoked'); + case 'quota': return t('gerr.quota'); + } +} + +export function TurnOnButton({ url, primary = true }: { url: string; primary?: boolean }) { + const d = useData(); + return ( + + ); +} + +/** One store's read error. `unreadable`: the sentence for an unknown error ({store} / {error}). */ +export function StoreReadNotice({ store, e, app, onRetry, busy, unreadable }: { + store: string; e: { error: string; finding?: GoogleFinding; detail?: string }; app?: string | null; onRetry?: () => void; busy?: boolean; unreadable: Key; +}) { + const d = useData(); + const f = findingOf(e, app); + const raw = e.detail ?? e.error; + useEffect(() => { api.logWebview('warn', `${store} could not be read${f ? ` (${f.kind})` : ''}`, raw).catch(() => {}); }, [store, raw]); // eslint-disable-line react-hooks/exhaustive-deps + const line = f ? findingLine(f) : t(unreadable, { store, error: plainError(raw) }); + const retry = onRetry && ; + const open = (url: string) => api.openExternal(url).catch(err => d.toast({ kind: 'bad', title: t('gerr.openFailed'), detail: errorText(err) })); + return ( + + {f?.kind === 'api-disabled' && } + {f?.kind === 'no-app-access' && } + {retry} + }> +
+ {line} + {raw !== line &&
{t('gerr.details')}
{raw}
} +
+
+ ); +} + +export interface BlockedProject { projectId: string | null; service: string; serviceTitle: string; activationUrl: string; apps: string[] } + +/** api-disabled findings across apps, one entry per Cloud project and API (several apps share one service account). Pure. */ +export function blockedProjects(items: { app: string; errors: ({ error: string; finding?: GoogleFinding; detail?: string } | string | undefined | null)[] }[]): BlockedProject[] { + const out = new Map(); + for (const it of items) for (const e of it.errors) { + if (!e) continue; + const f = typeof e === 'string' ? parseGoogleError(e) : findingOf(e); + if (f?.kind !== 'api-disabled') continue; + const k = `${f.projectId ?? '?'}|${f.service}`; + const b = out.get(k) ?? { projectId: f.projectId, service: f.service, serviceTitle: f.serviceTitle, activationUrl: f.activationUrl, apps: [] }; + if (!b.apps.includes(it.app)) b.apps.push(it.app); + out.set(k, b); + } + return [...out.values()]; +} + +/** The portfolio's one notice for Cloud projects that block reads of several apps. */ +export function BlockedProjectsNotice({ blocked, onRetry, busy }: { blocked: BlockedProject[]; onRetry: () => void; busy: boolean }) { + if (!blocked.length) return null; + const apps = [...new Set(blocked.flatMap(b => b.apps))]; + return ( + {t('gerr.checkAgain')}}> +
+ {t('gerr.portfolio', { count: blocked.length, apps: apps.join(', ') })} + {blocked.map(b => ( +
+ {findingLine({ kind: 'api-disabled', ...b })} + +
+ ))} +
+
+ ); +} diff --git a/cli/analytics.ts b/cli/analytics.ts index 58b139d..be3adb4 100644 --- a/cli/analytics.ts +++ b/cli/analytics.ts @@ -8,6 +8,7 @@ // Download reports > Statistics > Copy Cloud Storage URI) are read with the service account (store listing visitors, // acquisitions, daily user installs). compare lines the snapshots up with the git history of the store files (listing // text, screenshots, config): the days before vs after each change. Read-only on both stores except `setup`. +import { storeReadError } from './lib/google-errors'; import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { createOnce } from './lib/upsert'; @@ -120,11 +121,11 @@ export async function run(ctx: Ctx): Promise { const snap: Snapshot = { date: day(now), window: { start, end, days } }; if ((!store || store === 'apple') && config.apple) { try { const r = await appleSnapshot(ctx, start, end); snap.apple = { daily: r.daily, totals: appleTotals(r.daily) }; } - catch (e) { snap.apple = { error: (e as Error).message.slice(0, 400) }; warn(TAG, `apple: ${snap.apple.error}`); } + catch (e) { snap.apple = { error: storeReadError((e as Error).message).error.slice(0, 400) }; warn(TAG, `apple: ${snap.apple.error}`); } } if ((!store || store === 'play') && config.play) { try { const r = await playSnapshot(ctx, start, end); snap.play = { daily: r.daily, totals: playTotals(r.daily) }; } - catch (e) { snap.play = { error: (e as Error).message.slice(0, 400) }; warn(TAG, `play: ${snap.play.error}`); } + catch (e) { snap.play = { error: storeReadError((e as Error).message, { app: config.play.packageName }).error.slice(0, 400) }; warn(TAG, `play: ${snap.play.error}`); } } const rows: (string | number)[][] = []; if (snap.apple && !('error' in snap.apple)) { const t = snap.apple.totals; rows.push(['apple', `${t.impressions} impressions`, `${t.pageViews} page views`, `${t.downloads} downloads`, pct(t.conversion ?? null)]); } diff --git a/cli/health.ts b/cli/health.ts index fe6322c..3547d20 100644 --- a/cli/health.ts +++ b/cli/health.ts @@ -17,6 +17,7 @@ import { issuesQuery, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, type PlayHealth, ymd, ymdString, } from './lib/health'; import { done, log, printJson, warn } from './lib/log'; +import { plainError, storeReadError } from './lib/google-errors'; import { formatFindings } from './lib/validate-rules'; import { googleClient, REPORTING_API } from './play/google'; @@ -35,7 +36,7 @@ export async function collectPlayHealth(root: string, pkg: string, days: number) // the top issues are extra: a failure there must not hide the crash and ANR rates already read let issues: PlayHealth['issues'] = [], issuesError: string | undefined; try { issues = parseIssues(await g.json('GET', `${app}/errorIssues:search?${issuesQuery(end, days)}`)); } catch (e) { - issuesError = (e as Error).message; + issuesError = plainError((e as Error).message, { app: pkg }); warn('health', `play: top issues not read: ${issuesError.slice(0, 200)}`); } const start = ymd(new Date(Date.UTC(end.year, end.month - 1, end.day)), days); @@ -69,10 +70,10 @@ export async function collectHealth(ctx: Ctx, o: { apple: boolean; play: boolean const { config, root } = ctx.loaded; const h: HealthSnapshot = { date: new Date().toISOString().slice(0, 10) }; if (o.play && config.play) { - try { h.play = await collectPlayHealth(root, config.play.packageName, o.days); } catch (e) { h.play = { error: (e as Error).message }; } + try { h.play = await collectPlayHealth(root, config.play.packageName, o.days); } catch (e) { h.play = storeReadError((e as Error).message, { app: config.play.packageName }); } } if (o.apple && config.apple) { - try { h.apple = await collectAppleHealth(root, config.apple.bundleId, config.apple.appId, o.days); } catch (e) { h.apple = { error: (e as Error).message }; } + try { h.apple = await collectAppleHealth(root, config.apple.bundleId, config.apple.appId, o.days); } catch (e) { h.apple = storeReadError((e as Error).message); } } return h; } @@ -93,6 +94,7 @@ export async function run(ctx: Ctx): Promise { const h = await collectHealth(ctx, { apple: both || a.has('apple'), play: both || a.has('play'), days }); if (a.has('json')) { printJson(h); return; } console.log(healthTable(h)); + for (const [s, x] of [['Google Play', h.play], ['App Store', h.apple]] as const) if (x && 'error' in x) console.log(`\n${s}: ${x.error}`); console.log('\n' + formatFindings(healthFindings(h))); if (weekly) { const dir = ctx.loaded.resolve(a.get('out') ?? '.storebound/health'); diff --git a/cli/lib/analytics.ts b/cli/lib/analytics.ts index 0c5b0b0..d51f895 100644 --- a/cli/lib/analytics.ts +++ b/cli/lib/analytics.ts @@ -15,6 +15,7 @@ // stats/store_performance/store_performance___country.csv (Store listing acquisitions / visitors) // stats/installs/installs___country.csv (Daily User Installs, ...) // https://support.google.com/googleplay/android-developer/answer/6135870 +import { plainError } from './google-errors'; import { createHash } from 'node:crypto'; import { parseCsvRecords } from './csv'; import { mdTable, pct, textTable } from './table'; @@ -144,7 +145,7 @@ export function storeErrors(snaps: Snapshot[]): { apple?: string; play?: string for (const k of ['apple', 'play'] as const) { if (series[k].length) continue; const last = [...snaps].sort((x, y) => x.date.localeCompare(y.date)).reverse().find(s => s[k] && 'error' in s[k]!); - if (last) out[k] = (last[k] as { error: string }).error; + if (last) out[k] = plainError((last[k] as { error: string }).error, { withUrl: true }); } return out; } diff --git a/cli/lib/desktop-protocol.ts b/cli/lib/desktop-protocol.ts index 9818a1f..9c3f70d 100644 --- a/cli/lib/desktop-protocol.ts +++ b/cli/lib/desktop-protocol.ts @@ -69,6 +69,48 @@ export interface ToolResult { output: string; /** Structured result of a --json capable command. */ data?: unknown; + /** What the exit code means: 0 = ok; a documented findings code with the --json result printed (scan / check / + * validate / health exit 1 when they find problems) = findings; 3 = declined; anything else = failed. Missing in + * results cached by older versions (outcomeOf below decides then). */ + outcome?: Outcome; + /** failed only: the command's own error line (stderr's last "error: ..." line, else its last meaningful line). */ + error?: string; +} + +export type Outcome = 'ok' | 'findings' | 'declined' | 'failed'; +/** Read tools whose exit code 1 means "found problems" (the result is still printed with --json): by design, so CI fails. */ +export const FINDINGS_EXIT: Readonly> = { + scan: [1], check: [1], validate: [1], health: [1], check_aso: [1], shots_check: [1], catalog_diff: [1], aso_audit: [1], +}; +/** lib/ctx DECLINED_EXIT: the owner's answer was missing or "no". */ +export const DECLINED_CODE = 3; + +/** The outcome of a run (`tool`: the MCP tool name or the desktop method, dots or underscores). */ +export function outcomeOf(tool: string, code: number, hasData: boolean): Outcome { + if (code === 0) return 'ok'; + if (code === DECLINED_CODE) return 'declined'; + if (hasData && (FINDINGS_EXIT[tool.replace(/\./g, '_')] ?? []).includes(code)) return 'findings'; + return 'failed'; +} + +/** Is this line noise rather than a message (blank, a stack frame, a lone brace, a usage line)? */ +const noise = (l: string): boolean => !l.trim() || /^\s+at\s/.test(l) || /^[\s{}[\](),;]*$/.test(l) || /^\s*(usage|options|examples?):/i.test(l) || /^\s{2,}--?\w/.test(l); +/** A log line without its "12:00:00 [tag] " prefix. */ +const bare = (l: string): string => l.trim().replace(/^\d\d:\d\d:\d\d \[[^\]]+\] /, '').replace(/^\S+ \[[^\]]+\] /, ''); + +/** + * The command's own error line: the last "error: ..." line of stderr (the CLI's top-level catch), else the first + * meaningful stderr line (unknown command / option: the message comes before the usage), else the last meaningful + * output line. '' when the command said nothing. Pure. + */ +export function errorLine(stderr: string, output = ''): string { + const err = stderr.split('\n'); + const tagged = err.filter(l => /^\s*error:\s*\S/i.test(bare(l))).at(-1); + if (tagged) return bare(tagged).replace(/^error:\s*/i, '').slice(0, 500); + const first = err.find(l => !noise(l)); + if (first) return bare(first).slice(0, 500); + const last = output.split('\n').filter(l => !noise(l)).at(-1); + return last ? bare(last).slice(0, 500) : ''; } export interface ProgressParams { requestId: string | number; line?: string; stream?: 'stdout' | 'stderr'; n?: number; total?: number; project?: string } diff --git a/cli/lib/google-errors.ts b/cli/lib/google-errors.ts new file mode 100644 index 0000000..91c0349 --- /dev/null +++ b/cli/lib/google-errors.ts @@ -0,0 +1,98 @@ +// Google API errors -> typed findings. Google answers a disabled API, a missing app permission, a revoked key or a +// used-up quota with a JSON body ({"error":{"code":403,"status":"PERMISSION_DENIED","details":[{"reason": +// "SERVICE_DISABLED",...}]}}); storebound's clients put that body (on one line, maybe cut at 800 characters) into the +// Error message. This reads the message text (raw or cut), so every layer (CLI tables, health / analytics snapshots, +// the desktop window, even old cached snapshots) can say one plain line instead of showing the JSON. Pure. + +export type GoogleFinding = + /** An API is not enabled in the service account's Google Cloud project (403 SERVICE_DISABLED). */ + | { kind: 'api-disabled'; service: string; serviceTitle: string; projectId: string | null; activationUrl: string } + /** The service account has no access to this app in Play Console (403 PERMISSION_DENIED, not SERVICE_DISABLED). */ + | { kind: 'no-app-access'; app: string | null; usersUrl: string } + /** Google refused the key itself (401 UNAUTHENTICATED, invalid_grant, Invalid JWT): revoked, deleted or expired. */ + | { kind: 'auth-revoked' } + /** 429 / RESOURCE_EXHAUSTED / rateLimitExceeded: a quota is used up for now. */ + | { kind: 'quota'; serviceTitle: string | null }; + +export const PLAY_USERS_URL = 'https://play.google.com/console/developers/users-and-permissions'; + +const TITLES: Record = { + 'playdeveloperreporting.googleapis.com': 'Google Play Developer Reporting API', + 'androidpublisher.googleapis.com': 'Google Play Android Developer API', + 'storage.googleapis.com': 'Cloud Storage API', + 'storage-api.googleapis.com': 'Cloud Storage API', +}; +/** What the owner loses while that API is off (the start of the one line). */ +const NEEDS: Record = { + 'playdeveloperreporting.googleapis.com': 'Crash and ANR data', + 'androidpublisher.googleapis.com': 'Reading and updating the app on Google Play', + 'storage.googleapis.com': 'Play statistics (store listing visitors, installs)', + 'storage-api.googleapis.com': 'Play statistics (store listing visitors, installs)', +}; + +/** A JSON string's content without its escapes (\\n as a space: one line). */ +const unescape = (s: string): string => { try { return (JSON.parse(`"${s}"`) as string).replace(/\s+/g, ' ').trim(); } catch { return s.replace(/\\(.)/g, '$1'); } }; + +/** The API's own page in the Cloud console (the host the desktop may open), with the project chosen. */ +export function enableUrl(service: string, projectId: string | null): string { + return `https://console.cloud.google.com/apis/library/${service}${projectId ? `?project=${projectId}` : ''}`; +} + +/** The typed finding in a Google error text, or null when it is another error. `app`: the package, when the caller knows it. */ +export function parseGoogleError(text: string, o: { app?: string | null } = {}): GoogleFinding | null { + const s = String(text ?? ''); + const status = Number(/-> (\d{3})\b/.exec(s)?.[1] ?? /"code":\s*(\d{3})/.exec(s)?.[1] ?? NaN); + const app = o.app ?? /\/(?:applications|apps)\/([A-Za-z][\w.]*\.[\w.]+)/.exec(s)?.[1] ?? null; + if (/SERVICE_DISABLED|has not been used in project|accessNotConfigured|API has not been used|it is disabled\. Enable it| turned on in Google Cloud project/i.test(s)) { + const service = /"service":\s*"([a-z0-9.-]+\.googleapis\.com)"/.exec(s)?.[1] ?? /apis\/api\/([a-z0-9.-]+\.googleapis\.com)/.exec(s)?.[1] + ?? /apis\/library\/([a-z0-9.-]+\.googleapis\.com)/.exec(s)?.[1] ?? /([a-z0-9-]+\.googleapis\.com)/.exec(s)?.[1] ?? 'playdeveloperreporting.googleapis.com'; + const projectId = /"consumer":\s*"projects\/([a-z0-9-]+)"/i.exec(s)?.[1] ?? /in (?:Google Cloud )?project ([a-z0-9-]+)/i.exec(s)?.[1] ?? /[?&]project=([a-z0-9-]+)/i.exec(s)?.[1] ?? null; + const serviceTitle = (/"serviceTitle":\s*"((?:[^"\\]|\\.)*)"/.exec(s)?.[1] && unescape(/"serviceTitle":\s*"((?:[^"\\]|\\.)*)"/.exec(s)![1]!)) + ?? /"message":\s*"([^"]*?) has not been used in project/.exec(s)?.[1] ?? / needs the (.+?) turned on in Google Cloud/.exec(s)?.[1] ?? TITLES[service] ?? service; + return { kind: 'api-disabled', service, serviceTitle, projectId, activationUrl: enableUrl(service, projectId) }; + } + if (status === 429 || /RESOURCE_EXHAUSTED|rateLimitExceeded|quota(?: exceeded| metric)|Quota exceeded/i.test(s)) { + const service = /"service":\s*"([a-z0-9.-]+\.googleapis\.com)"/.exec(s)?.[1]; + return { kind: 'quota', serviceTitle: service ? TITLES[service] ?? service : null }; + } + if (status === 401 || /UNAUTHENTICATED|invalid_grant|Invalid JWT|account not found|invalid_client/i.test(s)) return { kind: 'auth-revoked' }; + if (status === 403 && /PERMISSION_DENIED|caller does not have permission|does not have permission|not have access/i.test(s)) return { kind: 'no-app-access', app, usersUrl: PLAY_USERS_URL }; + return null; +} + +/** The one plain line for a finding (no JSON). `withUrl`: the CLI adds where to fix it; the window has a button. */ +export function findingText(f: GoogleFinding, o: { withUrl?: boolean } = {}): string { + switch (f.kind) { + case 'api-disabled': { + const what = NEEDS[f.service] ?? 'This read'; + const line = `${what} needs the ${f.serviceTitle} turned on in Google Cloud project ${f.projectId ?? '(the service account\'s project)'}.`; + return o.withUrl ? `${line} Turn it on: ${f.activationUrl}` : line; + } + case 'no-app-access': { + const line = `The service account has no access to ${f.app ?? 'this app'} in Play Console: add it under Users and permissions (invite the service account, give it access to ${f.app ?? 'the app'}).`; + return o.withUrl ? `${line} ${f.usersUrl}` : line; + } + case 'auth-revoked': + return 'Google refused the service account key: it was revoked, deleted or has expired. Add a new JSON key (Google Cloud > IAM & Admin > Service accounts > Keys) and set it again.'; + case 'quota': + return `The ${f.serviceTitle ?? 'Google API'} quota is used up for now: try again later (quotas reset per minute and per day).`; + } +} + +/** An error text for people: the finding's line when there is one, else the text without a JSON body. */ +export function plainError(text: string, o: { app?: string | null; withUrl?: boolean } = {}): string { + const f = parseGoogleError(text, o); + if (f) return findingText(f, o); + const s = String(text ?? ''); + const msg = /"(?:message|detail|error_description)":\s*"((?:[^"\\]|\\.)*)"/.exec(s)?.[1]; + const head = s.split(/\s[{[]"/)[0]!.trim(); + return msg ? `${head} ${unescape(msg)}`.trim().slice(0, 300) : s.split('\n')[0]!.slice(0, 300); +} + +/** A store read that failed, for snapshots (health, analytics) and JSON results: the plain line (with the fix URL), + * the typed finding when Google said what is wrong, and the raw text as `detail` (logs, a collapsed "Details"). */ +export function storeReadError(raw: string, o: { app?: string | null } = {}): { error: string; finding?: GoogleFinding; detail?: string } { + const f = parseGoogleError(raw, o); + const error = f ? findingText(f, { withUrl: true }) : plainError(raw); + return { error, ...(f ? { finding: f } : {}), ...(error !== raw ? { detail: raw } : {}) }; +} diff --git a/cli/lib/health.ts b/cli/lib/health.ts index 6b563ff..c14fe6c 100644 --- a/cli/lib/health.ts +++ b/cli/lib/health.ts @@ -18,6 +18,7 @@ // (HANGS, DISK_WRITES, LAUNCHES), GET /v1/apps/{id}/betaFeedbackCrashSubmissions (TestFlight crash reports, API 4.0+). import type { Finding, Level } from './validate-rules'; import { mdTable, pct, textTable } from './table'; +import { findingText, type GoogleFinding, parseGoogleError, plainError } from './google-errors'; import { reportingEnableUrl } from './store-apps'; export const PLAY_THRESHOLDS = { crash: 0.0109, anr: 0.0047, perDevice: 0.08 } as const; @@ -115,7 +116,10 @@ export interface AppleHealth { betaCrashes: { build: string; count: number }[]; } -export interface HealthSnapshot { date: string; play?: PlayHealth | { error: string }; apple?: AppleHealth | { error: string } } +/** A store that could not be read: `error` is one plain line (never a JSON body), `finding` the typed Google error + * (google-errors.ts), `detail` the raw API text for logs and a collapsed "Details". */ +export interface StoreReadError { error: string; finding?: GoogleFinding; detail?: string } +export interface HealthSnapshot { date: string; play?: PlayHealth | StoreReadError; apple?: AppleHealth | StoreReadError } /** Last value of a metric over the rows (the 28-day rolling metrics: the latest row is the store's current view). */ export function lastValue(rows: MetricRow[], metric: string): number | null { @@ -186,11 +190,13 @@ export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; princ const f: Finding[] = []; const play = h.play; if (play && 'error' in play) { - const on = reportingEnableUrl(play.error, o.principal ?? null); - f.push(REPORTING_OFF.test(play.error) - ? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: the Google Play Developer Reporting API is off in the service account's Google Cloud project${on.project ? ` ${on.project}` : ''}`, - fix: `enable "Google Play Developer Reporting API" for that project: ${on.url} (Enable), wait a few minutes (store-health skill)` } - : { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${play.error.slice(0, 200)}`, fix: 'store-health skill (Reporting API access)' }); + const raw = play.detail ?? play.error; + const g = play.finding ?? parseGoogleError(raw); + const on = reportingEnableUrl(raw, o.principal ?? null); + f.push(g?.kind === 'api-disabled' || REPORTING_OFF.test(raw) + ? { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: the ${g?.kind === 'api-disabled' ? g.serviceTitle : 'Google Play Developer Reporting API'} is off in the service account's Google Cloud project${(g?.kind === 'api-disabled' ? g.projectId : on.project) ? ` ${g?.kind === 'api-disabled' ? g.projectId : on.project}` : ''}`, + fix: `enable "${g?.kind === 'api-disabled' ? g.serviceTitle : 'Google Play Developer Reporting API'}" for that project: ${g?.kind === 'api-disabled' ? g.activationUrl : on.url} (Enable), wait a few minutes (store-health skill)` } + : { store: 'play', level: o.unreadable ?? 'warn', code: 'vitals', msg: `vitals not readable: ${g ? findingText(g) : plainError(raw).slice(0, 200)}`, fix: g?.kind === 'no-app-access' ? `Play Console > Users and permissions: ${g.usersUrl}` : 'store-health skill (Reporting API access)' }); } else if (play) { const crash = lastValue(play.crash, 'userPerceivedCrashRate28dUserWeighted') ?? weightedAverage(play.crash, 'userPerceivedCrashRate'); @@ -209,7 +215,7 @@ export function healthFindings(h: HealthSnapshot, o: { unreadable?: Level; princ } } const apple = h.apple; - if (apple && 'error' in apple) f.push({ store: 'apple', level: o.unreadable ?? 'warn', code: 'diagnostics', msg: `diagnostics not readable: ${apple.error.slice(0, 200)}` }); + if (apple && 'error' in apple) f.push({ store: 'apple', level: o.unreadable ?? 'warn', code: 'diagnostics', msg: `diagnostics not readable: ${plainError(apple.error).slice(0, 200)}` }); else if (apple) { const poor = apple.metrics.filter(m => m.goal === 'poor'); for (const m of poor) f.push({ store: 'apple', level: 'warn', code: 'perf', msg: `${m.metric} ${m.value} ${m.unit} on ${m.version}: Apple rates it poor`, fix: 'Xcode Organizer > Metrics; store-health skill' }); @@ -230,12 +236,12 @@ export function healthTable(h: HealthSnapshot): string { rows.push(['play', 'ANR rate (user-perceived, 28 d)', pct(lastValue(p.anr, 'userPerceivedAnrRate28dUserWeighted')), pct(PLAY_THRESHOLDS.anr)]); rows.push(['play', `ANR rate (user-perceived, ${p.window.days} d avg)`, pct(weightedAverage(p.anr, 'userPerceivedAnrRate')), '']); rows.push(['play', 'top issues', p.issuesError ? 'not read' : p.issues.length, '']); - } else if (h.play) rows.push(['play', 'error', h.play.error.slice(0, 80), '']); + } else if (h.play) rows.push(['play', 'not readable', h.play.finding ? h.play.finding.kind : h.play.error.slice(0, 80), '']); if (h.apple && !('error' in h.apple)) { for (const m of h.apple.metrics.slice(0, 12)) rows.push(['apple', `${m.metric} (${m.version})`, `${m.value} ${m.unit}`.trim(), m.goal ?? '']); rows.push(['apple', 'diagnostic signatures', h.apple.signatures.length, '']); rows.push(['apple', 'TestFlight crash reports', h.apple.betaCrashes.reduce((n, b) => n + b.count, 0), '']); - } else if (h.apple) rows.push(['apple', 'error', h.apple.error.slice(0, 80), '']); + } else if (h.apple) rows.push(['apple', 'not readable', h.apple.finding ? h.apple.finding.kind : h.apple.error.slice(0, 80), '']); return textTable(['store', 'metric', 'value', 'threshold / goal'], rows); } diff --git a/cli/lib/runner.ts b/cli/lib/runner.ts index ac0fdd6..51246f7 100644 --- a/cli/lib/runner.ts +++ b/cli/lib/runner.ts @@ -28,7 +28,8 @@ export interface RunOptions { machine?: boolean; } -export interface RunResult { code: number; out: string; data?: unknown; cancelled: boolean } +/** `err`: the stderr lines only (for the command's own error line). */ +export interface RunResult { code: number; out: string; err?: string; data?: unknown; cancelled: boolean } export interface Running { result: Promise; cancel(): void } @@ -70,6 +71,7 @@ export function runCommand(argv: string[], o: RunOptions): Running { const p = rt.spawn([...selfCommand(), ...argv], { cwd: o.cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: childEnv(o.env, o.machine) }); let cancelled = false; const lines: string[] = []; + const errs: string[] = []; let data: unknown; const each = (line: string, kind: 'stdout' | 'stderr'): void => { if (kind === 'stdout' && line.startsWith(RESULT_MARK)) { @@ -77,12 +79,13 @@ export function runCommand(argv: string[], o: RunOptions): Running { return; } lines.push(line); + if (kind === 'stderr') errs.push(line); o.onLine?.(line, kind); }; const result = (async (): Promise => { await Promise.all([pump(p.stdout, 'stdout', each), pump(p.stderr, 'stderr', each)]); const code = await p.exited; - return { code, out: lines.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled }; + return { code, out: lines.join('\n').trim(), err: errs.join('\n').trim(), ...(data === undefined ? {} : { data }), cancelled }; })(); return { result, cancel: () => { cancelled = true; p.kill(); } }; } diff --git a/cli/serve.ts b/cli/serve.ts index 7863f91..fdf8acb 100644 --- a/cli/serve.ts +++ b/cli/serve.ts @@ -20,7 +20,7 @@ import pkg from '../package.json' with { type: 'json' }; import { canonical, parseSecret, TokenGuard } from './lib/confirm-token'; import { CONFIG_NAMES } from './lib/config'; import { - DESKTOP_PROTOCOL, ERR, type AppStatus, type MethodDescriptor, type PortfolioResult, type ProjectSummary, type ToolResult, type VirtualProject, + DESKTOP_PROTOCOL, ERR, type AppStatus, type MethodDescriptor, type PortfolioResult, type ProjectSummary, type ToolResult, type VirtualProject, errorLine, outcomeOf, } from './lib/desktop-protocol'; import { emptyStatus, fillAppStatus, lastLine } from './lib/app-status'; import { RENDER_FILE_RE } from './lib/project-files'; @@ -441,9 +441,11 @@ export class Session { const r = this.track(id, this.run(argv, { cwd: project, env, machine: true, onLine: (line, stream) => this.progress(id, { line, stream }) })); const res = await r.result; if (res.cancelled || this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled'); + const outcome = outcomeOf(t.write ? '' : t.name, res.code, res.data !== undefined); + const error = outcome === 'failed' ? errorLine(res.err ?? '', res.out) : ''; return { method, ok: res.code === 0, exitCode: res.code, dryRun: t.write ? dryRun : false, command: `storebound ${argv.join(' ')}`, output: cut(res.out), - ...(res.data === undefined ? {} : { data: res.data }), + ...(res.data === undefined ? {} : { data: res.data }), outcome, ...(error ? { error } : {}), }; } @@ -461,7 +463,7 @@ export class Session { const argv = [...cmd, '--store', play ? 'play' : 'apple', ...(args.icons === true ? ['--icons'] : []), '--json']; const r = await this.track(id, this.run(argv, { cwd: neutralDir(), env, machine: true, onLine: (line, stream) => this.progress(id, { line, stream }) })).result; if (r.cancelled || this.cancelled.has(id)) throw new RpcError(ERR.cancelled, 'Request cancelled'); - if (r.data === undefined) throw new RpcError(ERR.internal, lastLine(r.out) || `exit ${r.code}`); + if (r.data === undefined) throw new RpcError(ERR.internal, errorLine(r.err ?? '', r.out) || `storebound ${argv[0]} ${argv[1] ?? ''} stopped without an error message (exit ${r.code})`); return r.data; } diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index fb256af..aceceb3 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -660,3 +660,14 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `playPublicPage` (`cli/lib/play-public.ts`), `playReleases` / `versionKey` / `sameVersion` (`cli/lib/portfolio.ts`), `versionHistory` (`cli/lib/train.ts`), `tests/play-public-version-match.test.ts`, the desktop boot check's `release-state` cases (page 404 and 200). + +## 57. A raw Google JSON error in the window, and "Exit code 1" for a scan that found problems +- **Do:** never show an API error body to the owner. Parse Google errors into a typed finding (`parseGoogleError`): + SERVICE_DISABLED = the API is off in the service account's Cloud project (say which API and project, link the + console page to turn it on), PERMISSION_DENIED = the service account has no access to that app (Play Console > Users + and permissions), 401 / invalid_grant = the key is revoked or expired, 429 / RESOURCE_EXHAUSTED = quota. One project + that blocks several apps is one notice, not one error per app. Keep the raw text in a collapsed Details and the log. + Exit 1 of `scan` / `check` / `validate` / `health` with the JSON result printed means "found problems": show the + 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). diff --git a/tests/google-errors.test.ts b/tests/google-errors.test.ts new file mode 100644 index 0000000..50c092c --- /dev/null +++ b/tests/google-errors.test.ts @@ -0,0 +1,76 @@ +// Google API errors as typed findings (pitfall 57): the live 403 SERVICE_DISABLED body of the Play Developer Reporting +// API, a missing app permission, a revoked key and a quota; the one plain line the CLI prints; and the desktop +// runner's outcome of exit codes (scan / check / validate exit 1 with findings is a result, not an error). +import { describe, expect, test } from 'bun:test'; +import { EXAMPLE_CLOUD_PROJECT, healthApiDisabled, SCAN_FAILED, SCAN_FAILED_LINE, serviceDisabledMessage } from '../apps/desktop/src/dev/error-fixtures'; +import { errorLine, outcomeOf } from '../cli/lib/desktop-protocol'; +import { findingText, parseGoogleError, plainError, storeReadError } from '../cli/lib/google-errors'; +import { healthFindings, healthTable } from '../cli/lib/health'; + +const URL = `https://console.cloud.google.com/apis/library/playdeveloperreporting.googleapis.com?project=${EXAMPLE_CLOUD_PROJECT}`; +const LINE = `Crash and ANR data needs the Google Play Developer Reporting API turned on in Google Cloud project ${EXAMPLE_CLOUD_PROJECT}.`; + +describe('403 SERVICE_DISABLED (live shape)', () => { + const msg = serviceDisabledMessage('com.example.demo'); + test('parses to api-disabled with the service, project and a console URL the desktop may open', () => { + expect(parseGoogleError(msg)).toEqual({ kind: 'api-disabled', service: 'playdeveloperreporting.googleapis.com', serviceTitle: 'Google Play Developer Reporting API', projectId: EXAMPLE_CLOUD_PROJECT, activationUrl: URL }); + }); + test('the CLI line: one line with the URL, no JSON', () => { + const e = storeReadError(msg, { app: 'com.example.demo' }); + expect(e.error).toBe(`${LINE} Turn it on: ${URL}`); + expect(e.error).not.toContain('{'); + expect(e.detail).toBe(msg); + expect(findingText(e.finding!)).toBe(LINE); + }); + test('the plain line parses back (cached snapshots and portfolio store errors carry only the line)', () => { + expect(parseGoogleError(storeReadError(msg).error)).toEqual(parseGoogleError(msg)); + }); + test('a body cut at 800 characters (errorBody) still parses', () => { + expect(parseGoogleError(msg.slice(0, 400))?.kind).toBe('api-disabled'); + }); + test('health: the table and findings say it in words', () => { + const h = healthApiDisabled('com.example.demo'); + expect(healthTable(h)).not.toContain('{"error"'); + const f = healthFindings(h); + expect(f[0]!.msg).toContain(`Reporting API is off in the service account's Google Cloud project ${EXAMPLE_CLOUD_PROJECT}`); + expect(f[0]!.fix).toContain(URL); + }); +}); + +describe('other Google errors', () => { + test('403 PERMISSION_DENIED without SERVICE_DISABLED: no access to that app, with the Users and permissions step', () => { + const m = 'GET /androidpublisher/v3/applications/com.example.somegame/edits -> 403 {"error":{"code":403,"message":"The caller does not have permission","status":"PERMISSION_DENIED"}}'; + const f = parseGoogleError(m); + expect(f).toMatchObject({ kind: 'no-app-access', app: 'com.example.somegame' }); + expect(findingText(f!)).toContain('no access to com.example.somegame in Play Console'); + expect(findingText(f!)).toContain('Users and permissions'); + }); + test('invalid_grant and 401: the key is revoked or expired', () => { + expect(parseGoogleError('Google token: invalid_grant: Invalid JWT Signature.')?.kind).toBe('auth-revoked'); + expect(parseGoogleError('GET /v1beta1/apps:search -> 401 {"error":{"code":401,"status":"UNAUTHENTICATED"}}')?.kind).toBe('auth-revoked'); + }); + test('429 / RESOURCE_EXHAUSTED: quota', () => { + const f = parseGoogleError('POST /v1beta1/apps/com.example.a/anrRateMetricSet:query -> 429 {"error":{"code":429,"status":"RESOURCE_EXHAUSTED","details":[{"metadata":{"service":"playdeveloperreporting.googleapis.com"}}]}}'); + expect(f).toEqual({ kind: 'quota', serviceTitle: 'Google Play Developer Reporting API' }); + }); + test('an unknown error keeps its message, never the JSON', () => { + const p = plainError('GET /x -> 500 {"error":{"code":500,"message":"Internal error encountered.","status":"INTERNAL"}}'); + expect(p).toBe('GET /x -> 500 Internal error encountered.'); + }); +}); + +describe('desktop runner: exit codes', () => { + test('scan / check / validate / health exit 1 with the result printed are findings; without it, failed', () => { + for (const m of ['scan', 'check', 'validate', 'health']) expect(outcomeOf(m, 1, true)).toBe('findings'); + expect(outcomeOf('scan', 1, false)).toBe('failed'); + expect(outcomeOf('play_status', 1, true)).toBe('failed'); + expect(outcomeOf('owner.apple.submit', 3, false)).toBe('declined'); + expect(outcomeOf('scan', 0, true)).toBe('ok'); + }); + test('the error line is the command\'s own: "error: ..." of stderr, else the first message before the usage', () => { + expect(SCAN_FAILED.error).toBe(SCAN_FAILED_LINE); + expect(errorLine('unknown option for storebound scan: --x\n\nusage: storebound scan [--json]\n --json print JSON')).toBe('unknown option for storebound scan: --x'); + expect(errorLine('', '12:00:00 [scan] reading\n12:00:01 [scan] the folder has no app')).toBe('the folder has no app'); + expect(errorLine('', '')).toBe(''); + }); +}); diff --git a/tests/play-post-reads.test.ts b/tests/play-post-reads.test.ts index 9159923..7d3a9fa 100644 --- a/tests/play-post-reads.test.ts +++ b/tests/play-post-reads.test.ts @@ -107,7 +107,9 @@ describe('Play vitals: errorIssues:search time zone', () => { reporting(() => new Response(UNSUPPORTED_TZ, { status: 400 })); const h = await collectPlayHealth(mkdtempSync(join(tmpdir(), 'sb-post-')), PKG, 7); expect(h.crash).toEqual([]); - expect(h.issuesError).toStartWith(`GET /v1beta1/apps/${PKG}/errorIssues:search -> 400 {"error":{"code":400,"message":"Unsupported timezone`); + // one plain line: the API's message without the JSON around it (pitfall 57) + expect(h.issuesError).toStartWith(`GET /v1beta1/apps/${PKG}/errorIssues:search -> 400 Unsupported timezone`); + expect(h.issuesError).not.toContain('{"error"'); const f = healthFindings({ date: '2026-10-11', play: h }); expect(f.map(x => x.code)).toEqual(['vitals', 'vitals-issues']); expect(f[1]).toMatchObject({ level: 'warn', msg: expect.stringContaining('Unsupported timezone') }); diff --git a/tools/desktop-boot-check.ts b/tools/desktop-boot-check.ts index 9210f58..c55fc94 100644 --- a/tools/desktop-boot-check.ts +++ b/tools/desktop-boot-check.ts @@ -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 { + 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(); + 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): Promise => { + 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) => { + 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 { 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 {