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 => ({ 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[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 => ({ ...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 => ({ ...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']); }); });