import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createExecutionContext, createScheduledController, env, runDurableObjectAlarm, runInDurableObject, } from 'cloudflare:test'; import worker from './src/index.mjs'; import { nextRunAt, GRACE_MS, HOUR_MS } from './src/schedule.mjs'; import { fakeSplatNet, setSessionEnvironment } from './fakeSplatNet.mjs'; function stub() { return env.SCHEDULER.get(env.SCHEDULER.idFromName(`test-${crypto.randomUUID()}`)); } async function runAlarmUntil(scheduler, done) { await runDurableObjectAlarm(scheduler); await vi.waitFor(async () => expect(done(await scheduler.status())).toBe(true), { timeout: 5000 }); return scheduler.status(); } function network({ down = false, renderFails = false, beforeRequest } = {}) { let splatnet = fakeSplatNet(); let renders = []; vi.spyOn(env.BROWSER, 'quickAction').mockImplementation(async (action, options) => { renders.push(options); return new Response(renderFails ? 'render failed' : new Uint8Array([1, 2]), { status: renderFails ? 503 : 200, }); }); vi.stubGlobal('fetch', async (input, init) => { const url = new URL(input); if (url.hostname === 'site.test') { let object = await env.ASSETS.get(url.pathname.slice(1)); return object ? new Response(object.body) : new Response('missing', { status: 404 }); } await beforeRequest?.(); return down ? new Response('down', { status: 503 }) : splatnet(input, init); }); return renders; } beforeEach(() => { setSessionEnvironment(); process.env.SITE_URL = 'https://site.test'; for (let name of ['BLUESKY_SERVICE', 'BLUESKY_IDENTIFIER', 'BLUESKY_PASSWORD']) delete process.env[name]; }); afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); vi.useRealTimers(); }); describe('Scheduler', () => { it('arms the hourly job for :00:10 and preserves an existing schedule', async () => { let scheduler = stub(); let before = Date.now(); let first = await scheduler.ensureArmed(); expect(first.armed).toBe(true); expect(first.hourlyAt).toBeGreaterThanOrEqual(nextRunAt(before)); expect((first.hourlyAt - GRACE_MS) % HOUR_MS).toBe(0); expect(await scheduler.ensureArmed()).toEqual({ ...first, armed: false }); }); it('persists a pause through watchdog calls and only resumes explicitly', async () => { network(); let scheduler = stub(); await scheduler.ensureArmed(); expect(await scheduler.pause()).toEqual({ ok: true, paused: true }); expect(await scheduler.ensureArmed()).toEqual({ armed: false, paused: true }); expect((await scheduler.status()).alarmAt).toBeNull(); expect(await scheduler.run()).toMatchObject({ ok: false, paused: true }); await scheduler.resume(); expect((await scheduler.status()).alarmAt).not.toBeNull(); expect((await scheduler.status()).paused).toBe(false); }); it('repairs a missing alarm without moving its due time', async () => { let scheduler = stub(); let initial = await scheduler.ensureArmed(); await runInDurableObject(scheduler, async (instance, state) => state.storage.deleteAlarm()); expect(await scheduler.ensureArmed()).toEqual({ ...initial, armed: true }); }); it('converts pending requests from the previous deployment into a full run', async () => { network(); let scheduler = stub(); let hourlyAt = nextRunAt(); await runInDurableObject(scheduler, async (instance, state) => { await state.storage.put('state', { hourlyAt, pending: ['updaters', 'posters'] }); }); await scheduler.ensureArmed(); let status = await runAlarmUntil(scheduler, s => s.lastRun !== null); expect(status.lastRun.ok).toBe(true); expect(status.hourlyAt).toBe(hourlyAt); expect(status.retryAt).toBeNull(); }); it('runs a full hourly pipeline, records completion timing, and schedules the next hour', async () => { network(); let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 1); let status = await runAlarmUntil(scheduler, s => s.hourlyAt !== hourlyAt); expect(status.lastRun.ok).toBe(true); expect(status.lastRun.updaters.updaters).toHaveLength(8); expect(status.lastRun.social.ok).toBe(true); expect(status.lastRun.driftMs).toBeGreaterThanOrEqual(1); expect(status.alarmAt).toBe(hourlyAt + HOUR_MS); }); it('skips social after updater failure and gives up after three retries', async () => { let renders = network({ down: true }); let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 5); let status = await runAlarmUntil(scheduler, s => s.retries === 1); expect(status.lastRun.updaters.ok).toBe(false); expect(status.lastRun.social).toMatchObject({ skipped: true, reason: 'updater-failed' }); expect(renders).toEqual([]); for (let attempt = 2; attempt <= 3; attempt++) { vi.setSystemTime(status.retryAt); status = await runAlarmUntil(scheduler, s => s.retries === attempt); } vi.setSystemTime(status.retryAt); status = await runAlarmUntil(scheduler, s => s.retryAt === null); expect(status.runHistory).toHaveLength(4); expect(status.runHistory.every(run => run.trigger === 'scheduled' && !run.ok)).toBe(true); expect(status.retries).toBe(0); expect(status.alarmAt).toBe(hourlyAt + HOUR_MS); }); it('retries screenshot failures instead of reporting a successful social run', async () => { network({ renderFails: true }); let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 1); let status = await runAlarmUntil(scheduler, s => s.retries === 1); expect(status.lastRun.updaters.ok).toBe(true); expect(status.lastRun.social.ok).toBe(false); expect(status.lastRun.social.posts.some(post => post.ok === false)).toBe(true); }); it('manual targeted repairs do not post or move the hourly schedule', async () => { let renders = network(); let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); let result = await scheduler.run({ only: ['Schedules'] }); expect(result.ok).toBe(true); expect(result.updaters.updaters.map(u => u.name)).toEqual(['Schedules']); expect(result.social.reason).toBe('targeted-update'); expect(renders).toEqual([]); expect((await scheduler.status()).hourlyAt).toBe(hourlyAt); expect((await scheduler.run({ only: ['typo'] })).ok).toBe(false); expect((await scheduler.status()).busy).toBe(false); }); it('rejects overlapping manual work explicitly and preserves an hourly alarm due during it', async () => { let release; let gate = new Promise(resolve => { release = resolve; }); let entered = false; network({ beforeRequest: async () => { entered = true; await gate; }, }); let scheduler = stub(); let { hourlyAt } = await scheduler.ensureArmed(); let first = scheduler.run({ only: ['Schedules'] }); try { await vi.waitFor(() => expect(entered).toBe(true)); expect( (await scheduler.status()).activeRun.logs.lines.some(line => line.text.includes('Updating data')), ).toBe(true); expect(await scheduler.run()).toMatchObject({ ok: false, busy: true }); expect(await scheduler.ensureArmed()).toMatchObject({ busy: true }); expect(await scheduler.pause()).toMatchObject({ ok: false, busy: true }); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 1); await runDurableObjectAlarm(scheduler); let status = await scheduler.status(); expect(status.hourlyAt).toBe(hourlyAt); expect(status.alarmAt).not.toBeNull(); } finally { release(); } await first; let status = await runAlarmUntil(scheduler, s => s.lastRun !== null); expect(status.lastRun.updaters.ok).toBe(true); expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS); }); }); describe('Background manual runs', () => { it('persists a request, rejects overlap, and runs data without posting', async () => { let release; const gate = new Promise(resolve => { release = resolve; }); const renders = network({ beforeRequest: () => gate }); const scheduler = stub(); const { hourlyAt } = await scheduler.ensureArmed(); const result = await scheduler.startManual('data'); expect(result).toMatchObject({ ok: true, run: { mode: 'data', status: 'queued' } }); try { expect((await scheduler.status()).pendingManual.id).toBe(result.run.id); expect(await scheduler.startManual('social')).toMatchObject({ ok: false, busy: true }); expect(await scheduler.run()).toMatchObject({ ok: false, busy: true }); expect(await scheduler.pause()).toMatchObject({ ok: false, busy: true }); } finally { release(); } const status = await runAlarmUntil(scheduler, s => !!s.lastManualRun); expect(status.lastManualRun).toMatchObject({ id: result.run.id, ok: true, status: 'succeeded', social: { skipped: true }, }); expect(status.pendingManual).toBeNull(); expect(status.activeRun).toBeNull(); expect(status.lastManualRun.logs.lines.some(line => line.text.includes('Done.'))).toBe(true); expect(status.hourlyAt).toBe(hourlyAt); expect(renders).toHaveLength(0); }); it('runs social from the published data without invoking SplatNet again', async () => { network(); const scheduler = stub(); await scheduler.startManual('data'); await runAlarmUntil(scheduler, s => !!s.lastManualRun); const requests = vi.fn(); const renders = network({ beforeRequest: requests }); const { run } = await scheduler.startManual('social'); const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === run.id); expect(status.lastManualRun).toMatchObject({ ok: true, updaters: { skipped: true }, social: { ok: true }, }); expect(requests).not.toHaveBeenCalled(); expect(renders.length).toBeGreaterThan(0); }); it('reports an interrupted run instead of automatically replaying an uncertain post', async () => { const scheduler = stub(); const renders = network(); await scheduler.ensureArmed(); await runInDurableObject(scheduler, async (instance, state) => { await state.storage.put('pendingManual', { id: 'interrupted', mode: 'social', status: 'running', startedAt: Date.now() - 1000, }); }); await scheduler.ensureArmed(); const status = await runAlarmUntil(scheduler, s => !!s.lastManualRun); expect(status.lastManualRun).toMatchObject({ id: 'interrupted', ok: false, status: 'failed' }); expect(status.lastManualRun.error).toContain('interrupted'); expect(renders).toHaveLength(0); expect(status.pendingManual).toBeNull(); }); it('still performs an hourly cycle that became due alongside a manual request', async () => { network(); const scheduler = stub(); const { hourlyAt } = await scheduler.ensureArmed(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(hourlyAt + 1); await runInDurableObject(scheduler, async (instance, state) => { await state.storage.put('pendingManual', { id: 'due', mode: 'data', status: 'queued', requestedAt: Date.now(), }); }); const status = await runAlarmUntil(scheduler, s => !!s.lastRun); expect(status.lastManualRun).toMatchObject({ id: 'due', ok: true, mode: 'data' }); expect(status.lastRun).toMatchObject({ ok: true, mode: 'both', social: { ok: true } }); expect(status.runHistory.map(run => run.trigger)).toEqual(['scheduled', 'manual']); expect(status.hourlyAt).toBe(hourlyAt + HOUR_MS); }); it('preserves older results when starting a combined run history', async () => { const scheduler = stub(); const now = Date.now(); await runInDurableObject(scheduler, async (_instance, ctx) => { await ctx.storage.put('state', { automaticSchedulingEnabled: false, lastRun: { startedAt: now - 2000, ok: false, mode: 'both', }, }); await ctx.storage.put('lastManualRun', { startedAt: now - 1000, ok: true, mode: 'data', }); await ctx.storage.put('pendingManual', { id: 'interrupted', status: 'running', mode: 'social', startedAt: now, }); }); await scheduler.ensureArmed(); expect((await scheduler.status()).runHistory.map(run => run.trigger)) .toEqual(['manual', 'scheduled']); const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === 'interrupted'); expect(status.runHistory.map(run => [run.trigger, run.ok])).toEqual([ ['manual', false], ['manual', true], ['scheduled', false], ]); expect((await scheduler.status()).runHistory).toEqual(status.runHistory); }); it('retains only the newest 50 runs, including their logs', async () => { const scheduler = stub(); const now = Date.now(); const logs = { lines: [{ text: 'x'.repeat(30000) }], omitted: 0 }; await runInDurableObject(scheduler, async (_instance, ctx) => { await ctx.storage.put('state', { automaticSchedulingEnabled: false }); for (let i = 0; i < 50; i++) { const startedAt = now - 1000 + i; await ctx.storage.put(`run:${String(startedAt).padStart(15, '0')}:${i}`, { id: String(i), startedAt, trigger: 'scheduled', ok: true, logs, }); } await ctx.storage.put('pendingManual', { id: 'latest', status: 'running', mode: 'data', startedAt: now, }); }); await scheduler.ensureArmed(); const status = await runAlarmUntil(scheduler, s => s.lastManualRun?.id === 'latest'); expect(status.runHistory).toHaveLength(50); expect(status.runHistory[0].id).toBe('latest'); expect(status.runHistory.at(-1).id).toBe('1'); expect(status.runHistory[1].logs).toEqual(logs); await runInDurableObject(scheduler, async (_instance, ctx) => { expect((await ctx.storage.list({ prefix: 'run:' })).size).toBe(50); }); }); it('rejects invalid modes and paused scheduling', async () => { const scheduler = stub(); expect(await scheduler.startManual('anything')).toMatchObject({ ok: false }); await scheduler.pause(); expect(await scheduler.startManual('both')).toMatchObject({ ok: false, paused: true }); }); }); describe('Worker routing', () => { it('cron only calls the watchdog', async () => { const ensureArmed = vi.fn(async () => ({ armed: true })); const fakeEnv = { SCHEDULER: { idFromName: () => 'id', get: () => ({ ensureArmed }) } }; await worker.scheduled( createScheduledController({ cron: '30 * * * *' }), fakeEnv, createExecutionContext(), ); expect(ensureArmed).toHaveBeenCalledOnce(); }); it('authenticates manual runs and sends them through the DO with busy/failure HTTP statuses', async () => { const run = vi.fn(async () => ({ ok: false, busy: true })); const fakeEnv = { RUN_TOKEN: 'test-token', SCHEDULER: { idFromName: () => 'id', get: () => ({ run }) } }; const request = headers => new Request('https://worker.test/run?only=Schedules,Timeline', { method: 'POST', headers }); expect((await worker.fetch(request(), fakeEnv, createExecutionContext())).status).toBe(401); expect(run).not.toHaveBeenCalled(); const headers = { Authorization: 'Bearer test-token' }; expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(409); expect(run).toHaveBeenCalledWith({ only: ['Schedules', 'Timeline'] }); run.mockResolvedValue({ ok: false }); expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(502); run.mockResolvedValue({ ok: true }); expect((await worker.fetch(request(headers), fakeEnv, createExecutionContext())).status).toBe(200); }); }); it('keeps manual runs available with scheduling off and does not replay missed hours', async () => { network(); let scheduler = stub(); await scheduler.ensureArmed(); await scheduler.setAutomaticScheduling(false); expect(await scheduler.ensureArmed()).toEqual({ armed: false, automaticSchedulingEnabled: false }); expect(await scheduler.status()).toMatchObject({ automaticSchedulingEnabled: false, alarmAt: null, hourlyAt: null, retryAt: null, }); let manual = await scheduler.startManual('data'); expect(manual.ok).toBe(true); expect(await scheduler.setAutomaticScheduling(true)).toMatchObject({ busy: true }); let status = await runAlarmUntil(scheduler, s => s.lastManualRun !== null); expect(status.lastManualRun.ok).toBe(true); expect(status.lastRun).toBeNull(); expect(status.alarmAt).toBeNull(); expect(status.automaticSchedulingEnabled).toBe(false); // A stale platform alarm must not run automatic work after disabling it. await runInDurableObject(scheduler, instance => instance.alarm()); expect((await scheduler.status()).lastRun).toBeNull(); expect((await scheduler.run({ only: ['Schedules'] })).ok).toBe(true); expect((await scheduler.status()).alarmAt).toBeNull(); await scheduler.setAutomaticScheduling(true); status = await scheduler.status(); expect(status.hourlyAt).toBe(nextRunAt()); expect(status.alarmAt).toBe(status.hourlyAt); expect(status.lastRun).toBeNull(); });