report: storebound report writes one shareable, self-contained readiness page (.storebound/reports/readiness-<date>.html; --md, --json, --no-open, --offline, --out) from scan, check and validate: a verdict per store (Ready to submit / N blockers / Not checked with the reason), findings per store by severity with guideline, evidence, fix and console button, the owner's compliance questions as a checklist, secrets masked; storebound.dev look, light / dark, print; desktop Checks > Report (same code path, opens through project_reveal) and its Markdown export on the same renderer; read-only MCP tool report
This commit is contained in:
parent
6d95ea00f5
commit
af349b3db1
18 changed files with 792 additions and 12 deletions
|
|
@ -5,6 +5,7 @@
|
|||
// not contain it (tests/webview.test.ts checks the built files for FIXTURE_MARKER).
|
||||
import { type Account, type AccountCheck, type ConsoleFillResult, type ConsoleForm, type AppsListResult, type AppStatus, type CacheEntry, DEFAULT_SETTINGS, type DownloadEvent, type DraftsResult, type ListingField, type ListingsResult,
|
||||
type PortfolioResult, type Project, type ProjectSummary, type Settings, type StoreApp, type StoreRelease, type Studio, type ToolResult } from '../api';
|
||||
import { buildReport } from '../../../../cli/lib/report';
|
||||
import { buildApps, type Overrides } from '../lib/identity';
|
||||
import type { CatalogDiff, HealthSnapshot, MetricRow, Review, TestFlightStatus, ValidateResult, ScanResult } from '../lib/model';
|
||||
import { editableFixture, liveFixture } from './editor-fixtures';
|
||||
|
|
@ -461,6 +462,12 @@ function read(method: string, id: string, args: Record<string, unknown> = {}): T
|
|||
case 'apple.testflight_status': return ok(method, TF[id]);
|
||||
case 'catalog.diff': return ok(method, CATALOG[id]);
|
||||
case 'validate': return ok(method, VALIDATE[id]);
|
||||
case 'report': {
|
||||
const sum = SUMMARY[id];
|
||||
const r = buildReport({ app: { name: sum?.name ?? id }, stores: (['apple', 'play'] as const).filter(x => !!sum?.[x]), storeRead: { apple: true, play: 'no Play service account' },
|
||||
generatedAt: new Date().toISOString(), storebound: 'fixture', validate: VALIDATE[id] as never, scan: (SCAN[id] ?? SCAN_EMPTY) as never });
|
||||
return ok(method, { file: `/fixture/${id}/.storebound/reports/readiness.html`, path: '.storebound/reports/readiness.html', ...r });
|
||||
}
|
||||
case 'scan': return ok(method, SCAN[id] ?? SCAN_EMPTY);
|
||||
case 'project.declarations': return ok(method, DECLARATIONS[id]);
|
||||
case 'project.listing_text': return ok(method, LISTING_TEXT[id]);
|
||||
|
|
@ -505,7 +512,7 @@ const CONSOLE_FILL: Record<ConsoleForm, ConsoleFillResult> = {
|
|||
};
|
||||
|
||||
const CACHED_AGO: Record<string, number> = { health: 60 * 3, 'reviews.list': 45, 'apple.testflight_status': 60 * 5, 'catalog.diff': 60 * 26, validate: 60 * 2, 'prices.current': 60 * 4, 'apple.ppo_status': 60 * 6 };
|
||||
const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
||||
const NETWORK = new Set(['health', 'health.issue', 'reviews.list', 'apple.testflight_status', 'catalog.diff', 'validate', 'report', 'prices.current', 'prices.diff', 'apple.ppo_status', 'apple.status', 'play.status', 'console.verify']);
|
||||
|
||||
async function progressLines(job: string | null, lines: string[]) {
|
||||
for (const line of lines) { await sleep(260); if (job) emit('sidecar-progress', { params: { requestId: 1, line, stream: 'stderr' }, job }); }
|
||||
|
|
|
|||
|
|
@ -422,6 +422,14 @@ export const en = {
|
|||
'checks.problem': 'Problem',
|
||||
'checks.warning': 'Warning',
|
||||
'checks.validate': 'Store readiness',
|
||||
'checks.report': 'Shareable report',
|
||||
'checks.reportSub': 'One page for both stores with every finding, fix and console link, and the questions only you can answer. Opens in your browser; nothing is sent to a store.',
|
||||
'checks.reportOpen': 'Report',
|
||||
'checks.reportFailed': 'The report was not written',
|
||||
'checks.reportBlockers': '{count} blocker(s)',
|
||||
'checks.reportNotChecked': 'not checked (no store key)',
|
||||
'checks.reportNotSetUp': 'not set up',
|
||||
'checks.reportReady': 'ready to submit',
|
||||
'checks.validateSub': 'Read-only report from both stores, blockers in order',
|
||||
'checks.run': 'Run the report',
|
||||
'checks.validateEmpty': 'Readiness not checked yet',
|
||||
|
|
|
|||
|
|
@ -424,6 +424,14 @@ export const tr: Record<keyof typeof en, string> = {
|
|||
'checks.problem': 'Sorun',
|
||||
'checks.warning': 'Uyarı',
|
||||
'checks.validate': 'Mağaza hazırlığı',
|
||||
'checks.report': 'Paylaşılabilir rapor',
|
||||
'checks.reportSub': 'İki mağaza için tek sayfa: her bulgu, düzeltme ve konsol bağlantısı, yalnızca senin yanıtlayabileceğin sorular. Tarayıcında açılır; mağazaya hiçbir şey gönderilmez.',
|
||||
'checks.reportOpen': 'Rapor',
|
||||
'checks.reportFailed': 'Rapor yazılamadı',
|
||||
'checks.reportBlockers': '{count} engel',
|
||||
'checks.reportNotChecked': 'kontrol edilmedi (mağaza anahtarı yok)',
|
||||
'checks.reportNotSetUp': 'kurulmamış',
|
||||
'checks.reportReady': 'gönderime hazır',
|
||||
'checks.validateSub': 'İki mağazadan salt okunur rapor, engeller sırayla',
|
||||
'checks.run': 'Raporu çalıştır',
|
||||
'checks.validateEmpty': 'Hazırlık henüz kontrol edilmedi',
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@
|
|||
import type { AnalyticsCompare, AppStatus, DeclarationsResult, ListingText, PricesCurrent } from '../api';
|
||||
import type { CatalogDiff, HealthSnapshot, Review, ValidateResult } from './model';
|
||||
import { isError } from './model';
|
||||
import { buildReport, renderMarkdown } from '../../../../cli/lib/report';
|
||||
|
||||
type Cell = string | number | boolean | null | undefined;
|
||||
|
||||
|
|
@ -62,11 +63,12 @@ export function healthMd(app: string, h: HealthSnapshot): string {
|
|||
return out.join('\n');
|
||||
}
|
||||
|
||||
export function validateMd(app: string, v: ValidateResult, at: string | null): string {
|
||||
const lvl = (l: string) => (l === 'blocker' ? 'Blocker' : l === 'warn' ? 'Warning' : 'OK');
|
||||
return [`# ${app}: store readiness`, '', `Read ${at ?? 'now'} · ${v.blockers} blocker(s), ${v.findings.filter(f => f.level === 'warn').length} warning(s)`, '',
|
||||
mdTable(['store', 'level', 'check', 'finding', 'fix'], v.findings.map(f => [f.store === 'apple' ? 'App Store' : 'Google Play', lvl(f.level), f.code, f.msg, f.fix ?? ''])),
|
||||
].join('\n');
|
||||
/** The store readiness export: the same report and Markdown renderer as `storebound report --md` (cli/lib/report.ts),
|
||||
* from what the Checks tab read (validate, the offline check, the scan when it ran). */
|
||||
export function validateMd(app: string, v: ValidateResult, at: string | null, more: { check?: { problems: string[]; warnings: string[] } | null; scan?: unknown; version?: string; stores?: ('apple' | 'play')[] } = {}): string {
|
||||
const r = buildReport({ app: { name: app }, ...(more.stores ? { stores: more.stores } : {}), generatedAt: at ?? new Date().toISOString(), storebound: more.version ?? 'desktop',
|
||||
validate: v as never, check: more.check ?? null, scan: (more.scan ?? null) as never, storeRead: { apple: true, play: true } });
|
||||
return renderMarkdown(r);
|
||||
}
|
||||
|
||||
export function catalogMd(app: string, c: CatalogDiff): string {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import type { Finding, FixAction, ScanFinding, ScanResult, ScanSeverity, Validat
|
|||
import { useData, useRead, useWrite } from '../../store';
|
||||
import { WriteButton } from '../../ui/WriteButton';
|
||||
import { AiFix, aiFinding } from '../../ui/AiFix';
|
||||
import { api } from '../../api';
|
||||
import { api, errorText, newJob } from '../../api';
|
||||
import type { ToolResult } from '../../api';
|
||||
import type { ReportData } from '../../../../../cli/lib/report';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Icon } from '../../ui/icons';
|
||||
import { OpenLink } from '../../ui/OpenLink';
|
||||
|
|
@ -15,6 +17,8 @@ import { Card, Chip, Empty, ErrorNotice, ReadControl, Skeleton } from '../../ui/
|
|||
import { exportName, validateMd } from '../../lib/exports';
|
||||
import { useExports } from '../../ui/Exports';
|
||||
|
||||
type ReportResult = ReportData & { file: string; path: string };
|
||||
|
||||
const LEVEL: Record<Finding['level'], ['bad' | 'warn' | 'ok', 'alertCircle' | 'alert' | 'check']> = { blocker: ['bad', 'alertCircle'], warn: ['warn', 'alert'], ok: ['ok', 'check'] };
|
||||
|
||||
/** Fix methods the window runs from a finding (cli/lib/fix-actions.ts FIX_METHODS): local config writes and store
|
||||
|
|
@ -93,18 +97,55 @@ function ScanCard({ project }: { project: Project }) {
|
|||
);
|
||||
}
|
||||
|
||||
/** The shareable readiness report (`storebound report`, the same code path as the CLI): one HTML page in
|
||||
* .storebound/reports/, opened in the default browser through project_reveal (the host only opens that page). */
|
||||
function ReportBar({ project }: { project: Project }) {
|
||||
const d = useData();
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [last, setLast] = useState<ReportResult | null>(null);
|
||||
const run = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await api.call<ToolResult & { data?: ReportResult }>('report', project.id, {}, newJob('report'));
|
||||
if (!r.ok || !r.data?.path) throw new Error(r.output || t('checks.reportFailed'));
|
||||
setLast(r.data);
|
||||
await api.reveal(project.id, r.data.path, true);
|
||||
} catch (e) { d.toast({ kind: 'bad', title: t('checks.reportFailed'), detail: errorText(e) }); }
|
||||
finally { setBusy(false); }
|
||||
};
|
||||
return (
|
||||
<Card title={t('checks.report')} icon="external" sub={last ? reportSummary(last) : t('checks.reportSub')}
|
||||
actions={<button className="btn btn-sm btn-primary" onClick={run} disabled={busy}><Icon name={busy ? 'refresh' : 'external'} className={busy ? 'spin' : undefined} />{t('checks.reportOpen')}</button>}>
|
||||
{null}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** One line, both stores: "App Store: 2 blockers · Google Play: not checked". */
|
||||
export function reportSummary(r: ReportResult): string {
|
||||
const one = (s: 'apple' | 'play') => {
|
||||
const x = r.summary[s], read = r.read[s];
|
||||
const v = x.blockers ? t('checks.reportBlockers', { count: x.blockers }) : !r.stores.includes(s) ? t('checks.reportNotSetUp') : !read.checked ? t('checks.reportNotChecked') : t('checks.reportReady');
|
||||
return `${s === 'apple' ? 'App Store' : 'Google Play'}: ${v}`;
|
||||
};
|
||||
return `${one('apple')} · ${one('play')}`;
|
||||
}
|
||||
|
||||
export function ChecksTab({ project }: { project: Project }) {
|
||||
const d = useData();
|
||||
const e = d.statusOf(project);
|
||||
const v = useRead<ValidateResult>(project.id, 'validate', { network: true, title: t('checks.validate') });
|
||||
const w = useWrite(project.id);
|
||||
const name = e?.summary?.name ?? project.name;
|
||||
useExports('checks', [{ id: 'validate-md', label: t('export.validate'), icon: 'shield', kind: 'md', file: exportName(name, 'store readiness'), disabled: v.data ? false : t('export.readFirst'), build: () => validateMd(name, v.data!, v.at) }]);
|
||||
const stores = (['apple', 'play'] as const).filter(x => !!e?.summary?.[x]);
|
||||
useExports('checks', [{ id: 'validate-md', label: t('export.validate'), icon: 'shield', kind: 'md', file: exportName(name, 'store readiness'), disabled: v.data ? false : t('export.readFirst'),
|
||||
build: () => validateMd(name, v.data!, v.at, { check: e?.check ?? null, version: d.info?.version, ...(stores.length ? { stores } : {}) }) }]);
|
||||
const problems = [...(e?.errors ?? []), ...(e?.check?.problems ?? [])];
|
||||
const warnings = e?.check?.warnings ?? [];
|
||||
const findings = [...(v.data?.findings ?? [])].sort((a, b) => ['blocker', 'warn', 'ok'].indexOf(a.level) - ['blocker', 'warn', 'ok'].indexOf(b.level));
|
||||
return (
|
||||
<div className="grid g2" style={{ alignItems: 'start' }}>
|
||||
<div style={{ gridColumn: '1 / -1' }}><ReportBar project={project} /></div>
|
||||
<div style={{ gridColumn: '1 / -1' }}><ScanCard project={project} /></div>
|
||||
<Card title={t('checks.offline')} icon="shield" sub={t('checks.offlineSub')} pad={false}
|
||||
actions={<button className="btn btn-sm" onClick={() => d.refreshPortfolio(false)} disabled={!!d.portfolioBusy}><Icon name="refresh" className={d.portfolioBusy === 'offline' ? 'spin' : undefined} />{t('checks.again')}</button>}>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue