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
12
CHANGELOG.md
12
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 <id>. 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
|
||||
|
|
|
|||
|
|
@ -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<Value>) -> Res<Option<Entry>> {
|
||||
|
|
|
|||
64
apps/desktop/src/dev/error-fixtures.ts
Normal file
64
apps/desktop/src/dev/error-fixtures.ts
Normal file
|
|
@ -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';
|
||||
|
|
@ -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}',
|
||||
|
|
|
|||
|
|
@ -68,7 +68,25 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'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}',
|
||||
|
|
|
|||
|
|
@ -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' });
|
||||
|
|
|
|||
20
apps/desktop/src/lib/results.ts
Normal file
20
apps/desktop/src/lib/results.ts
Normal file
|
|
@ -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<ToolResult, 'exitCode' | 'data' | 'outcome'>): 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<ToolResult, 'exitCode' | 'data' | 'outcome'>): 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<ToolResult, 'exitCode' | 'output' | 'error'>): string {
|
||||
const line = r.error || errorLine('', r.output ?? '');
|
||||
return line ? plainError(line) : t('common.exitNoMessage', { code: r.exitCode });
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="page">
|
||||
|
|
|
|||
|
|
@ -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 <span className="muted small">{t('app.notOnStore')}</span>;
|
||||
if (!e) return <span className="muted small">{t('common.unknown')}</span>;
|
||||
const err = e.storeErrors[store];
|
||||
if (err) return <span title={err}><Chip tone="warn">{t('portfolio.unreadable')}</Chip></span>;
|
||||
if (err) return <span title={plainError(err)}><Chip tone="warn">{t('portfolio.unreadable')}</Chip></span>;
|
||||
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<string, { h: HealthSnapshot; at: string } | null> {
|
||||
function useCachedHealth(targets: string[], round = 0): Record<string, { h: HealthSnapshot; at: string } | null> {
|
||||
const [m, set] = useState<Record<string, { h: HealthSnapshot; at: string } | null>>({});
|
||||
const key = targets.join();
|
||||
const key = targets.join() + '#' + round;
|
||||
useEffect(() => {
|
||||
for (const id of targets) {
|
||||
api.cached<ToolResult>('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<View>) => 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<ToolResult>('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() {
|
|||
</div>
|
||||
|
||||
{d.portfolioError && <Notice tone="bad">{d.portfolioError}</Notice>}
|
||||
<BlockedProjectsNotice blocked={blocked} onRetry={recheck} busy={rechecking} />
|
||||
{neverImported > 0 && <Notice tone="info" icon="download" action={<button className="btn btn-sm" onClick={() => d.importApps()} disabled={d.importBusy}>{t('action.importApps')}</button>}>{t('portfolio.notImported', { count: neverImported })}</Notice>}
|
||||
{!known && d.ready && <Notice tone="info" action={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(true)}>{t('action.readStores')}</button>}>{t('portfolio.notReadNotice')}</Notice>}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
|||
</div>
|
||||
{r.error && <Notice tone="warn">{t('common.readFailedKeep', { error: r.error })}</Notice>}
|
||||
<h2 className="label">Google Play</h2>
|
||||
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <Notice tone="warn">{t('health.unreadable', { store: 'Google Play', error: h.play.error })}</Notice> : <Play h={h.play} />}
|
||||
{h.play === undefined ? <Notice tone="info">{t('health.noPlay')}</Notice> : isError(h.play) ? <StoreReadNotice store="Google Play" e={h.play} app={app.android?.app.id ?? null} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Play h={h.play} />}
|
||||
<h2 className="label">App Store</h2>
|
||||
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <Notice tone="warn">{t('health.unreadable', { store: 'App Store', error: h.apple.error })}</Notice> : <Apple h={h.apple} />}
|
||||
{h.apple === undefined ? <Notice tone="info">{t('health.noApple')}</Notice> : isError(h.apple) ? <StoreReadNotice store="App Store" e={h.apple} onRetry={r.refresh} busy={r.loading} unreadable="health.unreadable" /> : <Apple h={h.apple} />}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: msg })}</Notice>)}
|
||||
{errs.map(([s, msg]) => <Notice key={s} tone="warn">{t('releases.storeUnreadable', { store: storeName(s as 'ios'), error: plainError(msg) })}</Notice>)}
|
||||
{l.release.next && <Train title={t('tl.next')} train={l.release.next} tf={tf.data} tone="accent" app={app} />}
|
||||
{l.release.current && <Train title={t('tl.live')} train={l.release.current} tf={tf.data} tone="ok" app={app} />}
|
||||
{!l.release.next && !l.release.current && <Card pad={false}><Empty compact illo="unknown" title={t('releases.noVersions')}>{t('releases.noVersionsText')}</Empty></Card>}
|
||||
|
|
|
|||
|
|
@ -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<T>(target: string, method: string, o: { network: boolean
|
|||
try {
|
||||
const r = await api.call<ToolResult>(method, target, { args: JSON.parse(argsKey) as Record<string, unknown> }, 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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ToolResult & { data?: ConsoleFillResult }>('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);
|
||||
|
|
|
|||
|
|
@ -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<ToolResult & { data?: Plan }>('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<ToolResult & { data?: VerifyResult }>('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') }), () => {}); };
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
102
apps/desktop/src/ui/StoreError.tsx
Normal file
102
apps/desktop/src/ui/StoreError.tsx
Normal file
|
|
@ -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<string, Key> = {
|
||||
'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 (
|
||||
<button className={`btn btn-sm${primary ? ' btn-primary' : ''}`} onClick={() => api.openExternal(url).catch(e => d.toast({ kind: 'bad', title: t('gerr.openFailed'), detail: errorText(e) }))}>
|
||||
<Icon name="external" />{t('gerr.turnOn')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 && <button className="btn btn-sm" onClick={onRetry} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('gerr.checkAgain')}</button>;
|
||||
const open = (url: string) => api.openExternal(url).catch(err => d.toast({ kind: 'bad', title: t('gerr.openFailed'), detail: errorText(err) }));
|
||||
return (
|
||||
<Notice tone="warn" action={<div className="row" style={{ gap: 8, flex: 'none' }}>
|
||||
{f?.kind === 'api-disabled' && <TurnOnButton url={f.activationUrl} />}
|
||||
{f?.kind === 'no-app-access' && <button className="btn btn-sm btn-primary" onClick={() => open(f.usersUrl)}><Icon name="external" />{t('gerr.openUsers')}</button>}
|
||||
{retry}
|
||||
</div>}>
|
||||
<div className="stack" style={{ gap: 4 }}>
|
||||
<span className="store-error-line">{line}</span>
|
||||
{raw !== line && <details className="toast-log store-error-details"><summary className="small">{t('gerr.details')}</summary><pre className="log selectable">{raw}</pre></details>}
|
||||
</div>
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
||||
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<string, BlockedProject>();
|
||||
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 (
|
||||
<Notice tone="warn" icon="cloud" action={<button className="btn btn-sm" onClick={onRetry} disabled={busy}><Icon name="refresh" className={busy ? 'spin' : undefined} />{t('gerr.checkAgain')}</button>}>
|
||||
<div className="stack blocked-projects" style={{ gap: 6 }}>
|
||||
<span className="strong">{t('gerr.portfolio', { count: blocked.length, apps: apps.join(', ') })}</span>
|
||||
{blocked.map(b => (
|
||||
<div key={`${b.projectId}|${b.service}`} className="row" style={{ gap: 8, flexWrap: 'wrap' }}>
|
||||
<span className="grow small">{findingLine({ kind: 'api-disabled', ...b })}</span>
|
||||
<TurnOnButton url={b.activationUrl} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Notice>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<void> {
|
|||
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)]); }
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
|||
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');
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@
|
|||
// stats/store_performance/store_performance_<package>_<yyyyMM>_country.csv (Store listing acquisitions / visitors)
|
||||
// stats/installs/installs_<package>_<yyyyMM>_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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Record<string, readonly number[]>> = {
|
||||
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 }
|
||||
|
|
|
|||
98
cli/lib/google-errors.ts
Normal file
98
cli/lib/google-errors.ts
Normal file
|
|
@ -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<string, string> = {
|
||||
'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<string, string> = {
|
||||
'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 } : {}) };
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<RunResult>; 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<RunResult> => {
|
||||
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(); } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
76
tests/google-errors.test.ts
Normal file
76
tests/google-errors.test.ts
Normal file
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
|
|
@ -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') });
|
||||
|
|
|
|||
|
|
@ -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