storebound/tests/watch.test.ts

248 lines
18 KiB
TypeScript

import { describe, expect, test } from 'bun:test';
import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { TOOLS, toArgv } from '../cli/mcp-tools';
import type { HealthSnapshot } from '../cli/lib/health';
import {
type AppSnap, appendEvents, diffApp, durationMs, eventsFile, filterEvents, healthSnap, mergeSnap, readEvents, readState, releaseMap, type RelSnap, runPass, stateFile,
testEvent, type WatchEvent, writeState,
} from '../cli/lib/watch';
import { type FetchLike, ntfyRequest, osCommand, post, sendEvent, sinkConfig, telegramRequest, webhookRequest } from '../cli/lib/watch-sinks';
const AT = '2026-10-22T10:00:00.000Z';
const rel = (phase: string, state: string, fraction: number | null = null): RelSnap => ({ phase, state, fraction });
const snap = (o: Partial<AppSnap> = {}): AppSnap => ({ name: 'Demo App', ids: { appleAppId: '6400000000', playDeveloperId: '111', playAppId: '222' }, ...o });
const kinds = (es: WatchEvent[]) => es.map(e => `${e.store}:${e.kind}`);
const tmp = () => mkdtempSync(join(tmpdir(), 'sb-watch-'));
describe('watch diff', () => {
test('a first read of an app is a baseline: no events', () => {
expect(diffApp(undefined, snap({ releases: { apple: { '1.0': rel('rejected', 'REJECTED') } } }), AT)).toEqual([]);
});
test('App Store: in review -> rejected, pending developer release, live', () => {
const was = snap({ releases: { apple: { '1.2.0': rel('review', 'IN_REVIEW') } } });
const rejected = diffApp(was, snap({ releases: { apple: { '1.2.0': rel('rejected', 'REJECTED') } } }), AT);
expect(rejected).toHaveLength(1);
expect(rejected[0]).toMatchObject({ app: 'Demo App', store: 'apple', kind: 'rejected', version: '1.2.0', from: 'IN_REVIEW', to: 'REJECTED', at: AT });
expect(rejected[0]!.url).toBe('https://appstoreconnect.apple.com/apps/6400000000/distribution/reviewsubmissions');
const approved = diffApp(was, snap({ releases: { apple: { '1.2.0': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), AT);
expect(kinds(approved)).toEqual(['apple:approved']);
expect(approved[0]!.url).toContain('/apps/6400000000/distribution/ios/version');
const live = diffApp(snap({ releases: { apple: { '1.2.0': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), snap({ releases: { apple: { '1.2.0': rel('live', 'READY_FOR_SALE') } } }), AT);
expect(kinds(live)).toEqual(['apple:live']);
});
test('Google Play: review -> live, rollout starts, rollout halted', () => {
const play = (r: RelSnap) => snap({ releases: { play: { '1.2.0': r } } });
expect(kinds(diffApp(play(rel('review', 'completed')), play(rel('live', 'completed')), AT))).toEqual(['play:live']);
const started = diffApp(play(rel('review', 'completed')), play(rel('rollout', 'inProgress', 0.1)), AT);
expect(kinds(started)).toEqual(['play:live']);
expect(started[0]!.detail).toContain('10 %');
expect(started[0]!.url).toBe('https://play.google.com/console/u/0/developers/111/app/222/tracks/production');
const halted = diffApp(play(rel('rollout', 'inProgress', 0.1)), play(rel('halted', 'halted', 0.1)), AT);
expect(kinds(halted)).toEqual(['play:rollout_halted']);
});
test('no event for an unchanged phase, a rollout step, a rollout completing, or a store that was not read', () => {
const a = snap({ releases: { apple: { '1.0': rel('rollout', 'READY_FOR_SALE', 0.1) }, play: { '1.0': rel('rollout', 'inProgress', 0.1) } } });
const b = snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE', 1) }, play: { '1.0': rel('rollout', 'inProgress', 0.5) } } });
expect(diffApp(a, b, AT)).toEqual([]);
expect(diffApp(a, snap(), AT)).toEqual([]);
expect(diffApp(snap(), snap({ releases: { apple: { '1.0': rel('rejected', 'REJECTED') } } }), AT)).toEqual([]);
});
test('a new version that appears already live is an event; one that appears in review is not', () => {
const was = snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE') } } });
expect(kinds(diffApp(was, snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE'), '1.1': rel('review', 'WAITING_FOR_REVIEW') } } }), AT))).toEqual([]);
expect(kinds(diffApp(was, snap({ releases: { apple: { '1.0': rel('live', 'READY_FOR_SALE'), '1.1': rel('live', 'READY_FOR_SALE') } } }), AT))).toEqual(['apple:live']);
});
test('crash threshold: only the crossing, per metric and per store', () => {
const play = (crash: number | null, anr: number | null) => snap({ health: { play: { crash, anr } } });
const cross = diffApp(play(0.005, 0.001), play(0.012, 0.001), AT);
expect(kinds(cross)).toEqual(['play:crash_threshold']);
expect(cross[0]).toMatchObject({ from: '0.50 %', to: '1.20 %' });
expect(cross[0]!.url).toContain('/vitals/crashes');
expect(diffApp(play(0.012, 0.001), play(0.015, 0.001), AT)).toEqual([]);
expect(kinds(diffApp(play(null, null), play(0.012, 0.005), AT))).toEqual(['play:crash_threshold', 'play:crash_threshold']);
expect(diffApp(play(0.012, 0), play(0.002, 0), AT)).toEqual([]);
const apple = (poor: string[]) => snap({ health: { apple: { poor } } });
expect(kinds(diffApp(apple([]), apple(['hangRate 2 s on 1.2']), AT))).toEqual(['apple:crash_threshold']);
expect(diffApp(apple(['hangRate 2 s on 1.2']), apple(['hangRate 2 s on 1.2']), AT)).toEqual([]);
});
test('reviews: new low review, new unanswered review, nothing for seen or answered ones', () => {
const r = (id: string, rating: number, replied = false) => ({ id, rating, replied, date: '2026-10-22T09:00:00Z', text: `text of ${id}` });
const was = snap({ reviews: { apple: [r('a', 5)], play: [r('p', 1)] } });
const now = snap({ reviews: { apple: [r('a', 5), r('b', 1), r('c', 4), r('d', 5, true)], play: [r('p', 1), r('q', 2)] } });
const evs = diffApp(was, now, AT);
expect(evs.map(e => `${e.store}:${e.kind}:${e.to}`)).toEqual(['apple:review_low:1 stars', 'apple:review_needs_reply:4 stars', 'play:review_low:2 stars']);
expect(evs[0]!.detail).toContain('text of b');
expect(diffApp(was, was, AT)).toEqual([]);
});
test('every event kind is producible and carries the full shape', () => {
const all = [
...diffApp(snap({ releases: { apple: { '1': rel('review', 'IN_REVIEW') }, play: { '1': rel('rollout', 'inProgress') } } }),
snap({ releases: { apple: { '1': rel('rejected', 'REJECTED') }, play: { '1': rel('halted', 'halted') } } }), AT),
...diffApp(snap({ releases: { apple: { '2': rel('review', 'IN_REVIEW') } } }), snap({ releases: { apple: { '2': rel('approved', 'PENDING_DEVELOPER_RELEASE') } } }), AT),
...diffApp(snap({ releases: { apple: { '3': rel('approved', 'X') } } }), snap({ releases: { apple: { '3': rel('live', 'READY_FOR_SALE') } } }), AT),
...diffApp(snap({ health: { play: { crash: 0, anr: 0 } } }), snap({ health: { play: { crash: 0.02, anr: 0 } } }), AT),
...diffApp(snap({ reviews: { play: [] } }), snap({ reviews: { play: [{ id: 'x', rating: 1, replied: false, date: AT, text: 't' }, { id: 'y', rating: 5, replied: false, date: AT, text: 't' }] } }), AT),
];
expect([...new Set(all.map(e => e.kind))].sort()).toEqual(['approved', 'crash_threshold', 'live', 'rejected', 'review_low', 'review_needs_reply', 'rollout_halted']);
for (const e of all) { expect(Object.keys(e).sort()).toEqual(['app', 'at', 'detail', 'from', 'kind', 'store', 'to', 'url', 'version']); expect(e.url).toMatch(/^https:\/\//); }
});
});
describe('watch snapshots and a pass', () => {
test('releaseMap keeps the furthest-along row per version; healthSnap reads rates and poor items', () => {
const rows = [
{ store: 'android', version: '1.0', build: '1', state: 'completed', phase: 'testing', fraction: null, track: 'beta' },
{ store: 'android', version: '1.0', build: '2', state: 'inProgress', phase: 'rollout', fraction: 0.2, track: 'production' },
{ store: 'ios', version: '1.0', build: '2', state: 'IN_REVIEW', phase: 'review', fraction: null, track: null },
] as Parameters<typeof releaseMap>[0];
expect(releaseMap(rows, 'android')).toEqual({ '1.0': { phase: 'rollout', state: 'inProgress', fraction: 0.2 } });
expect(Object.keys(releaseMap(rows, 'ios'))).toEqual(['1.0']);
const h: HealthSnapshot = { date: '2026-10-22',
play: { window: { start: 'a', end: 'b', days: 7 }, crash: [{ date: 'd', dims: {}, values: { userPerceivedCrashRate28dUserWeighted: 0.012 } }], anr: [], issues: [] },
apple: { metrics: [{ category: 'c', metric: 'hangRate', unit: 's', version: '1.0', value: 2, goal: 'poor' }], signatures: [], betaCrashes: [] } };
expect(healthSnap(h)).toEqual({ play: { crash: 0.012, anr: null }, apple: { poor: ['hangRate 2 s on 1.0'] } });
expect(healthSnap({ date: 'd', play: { error: 'x' } })).toEqual({});
});
test('mergeSnap keeps a part this read did not get, and the seen reviews', () => {
const old = snap({ releases: { apple: { '1': rel('live', 'X') } }, reviews: { play: [{ id: 'a', rating: 5, replied: true, date: '2026-10-01', text: '' }] } });
const m = mergeSnap(old, snap({ releases: { play: { '1': rel('live', 'X') } }, reviews: { play: [{ id: 'b', rating: 5, replied: true, date: '2026-10-02', text: '' }] } }));
expect(Object.keys(m.releases!).sort()).toEqual(['apple', 'play']);
expect(m.reviews!.play!.map(r => r.id)).toEqual(['b', 'a']);
});
test('runPass: baseline pass emits nothing, the second pass emits, an unreadable app keeps its state', async () => {
const targets = [{ key: 'dir:/a', name: 'A' }, { key: 'dir:/b', name: 'B' }];
let phase = 'review', failB = false;
const read = async (t: { key: string; name: string }) => {
if (t.name === 'B' && failB) return { snap: null, errors: ['boom'] };
return { snap: snap({ name: t.name, releases: { apple: { '1': rel(phase, phase === 'live' ? 'READY_FOR_SALE' : 'IN_REVIEW') } } }), errors: [] };
};
const one = await runPass({ targets, prev: readState(join(tmp(), 'none.json')), at: AT, read });
expect(one.events).toEqual([]);
expect(one.baseline).toEqual(['A', 'B']);
phase = 'live'; failB = true;
const two = await runPass({ targets, prev: one.state, at: AT, read });
expect(two.events.map(e => e.app)).toEqual(['A']);
expect(two.errors).toEqual([{ app: 'B', error: 'boom' }]);
expect(two.state.apps['dir:/b']!.releases!.apple!['1']!.phase).toBe('review');
});
});
describe('events.jsonl and state files', () => {
const home = () => ({ STOREBOUND_HOME: tmp() });
const ev = (o: Partial<WatchEvent> = {}): WatchEvent => ({ ...testEvent(AT), ...o });
test('paths follow STOREBOUND_HOME', () => {
const env = home();
expect(eventsFile(env)).toBe(join(env.STOREBOUND_HOME, 'events.jsonl'));
expect(stateFile(env)).toBe(join(env.STOREBOUND_HOME, 'watch-state.json'));
});
test('append writes one JSON per line and reads back; a torn line is skipped', () => {
const f = eventsFile(home());
appendEvents([ev({ app: 'A' }), ev({ app: 'B' })], f);
appendEvents([ev({ app: 'C' })], f);
writeFileSync(f, readFileSync(f, 'utf8') + '{"at":"2026\n');
expect(readEvents(f).map(e => e.app)).toEqual(['A', 'B', 'C']);
});
test('rotation at the size limit keeps one .1 file and reads both', () => {
const f = eventsFile(home());
appendEvents([ev({ app: 'old' })], f, 50);
appendEvents([ev({ app: 'new' })], f, 50);
expect(existsSync(`${f}.1`)).toBe(true);
expect(readFileSync(f, 'utf8').trim().split('\n')).toHaveLength(1);
appendEvents([ev({ app: 'newer' })], f, 50);
appendEvents([ev({ app: 'newest' })], f, 50);
expect(readEvents(f).map(e => e.app)).toEqual(['newer', 'newest']);
expect(readFileSync(`${f}.1`, 'utf8')).toContain('newer');
});
test('state round trip; an unreadable file is a fresh baseline', () => {
const f = stateFile(home());
expect(readState(f).apps).toEqual({});
writeState({ version: 1, at: AT, apps: { k: snap() } }, f);
expect(readState(f).apps.k!.name).toBe('Demo App');
writeFileSync(f, '{nope');
expect(readState(f).apps).toEqual({});
});
test('events filters: since, app, kind', () => {
const now = Date.parse(AT);
const evs = [ev({ at: '2026-10-22T09:00:00Z', app: 'Alpha', kind: 'live' }), ev({ at: '2026-10-20T09:00:00Z', app: 'Beta', kind: 'rejected' }), ev({ at: '2026-10-22T08:00:00Z', app: 'Beta', kind: 'rejected' })];
expect(filterEvents(evs, { sinceMs: durationMs('24h'), now }).map(e => e.app)).toEqual(['Alpha', 'Beta']);
expect(filterEvents(evs, { app: 'beta' })).toHaveLength(2);
expect(filterEvents(evs, { kind: ['live'] })).toHaveLength(1);
expect(filterEvents(evs, { app: 'beta', kind: ['rejected'], sinceMs: durationMs('2d'), now })).toHaveLength(1);
expect(durationMs('15', 'm')).toBe(15 * 60_000);
expect(() => durationMs('soon')).toThrow();
});
});
describe('watch sinks', () => {
const e = (o: Partial<WatchEvent> = {}): WatchEvent => ({ ...testEvent(AT), app: 'Demo App', version: '1.2.0', kind: 'rejected', detail: 'rejected', url: 'https://appstoreconnect.apple.com/apps/1', ...o });
test('sinkConfig reads env, a file, and --notify limits', () => {
expect(sinkConfig({}, null)).toEqual({});
const c = sinkConfig({ STOREBOUND_NTFY_TOPIC: 't', STOREBOUND_NTFY_URL: 'https://ntfy.example.com/', STOREBOUND_TELEGRAM_TOKEN: 'tok', STOREBOUND_TELEGRAM_CHAT: '42', STOREBOUND_WEBHOOK_URL: 'https://hook.example.com/x' }, null);
expect(c).toEqual({ ntfy: { url: 'https://ntfy.example.com', topic: 't' }, telegram: { token: 'tok', chat: '42' }, webhook: { url: 'https://hook.example.com/x' } });
expect(sinkConfig({ STOREBOUND_NTFY_TOPIC: 't' }, null).ntfy!.url).toBe('https://ntfy.sh');
expect(sinkConfig({ STOREBOUND_TELEGRAM_TOKEN: 'tok' }, null).telegram).toBeUndefined();
expect(sinkConfig({ STOREBOUND_NTFY_TOPIC: 't' }, null, ['os'])).toEqual({ os: true });
const file = join(tmp(), 'watch.json');
writeFileSync(file, JSON.stringify({ notify: ['os'], webhook: { url: 'https://h.example.com' } }));
expect(sinkConfig({}, file)).toEqual({ os: true, webhook: { url: 'https://h.example.com' } });
});
test('ntfy: headers (Title, Tags, Click, Priority) and body', () => {
const r = ntfyRequest(e(), { url: 'https://ntfy.sh', topic: 'my topic' });
expect(r.url).toBe('https://ntfy.sh/my%20topic');
expect(r.body).toBe('rejected');
expect(r.headers).toEqual({ Title: 'Demo App 1.2.0: Rejected', Tags: 'x,apple', Click: 'https://appstoreconnect.apple.com/apps/1', Priority: 'high' });
expect(ntfyRequest(e({ kind: 'live' }), { url: 'https://ntfy.sh', topic: 't', token: 'k' }).headers).toMatchObject({ Priority: 'default', Tags: 'rocket,apple', Authorization: 'Bearer k' });
expect(ntfyRequest(e({ kind: 'rollout_halted' }), { url: 'https://ntfy.sh', topic: 't' }).headers.Priority).toBe('high');
expect(ntfyRequest(e({ app: 'Café\nApp' }), { url: 'https://ntfy.sh', topic: 't' }).headers.Title).toBe('Caf? App 1.2.0: Rejected');
});
test('telegram and webhook bodies', () => {
const t = telegramRequest(e(), { token: 'TOKEN', chat: '42' });
expect(t.url).toBe('https://api.telegram.org/botTOKEN/sendMessage');
expect(JSON.parse(t.body)).toEqual({ chat_id: '42', text: 'Demo App 1.2.0: Rejected\nrejected\nhttps://appstoreconnect.apple.com/apps/1', disable_web_page_preview: true });
const w = webhookRequest(e(), { url: 'https://hook.example.com' });
expect(w.headers['Content-Type']).toBe('application/json');
expect(JSON.parse(w.body)).toEqual(e());
});
test('os command per platform keeps the text out of the script', () => {
expect(osCommand('win32', 'T', 'M')!.cmd).toBe('powershell');
expect(osCommand('win32', 'T"; evil', 'M')!.args.join(' ')).not.toContain('evil');
expect(osCommand('darwin', 'T', 'M')).toMatchObject({ cmd: 'osascript', env: { SB_TITLE: 'T', SB_MSG: 'M' } });
expect(osCommand('linux', 'T', 'M')).toMatchObject({ cmd: 'notify-send', args: ['T', 'M'] });
expect(osCommand('freebsd', 'T', 'M')).toBeNull();
});
test('post: one retry on 5xx / network error, none on 4xx', async () => {
const calls: string[] = [];
const seq = (...rs: (number | 'throw')[]): FetchLike => async url => { calls.push(url); const r = rs.shift()!; if (r === 'throw') throw new Error('down'); return { ok: r < 300, status: r }; };
const req = { url: 'https://x.example.com', headers: {}, body: '' };
expect(await post(seq(503, 200), req, { retryMs: 1 })).toBeNull();
expect(calls).toHaveLength(2);
calls.length = 0;
expect(await post(seq('throw', 'throw'), req, { retryMs: 1 })).toBe('network error');
expect(calls).toHaveLength(2);
calls.length = 0;
expect(await post(seq(400, 200), req, { retryMs: 1 })).toBe('HTTP 400');
expect(calls).toHaveLength(1);
});
test('sendEvent: every configured sink gets it with a fake fetch; a failing sink does not stop the others', async () => {
const seen: string[] = [];
const fake: FetchLike = async url => { seen.push(url); return url.includes('telegram') ? { ok: false, status: 400 } : { ok: true, status: 200 }; };
let osCalls = 0;
const rep = await sendEvent(e(), { os: true, ntfy: { url: 'https://ntfy.sh', topic: 't' }, telegram: { token: 'T', chat: '1' }, webhook: { url: 'https://hook.example.com' } },
{ fetch: fake, os: () => { osCalls++; return true; }, platform: 'linux', retryMs: 1 });
expect(rep).toEqual([{ sink: 'os', ok: true }, { sink: 'ntfy', ok: true }, { sink: 'telegram', ok: false, error: 'HTTP 400' }, { sink: 'webhook', ok: true }]);
expect(osCalls).toBe(1);
expect(seen).toEqual(['https://ntfy.sh/t', 'https://api.telegram.org/botT/sendMessage', 'https://hook.example.com']);
// an OS notifier that is not installed is skipped quietly
expect(await sendEvent(e(), { os: true }, { os: () => false, platform: 'linux' })).toEqual([]);
});
});
describe('watch and events commands', () => {
test('mcp: events_recent is a read tool that maps to events --json', () => {
const t = TOOLS.find(x => x.name === 'events_recent')!;
expect(t).toMatchObject({ write: false, network: false, command: ['events'] });
expect(toArgv(t, { since: '24h', app: 'Demo', kind: 'rejected' }).argv).toEqual(['events', '--since', '24h', '--app', 'Demo', '--kind', 'rejected']);
});
});