storebound/apps/desktop/tests/scheduler.test.ts

257 lines
14 KiB
TypeScript

// The background read scheduler (src/lib/scheduler.ts) with a fake clock: concurrency per lane, priority, dedupe,
// spacing per credential, backoff with jitter on 429, slowing down and pausing on App Store Connect's quota header;
// and the TTLs / plan of src/lib/freshness.ts, the "Copy for AI" markdown of src/lib/issues.ts.
import { describe, expect, test } from 'bun:test';
import { isStale, lanesOf, looksThrottled, oneLine, readKey, readsFor, ttlOf } from '../src/lib/freshness';
import { appleSignatureView, issueMarkdown, playIssueView } from '../src/lib/issues';
import { type Clock, type Outcome, ReadScheduler, type ReadTask } from '../src/lib/scheduler';
class FakeClock implements Clock {
t = 0;
private timers: { at: number; fn: () => void; id: number }[] = [];
private n = 0;
rand = 0.5;
now() { return this.t; }
setTimeout(fn: () => void, ms: number) { const id = ++this.n; this.timers.push({ at: this.t + ms, fn, id }); return id; }
clearTimeout(h: unknown) { this.timers = this.timers.filter(x => x.id !== h); }
random() { return this.rand; }
/** Moves time forward, firing due timers in order (and letting promise callbacks run in between). */
async advance(ms: number) {
const end = this.t + ms;
for (;;) {
await flush();
const next = this.timers.filter(x => x.at <= end).sort((a, b) => a.at - b.at)[0];
if (!next) break;
this.t = next.at;
this.timers = this.timers.filter(x => x !== next);
next.fn();
}
this.t = end;
await flush();
}
}
const flush = async () => { for (let i = 0; i < 10; i++) await Promise.resolve(); };
/** A task whose run resolves when the test says so. */
function controlled() {
const runs: { key: string; done: (v: unknown) => void; fail: (e: unknown) => void }[] = [];
const task = (key: string, o: Partial<ReadTask<unknown>> = {}): ReadTask<unknown> => ({
key, lanes: ['apple'], accounts: ['apple:k1'], priority: 2, ...o,
run: () => new Promise((done, fail) => { runs.push({ key, done, fail }); }),
});
return { runs, task };
}
describe('ReadScheduler', () => {
test('at most N at once per lane, the rest wait for a slot', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 2, play: 1 } });
const { runs, task } = controlled();
for (const k of ['a', 'b', 'c', 'd']) s.enqueue(task(k)).catch(() => {});
s.enqueue(task('p1', { lanes: ['play'], accounts: ['play:k'] })).catch(() => {});
s.enqueue(task('p2', { lanes: ['play'], accounts: ['play:k'] })).catch(() => {});
await clock.advance(0);
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1']);
expect(s.stats()).toMatchObject({ running: 3, queued: 3, total: 6 });
runs[0]!.done(1);
await clock.advance(0);
expect(runs.map(r => r.key)).toEqual(['a', 'b', 'p1', 'c']);
// a task in both lanes needs a slot in each
const both = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1, play: 1 } });
const c2 = controlled();
both.enqueue(c2.task('apple-only')).catch(() => {});
both.enqueue(c2.task('both', { lanes: ['apple', 'play'], accounts: [] })).catch(() => {});
both.enqueue(c2.task('play-only', { lanes: ['play'], accounts: [] })).catch(() => {});
await clock.advance(0);
expect(c2.runs.map(r => r.key)).toEqual(['apple-only', 'play-only']);
});
test('priority first (visible app, portfolio, the rest), FIFO within one; re-enqueueing raises it', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1 } });
const { runs, task } = controlled();
s.enqueue(task('first')).catch(() => {});
s.enqueue(task('rest-1', { priority: 2 })).catch(() => {});
s.enqueue(task('rest-2', { priority: 2 })).catch(() => {});
s.enqueue(task('portfolio', { priority: 1 })).catch(() => {});
s.enqueue(task('visible', { priority: 0 })).catch(() => {});
s.enqueue(task('rest-2', { priority: 0 })).catch(() => {}); // the owner opened that tab
await clock.advance(0);
for (let i = 0; i < 5; i++) { runs.at(-1)!.done(i); await clock.advance(0); }
expect(runs.map(r => r.key)).toEqual(['first', 'rest-2', 'visible', 'portfolio', 'rest-1']);
});
test('one in-flight read per key: the same promise, one run', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 0 });
const { runs, task } = controlled();
const p1 = s.enqueue(task('k'));
const p2 = s.enqueue(task('k'));
await clock.advance(0);
const p3 = s.enqueue(task('k'));
expect(s.has('k')).toBe(true);
expect(runs).toHaveLength(1);
runs[0]!.done('v');
expect(await Promise.all([p1, p2, p3])).toEqual(['v', 'v', 'v']);
expect(s.has('k')).toBe(false);
// finished: a new enqueue runs again
s.enqueue(task('k')).catch(() => {});
await clock.advance(0);
expect(runs).toHaveLength(2);
});
test('starts on one credential are spaced; other accounts are not held up', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 400, limits: { apple: 8 } });
const { runs, task } = controlled();
for (const k of ['a', 'b', 'c']) s.enqueue(task(k)).catch(() => {});
s.enqueue(task('other', { accounts: ['apple:k2'] })).catch(() => {});
await clock.advance(0);
expect(runs.map(r => r.key)).toEqual(['a', 'other']);
await clock.advance(399);
expect(runs).toHaveLength(2);
await clock.advance(1);
expect(runs.map(r => r.key)).toEqual(['a', 'other', 'b']);
await clock.advance(400);
expect(runs.map(r => r.key)).toEqual(['a', 'other', 'b', 'c']);
});
test('429: runs again later with exponential backoff and jitter, the key waits too; gives up after maxAttempts', async () => {
const clock = new FakeClock();
let attempts = 0;
const classify = (r: unknown): Outcome => ({ throttled: (r as { status: number }).status === 429 });
const s = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 1000, backoffMaxMs: 60_000, maxAttempts: 4, classify });
const starts: number[] = [];
const p = s.enqueue({ key: 'x', lanes: ['play'], accounts: ['play:k'], priority: 0, run: async () => { starts.push(clock.now()); attempts++; return { status: attempts < 3 ? 429 : 200 }; } });
let other = false;
await clock.advance(0);
s.enqueue({ key: 'y', lanes: ['play'], accounts: ['play:k'], priority: 2, run: async () => { other = true; return { status: 200 }; } }).catch(() => {});
await clock.advance(0);
// first try at 0; jitter (rand 0.5) -> 0.75 x the exponential step: 750 ms, then 1500 ms
expect(starts).toEqual([0]);
expect(s.stats().paused['play:k']).toBe(750);
await clock.advance(749);
expect(starts).toEqual([0]);
expect(other).toBe(false); // the throttled key holds back its other reads as well
await clock.advance(1);
expect(starts).toEqual([0, 750]);
await clock.advance(1500);
expect(starts).toEqual([0, 750, 2250]);
expect(await p).toEqual({ status: 200 });
await clock.advance(0);
expect(other).toBe(true);
// always 429: resolved with the last answer after maxAttempts, counted as failed
const s2 = new ReadScheduler({ clock, spacingMs: 0, backoffBaseMs: 10, maxAttempts: 3, classify });
let n = 0;
const q = s2.enqueue({ key: 'z', lanes: ['play'], accounts: ['play:k'], priority: 0, run: async () => { n++; return { status: 429 }; } });
await clock.advance(1000);
expect(await q).toEqual({ status: 429 });
expect(n).toBe(3);
});
test('App Store Connect quota: spreads the rest of the hour when it runs low, pauses at the reserve', async () => {
const clock = new FakeClock();
let remaining = 3412;
const classify = (_r: unknown, _e: unknown, t: ReadTask<unknown>): Outcome => ({ quota: { credential: t.accounts[0]!, limit: 3600, remaining } });
const s = new ReadScheduler({ clock, spacingMs: 100, reserve: 100, slowBelow: 0.2, pauseMs: 600_000, limits: { apple: 4 }, classify });
const starts: number[] = [];
const run = async () => { starts.push(clock.now()); return {}; };
const add = (k: string) => s.enqueue({ key: k, lanes: ['apple'], accounts: ['apple:k1'], priority: 2, run }).catch(() => {});
add('a'); add('b');
await clock.advance(0);
await clock.advance(100);
expect(starts).toEqual([0, 100]); // plenty left: the base spacing
expect(s.stats().quota['apple:k1']).toMatchObject({ limit: 3600, remaining: 3412 });
remaining = 500; // under 20 %: (500 - 100 reserve) left for the hour -> one start every 9 s
add('c'); add('d');
await clock.advance(100);
const c = starts.at(-1)!;
await clock.advance(8_000);
expect(starts.at(-1)).toBe(c);
await clock.advance(1_100);
expect(starts.at(-1)! - c).toBe(9_000);
remaining = 80; // at the reserve: the key pauses for 10 minutes
add('e'); add('f');
await clock.advance(9_000);
const e = starts.at(-1)!;
await clock.advance(599_000);
expect(starts.at(-1)).toBe(e);
expect(Object.keys(s.stats().paused)).toEqual(['apple:k1']);
await clock.advance(2_000);
expect(starts.at(-1)! - e).toBe(600_000);
// the quota a run reported stays the lowest of the hour, and is forgotten after it
remaining = 3000;
add('g');
await clock.advance(600_000);
expect(s.stats().quota['apple:k1']!.remaining).toBe(80);
await clock.advance(3_600_000);
expect(s.stats().quota['apple:k1']).toBeUndefined();
});
test('progress for the status line: done of total in one round, failures, clear()', async () => {
const clock = new FakeClock();
const s = new ReadScheduler({ clock, spacingMs: 0, limits: { apple: 1 } });
const { runs, task } = controlled();
const ps = ['a', 'b', 'c'].map(k => s.enqueue(task(k)).catch(e => String(e)));
await clock.advance(0);
runs[0]!.done(1);
await clock.advance(0);
expect(s.stats()).toMatchObject({ done: 1, running: 1, queued: 1, total: 3 });
runs[1]!.fail(new Error('boom'));
await clock.advance(0);
expect(s.stats()).toMatchObject({ done: 2, failed: 1, total: 3 });
s.clear();
runs[2]?.done(1);
await clock.advance(0);
expect(await ps[1]).toBe('Error: boom');
expect(s.stats()).toMatchObject({ done: 0, total: 0, running: 0, queued: 0 });
});
});
describe('freshness', () => {
test('TTLs: status 5 min, reviews 15 min, catalog / prices / listings 30 min, health / analytics 6 h', () => {
expect([ttlOf('apple.status'), ttlOf('play.status'), ttlOf('reviews.list'), ttlOf('catalog.diff'), ttlOf('prices.current'), ttlOf('project.listings'), ttlOf('health'), ttlOf('analytics.compare')].map(x => x / 60_000))
.toEqual([5, 5, 15, 30, 30, 30, 360, 360]);
const now = Date.parse('2026-10-13T12:00:00Z');
expect(isStale('apple.status', '2026-10-13T11:56:00Z', now)).toBe(false);
expect(isStale('apple.status', '2026-10-13T11:55:00Z', now)).toBe(true);
expect(isStale('health', '2026-10-13T07:00:00Z', now)).toBe(false);
expect(isStale('health', null, now)).toBe(true);
expect(isStale('health', 'garbage', now)).toBe(true);
});
test('lanes, the reads of an app, keys, throttling text, one-line reasons', () => {
expect(lanesOf('apple.status', { ios: true, android: true })).toEqual(['apple']);
expect(lanesOf('health', { ios: true, android: true })).toEqual(['apple', 'play']);
expect(lanesOf('health', { ios: false, android: true })).toEqual(['play']);
expect(lanesOf('scan', { ios: true, android: true })).toEqual(['local']);
const all = readsFor({ folder: true, ios: true, android: true }).map(r => r.method);
expect(all).toEqual(expect.arrayContaining(['apple.status', 'play.status', 'apple.testflight_status', 'reviews.list', 'health', 'catalog.diff', 'prices.current', 'project.listings', 'validate', 'scan', 'analytics.compare', 'aso.audit', 'apple.ppo_status']));
expect(readsFor({ folder: false, ios: false, android: true }).map(r => r.method)).toEqual(['play.status', 'reviews.list', 'health']);
expect(readKey('p1', 'reviews.list', { days: 30 })).toBe(readKey('p1', 'reviews.list', { days: 30 }));
expect(readKey('p1', 'reviews.list', { days: 30 })).not.toBe(readKey('p1', 'reviews.list', {}));
expect(looksThrottled('GET /v1/apps -> 429 Too Many Requests')).toBe(true);
expect(looksThrottled('{"error":{"status":"RESOURCE_EXHAUSTED"}}')).toBe(true);
expect(looksThrottled('-> 403 forbidden')).toBe(false);
expect(oneLine('12:00:01 [health] reading\n12:00:02 [health] error: key not found\n')).toBe('error: key not found');
});
});
describe('issue detail', () => {
test('Copy for AI: title, type, counts, versions, top frames, console link', () => {
const v = playIssueView({ type: 'CRASH', cause: 'java.lang.IllegalStateException', location: 'com.example.Combo.update', reports: 412, users: 268, firstVersion: '224', lastVersion: '231', id: 'a1', uri: 'https://play.google.com/console/x' });
expect(v.sampleArgs).toEqual({ issue: 'a1' });
const stack = Array.from({ length: 20 }, (_, i) => `\tat com.example.F${i}(F.kt:${i})`);
const md = issueMarkdown('Prism Puddle', v, { store: 'play', version: '231', device: 'Galaxy A12', os: 'API 31', stack });
expect(md).toContain('## Google Play CRASH: java.lang.IllegalStateException');
expect(md).toContain('- Reports: 412, users: 268');
expect(md).toContain('- versionCode: 224 to 231');
expect(md).toContain('F14(F.kt:14)');
expect(md).not.toContain('F15(F.kt:15)');
expect(md).toContain('... 5 more frames');
expect(md).toContain('Console: https://play.google.com/console/x');
const a = appleSignatureView({ build: '48', type: 'HANGS', signature: 'ComboMeter.update + 212', weight: 0.31, id: 's1' });
expect(a.sampleArgs).toEqual({ signature: 's1' });
expect(issueMarkdown('X', a, null)).toContain('- Share of reports: 31 %');
expect(playIssueView({ type: 'CRASH', cause: '', location: '', reports: 1, users: 1 }).sampleArgs).toBeNull();
});
});