test/schedule-status.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from 'vitest';
import { execFileSync } from 'node:child_process';
import { scheduleStatus } from '../lib/schedule-status.js';
import { nextCronIso } from '../lib/cron.js';
const now = Date.parse('2026-10-01T12:10:00Z');
const at = offset => new Date(now + offset).toISOString();
const schedule = (over = {}) => ({ id: 'schedule', type: 'scheduled', state: 'todo', schedule: '@hourly', postedBy: 'owner', repoPath: '/repo', nextRunAt: at(50 * 60000), ...over });
const run = (over = {}) => ({ id: 'original', scheduleId: 'schedule', postedBy: 'owner', repoPath: '/repo', state: 'in-progress', agentSessionId: 'shadow', ...over });
const status = (s = schedule(), jobs = [], sessions = new Map(), opts = {}) => scheduleStatus(s, jobs, sessions, { now, ...opts });
describe('passive schedule status', () => {
it('separates future, due within configured interval and overdue posting', () => {
expect(status().code).toBe('scheduled');
const s = schedule({ nextRunAt: at(-60000) });
expect(status(s, [], new Map(), { intervalMs: 120000 }).code).toBe('due');
expect(status(s, [], new Map(), { intervalMs: 30000 })).toMatchObject({ code: 'overdue', graceMs: 30000 });
expect(status(schedule({ nextRunAt: at(-120000) }), [], new Map(), { intervalMs: 120000 }).code).toBe('due');
});
it('uses existing active, waiting, input and gone semantics without declaring a missed firing', () => {
const s = schedule({ nextRunAt: at(-3600000) });
const r = run();
for (const [session, code] of [
[{ state: 'WORKING', lastOutputAt: now - 999999 }, 'held'],
[{ state: 'WAITING', lastOutputAt: now }, 'held'],
[{ state: 'WAITING', lastOutputAt: now - 180001 }, 'held-stalled'],
[{ state: 'MESSAGE' }, 'held-needs-input'],
[{ exited: true }, 'held-gone'],
]) expect(status(s, [r], new Map([['shadow', session]])).code).toBe(code);
expect(status(s, [r])).toMatchObject({ code: 'held-gone', blocker: { id: 'original', status: 'gone' } });
});
it('standalone recovery and a newer completed run cannot erase the original blocker', () => {
const jobs = [run(), run({ id: 'recovery', scheduleId: null, state: 'done' }), run({ id: 'newer', state: 'done' })];
expect(status(schedule({ lastRunJobId: 'newer' }), jobs)).toMatchObject({ code: 'held-gone', blocker: { id: 'original' }, lastRun: { id: 'newer', state: 'done' } });
});
it('a queued/cap-held run has already fired; PR review holds, summary review does not', () => {
const s = schedule({ lastRunAt: at(-3600000), lastRunJobId: 'original' });
expect(status(s, [run({ state: 'todo', lastError: 'capacity' })])).toMatchObject({ code: 'held', lastRun: { state: 'todo', startedAt: null } });
expect(status(s, [run({ state: 'review', requiresPr: true })]).label).toBe('Held: prior PR review');
expect(status(s, [run({ state: 'review', requiresPr: false })]).code).toBe('scheduled');
});
it('keeps paused, dispatcher stopped, held, spent once and archived schedules quiet', () => {
const s = schedule({ nextRunAt: at(-3600000) });
expect(status({ ...s, paused: true }, [run()]).code).toBe('paused');
expect(status(s, [run()], new Map(), { running: false }).code).toBe('stopped');
expect(status({ ...s, holdUntil: at(60000) }).code).toBe('held');
expect(status({ ...s, once: true, runCount: 1 }, [run()]).code).toBe('held-gone');
expect(status({ ...s, state: 'done' }, [run()])).toMatchObject({ code: 'archived', attention: false, blocker: null });
expect(status({ ...s, once: true }).code).toBe('overdue');
expect(status(schedule({ once: true })).code).toBe('scheduled');
});
it('does not invent historical firings from legacy skips or an unavailable due cursor', () => {
expect(status(schedule({ lastSkipAt: at(-60000), lastSkipReason: 'busy' }))).toMatchObject({ lastExpectedAt: null, observedAt: null });
for (const nextRunAt of [null, 'bad date']) expect(status(schedule({ nextRunAt })).code).toBe('unknown');
expect(status(schedule({ schedule: '0 0 30 2 *', nextRunAt: null })).code).toBe('unschedulable');
expect(status(schedule({ schedule: 'invalid', nextRunAt: null })).code).toBe('unschedulable');
});
it('keeps delayed-loop evidence factual and marks failed posting separately', () => {
const lastScheduleObservation = { cron: '@hourly', expectedAt: at(-3600000), observedAt: at(-60000), outcome: 'posted' };
expect(status(schedule({ lastScheduleObservation }))).toMatchObject({ code: 'posted-late', lastExpectedAt: at(-3600000), observedAt: at(-60000), delayedByMs: 3540000 });
expect(status(schedule({ lastScheduleObservation: { ...lastScheduleObservation, outcome: 'error' } })).code).toBe('post-error');
expect(status(schedule({ lastScheduleObservation: { ...lastScheduleObservation, outcome: 'held' } })).code).toBe('held-previously');
expect(status(schedule({ lastScheduleObservation: { ...lastScheduleObservation, cron: '@daily' } }))).toMatchObject({ code: 'scheduled', lastExpectedAt: null });
expect(status(schedule({ lastScheduleObservation: { ...lastScheduleObservation, observedAt: at(60000) } })).code).toBe('clock');
expect(status(schedule({ lastRunAt: at(60000) })).code).toBe('clock');
});
it('never exposes a foreign owner or repo link and never mutates cards', () => {
const s = schedule({ lastRunJobId: 'original' });
const jobs = [run({ postedBy: 'other' }), run({ id: 'foreign', repoPath: '/other' })];
const before = JSON.stringify([s, jobs]);
expect(status(s, jobs)).toMatchObject({ blocker: null, lastRun: null });
expect(JSON.stringify([s, jobs])).toBe(before);
expect(status({ type: 'one-time' })).toBeNull();
});
it('uses the scheduler timezone and existing cron DST behavior, not the browser timezone', () => {
const script = `import { nextCronIso } from './lib/cron.js';
import { scheduleStatus } from './lib/schedule-status.js';
const now = Date.parse('2026-03-08T09:59:00Z');
const due = nextCronIso('30 2 * * *', now);
const s = {type:'scheduled',state:'todo',schedule:'30 2 * * *',nextRunAt:due};
console.log(JSON.stringify({due,status:scheduleStatus(s,[],new Map(),{now})}));`;
const result = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e', script], { cwd: process.cwd(), env: { ...process.env, TZ: 'America/Los_Angeles' }, encoding: 'utf8' }));
expect(result.due).toBe('2026-03-09T09:30:00.000Z');
expect(result.status).toMatchObject({ code: 'scheduled', timeZone: 'America/Los_Angeles' });
const fall = script.replaceAll('2026-03-08T09:59:00Z', '2026-11-01T07:59:00Z').replaceAll('30 2 * * *', '30 1 * * *');
const fallResult = JSON.parse(execFileSync(process.execPath, ['--input-type=module', '-e', fall], { env: { ...process.env, TZ: 'America/Los_Angeles' }, encoding: 'utf8' }));
expect(fallResult.due).toBe('2026-11-01T08:30:00.000Z');
expect(fallResult.status.code).toBe('scheduled');
const leap = nextCronIso('0 0 29 2 *', Date.parse('2025-03-01T00:00:00Z'));
expect(status(schedule({ nextRunAt: leap })).code).toBe('scheduled');
});
});