storebound/tests/health.test.ts
CRT b4d924b132 Guarded read-only run of the Play POST read paths: errorIssues:search in UTC, issues failure keeps the vitals, read-only listing / images dry runs
A live run where only the token exchange, edit insert / delete and Reporting :query were let through:
- health / validate: errorIssues:search answered 400 "Unsupported timezone" for America/Los_Angeles (it takes
  hour-aligned UTC only); the failure dropped the crash and ANR rates and the reason was cut off in the query string.
  Issues are asked in UTC; a failing issues search is a vitals-issues warning; Reporting errors leave out the query.
- play listing --dry / play images --dry wrote into the edit (PUT details and listings, image clears and uploads,
  :validate) before discarding it. Now one throwaway edit is read (details, listings, image sha256) and compared
  with the local copy, then deleted.
- play.read(): one helper for read-only edits (status, next-version-code), always deleted, warns if the delete fails;
  next-version-code counts distinct codes.
- tests on sanitized recorded answers (tests/play-post-reads.test.ts)
2026-10-11 08:41:55 +08:00

109 lines
9.1 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { mkdtempSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { previousSnapshot } from '../cli/health';
import {
ANR_METRICS, betaCrashCounts, CRASH_METRICS, deviceBreakdown, healthFindings, healthMarkdown, type HealthSnapshot, healthTable, issuesQuery,
lastValue, latestDailyEnd, metricQuery, parseIssues, parseMetricRows, parsePerfPower, PLAY_THRESHOLDS, weightedAverage, ymd,
} from '../cli/lib/health';
import { TOOLS, toArgv } from '../cli/mcp-tools';
const row = (day: number, values: Record<string, string>, dims: Record<string, string> = {}) => ({
startTime: { year: 2026, month: 10, day, timeZone: { id: 'America/Los_Angeles' } },
dimensions: Object.entries(dims).map(([dimension, stringValue]) => ({ dimension, stringValue })),
metrics: Object.entries(values).map(([metric, value]) => ({ metric, decimalValue: { value } })),
});
describe('reporting api requests', () => {
test('DAILY timeline in America/Los_Angeles, exclusive end, the documented metric names', () => {
const q = metricQuery(CRASH_METRICS, { year: 2026, month: 10, day: 8 }, 7) as { timelineSpec: Record<string, unknown>; metrics: string[]; dimensions?: string[] };
expect(q.timelineSpec).toEqual({ aggregationPeriod: 'DAILY', startTime: { year: 2026, month: 10, day: 1, timeZone: { id: 'America/Los_Angeles' } }, endTime: { year: 2026, month: 10, day: 8, timeZone: { id: 'America/Los_Angeles' } } });
expect(q.metrics).toEqual(['userPerceivedCrashRate', 'userPerceivedCrashRate28dUserWeighted', 'crashRate', 'distinctUsers']);
expect(ANR_METRICS).toContain('userPerceivedAnrRate28dUserWeighted');
expect(q.dimensions).toBeUndefined();
expect((metricQuery(['x'], { year: 2026, month: 3, day: 1 }, 1, ['deviceModel']) as { dimensions: string[]; timelineSpec: { startTime: unknown } }).timelineSpec.startTime).toMatchObject({ month: 2, day: 28 });
expect(ymd(new Date('2026-01-01T10:00:00Z'), 1)).toEqual({ year: 2025, month: 12, day: 31 });
});
test('freshness and issue search parameters', () => {
expect(latestDailyEnd({ freshnessInfo: { freshnesses: [{ aggregationPeriod: 'HOURLY', latestEndTime: { year: 2026, month: 10, day: 9 } }, { aggregationPeriod: 'DAILY', latestEndTime: { year: 2026, month: 10, day: 8 } }] } })).toEqual({ year: 2026, month: 10, day: 8 });
expect(latestDailyEnd({})).toBeNull();
const p = new URLSearchParams(issuesQuery({ year: 2026, month: 10, day: 8 }, 7, { type: 'CRASH' }));
expect(p.get('interval.startTime.day')).toBe('1');
expect(p.get('interval.endTime.timeZone.id')).toBe('UTC'); // errorIssues:search takes hour-aligned UTC only (400 for LA)
expect(p.get('filter')).toBe('errorIssueType = CRASH');
expect(p.get('orderBy')).toBe('errorReportCount desc');
});
});
describe('parsing', () => {
const crash = parseMetricRows({ rows: [row(2, { userPerceivedCrashRate: '0.004', distinctUsers: '1000', userPerceivedCrashRate28dUserWeighted: '0.006' }), row(1, { userPerceivedCrashRate: '0.010', distinctUsers: '3000' })] });
test('rows sorted by date, decimals as numbers, weighted averages', () => {
expect(crash.map(r => r.date)).toEqual(['2026-10-01', '2026-10-02']);
expect(lastValue(crash, 'userPerceivedCrashRate28dUserWeighted')).toBe(0.006);
expect(lastValue(crash, 'nope')).toBeNull();
expect(weightedAverage(crash, 'userPerceivedCrashRate')).toBeCloseTo((0.01 * 3000 + 0.004 * 1000) / 4000, 10);
});
test('devices: weighted per model, small samples dropped, worst first', () => {
const c = parseMetricRows({ rows: [row(1, { userPerceivedCrashRate: '0.09', distinctUsers: '600' }, { deviceModel: 'acme/old' }), row(1, { userPerceivedCrashRate: '0.5', distinctUsers: '10' }, { deviceModel: 'acme/rare' }),
row(1, { userPerceivedCrashRate: '0.01', distinctUsers: '5000' }, { deviceModel: 'acme/new' })] });
const d = deviceBreakdown(c, []);
expect(d.map(x => x.model)).toEqual(['acme/old', 'acme/new']);
expect(d[0]!.crash).toBeCloseTo(0.09, 10);
});
test('issues, Apple metrics, TestFlight crashes', () => {
expect(parseIssues({ errorIssues: [{ type: 'APPLICATION_NOT_RESPONDING', cause: 'Input dispatching timed out', location: 'com.example.Main', errorReportCount: '12', distinctUsers: '9', lastAppVersion: { versionCode: '42' } }] }))
.toEqual([{ type: 'ANR', cause: 'Input dispatching timed out', location: 'com.example.Main', reports: 12, users: 9, lastVersion: '42', uri: undefined }]);
const perf = parsePerfPower({ productData: [{ metricCategories: [{ identifier: 'HANG', metrics: [{ identifier: 'hangRate', unit: { displayName: 's/hr' },
datasets: [{ filterCriteria: { device: 'all_iphones', percentile: 'percentile.fifty' }, points: [{ version: '1.0', value: 0.5, goal: 'good' }, { version: '1.1', value: 4.2, goal: 'poor' }] }] }] }] }] });
expect(perf).toEqual([{ category: 'HANG', metric: 'hangRate', unit: 's/hr', version: '1.1', value: 4.2, goal: 'poor' }]);
const subs = [{ attributes: { createdDate: '2026-10-05T10:00:00Z' }, relationships: { build: { data: { id: 'b1' } } } }, { attributes: { createdDate: '2026-10-06T10:00:00Z' }, relationships: { build: { data: { id: 'b1' } } } },
{ attributes: { createdDate: '2026-09-01T10:00:00Z' }, relationships: { build: { data: { id: 'b1' } } } }];
expect(betaCrashCounts(subs, new Map([['b1', '42']]), '2026-10-01')).toEqual([{ build: '42', count: 2 }]);
});
});
describe('findings and report', () => {
const play = (crash: string, anr: string) => ({ window: { start: '2026-10-01', end: '2026-10-08', days: 7 },
crash: parseMetricRows({ rows: [row(7, { userPerceivedCrashRate: crash, userPerceivedCrashRate28dUserWeighted: crash, distinctUsers: '1000' })] }),
anr: parseMetricRows({ rows: [row(7, { userPerceivedAnrRate: anr, userPerceivedAnrRate28dUserWeighted: anr, distinctUsers: '1000' })] }), devices: [], issues: [] });
test('thresholds: over = blocker, from 80 % = warn, under = ok', () => {
expect(PLAY_THRESHOLDS).toEqual({ crash: 0.0109, anr: 0.0047, perDevice: 0.08 });
const levels = (h: HealthSnapshot) => Object.fromEntries(healthFindings(h).map(f => [f.code, f.level]));
expect(levels({ date: 'd', play: play('0.0120', '0.0010') })).toEqual({ 'vitals-crash': 'blocker', 'vitals-anr': 'ok' });
expect(levels({ date: 'd', play: play('0.0090', '0.0040') })).toEqual({ 'vitals-crash': 'warn', 'vitals-anr': 'warn' });
const dev = healthFindings({ date: 'd', play: { ...play('0.001', '0.001'), devices: [{ model: 'acme/old', crash: 0.09, users: 600 }] } });
expect(dev.find(f => f.code === 'vitals-device')).toMatchObject({ level: 'blocker' });
expect(healthFindings({ date: 'd', play: { error: '403 forbidden' } })[0]).toMatchObject({ level: 'warn', code: 'vitals' });
expect(healthFindings({ date: 'd', play: { error: '403' } }, { unreadable: 'ok' })[0]!.level).toBe('ok');
expect(healthFindings({ date: 'd', play: { ...play('0', '0'), crash: [], anr: [] } })[0]!.msg).toContain('no vitals data yet');
const apple = healthFindings({ date: 'd', apple: { metrics: [{ category: 'HANG', metric: 'hangRate', unit: 's/hr', version: '1.1', value: 4, goal: 'poor' }], signatures: [{ build: '42', type: 'HANGS', signature: 'main thread', weight: 0.4 }], betaCrashes: [] } });
expect(apple.map(f => f.level)).toEqual(['warn', 'warn']);
});
test('table and markdown with week-over-week change', () => {
const h: HealthSnapshot = { date: '2026-10-09', play: { ...play('0.0120', '0.0010'), issues: [{ type: 'CRASH', cause: 'NullPointerException', location: 'Main.kt', reports: 5, users: 4, lastVersion: '42' }] },
apple: { metrics: [], signatures: [], betaCrashes: [{ build: '42', count: 2 }] } };
const t = healthTable(h);
expect(t).toContain('1.20%');
expect(t).toContain('TestFlight crash reports');
const md = healthMarkdown(h, { appName: 'Example Game', previous: { date: '2026-10-02', play: play('0.0100', '0.0010') } });
expect(md).toContain('# Health report · Example Game · 2026-10-09');
expect(md).toContain('OVER THRESHOLD');
expect(md).toContain('+0.20 pt');
expect(md).toContain('| CRASH | NullPointerException | Main.kt | 5 | 4 | 42 |');
});
test('previous snapshot is the newest one before today', () => {
const dir = mkdtempSync(join(tmpdir(), 'storebound-health-'));
writeFileSync(join(dir, '2026-09-25.json'), JSON.stringify({ date: '2026-09-25' }));
writeFileSync(join(dir, '2026-10-02.json'), JSON.stringify({ date: '2026-10-02' }));
writeFileSync(join(dir, '2026-10-09.json'), JSON.stringify({ date: '2026-10-09' }));
expect(previousSnapshot(dir, '2026-10-09')!.date).toBe('2026-10-02');
expect(previousSnapshot(join(dir, 'none'), '2026-10-09')).toBeNull();
});
test('mcp: health is read-only, the report is a dry-by-default local write', () => {
const t = (n: string) => TOOLS.find(x => x.name === n)!;
expect(t('health')).toMatchObject({ write: false, network: true });
expect(toArgv(t('health'), { store: 'play', days: 28 }).argv).toEqual(['health', '--play', '--days', '28']);
expect(toArgv(t('health_report'), {}).argv).toEqual(['health', '--weekly', '--dry']);
});
});