test/jobs-scheduled.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect } from 'vitest';
import {
createJob, resolveJobType, jobType, isScheduled, isJobDue, selectDispatchableJobs,
buildJobPrompt, scheduleHold, supersededRuns, createRunJob, runsToPrune, parseClosedPr,
JOB_TYPES, DEFAULT_JOB_TYPE,
} from '../lib/jobs.js';
import { nextCronIso } from '../lib/cron.js';
const REPO = '/repos/alpha';
const make = (over = {}) => {
const { job, error } = createJob({ title: 'A job', repoPath: REPO, ...over });
if (error) throw new Error(error);
return job;
};
// --- Type resolution ---
describe('job types', () => {
it('defaults to one-time, and treats a card written before types existed as one', () => {
expect(make().type).toBe(DEFAULT_JOB_TYPE);
expect(jobType({ title: 'legacy' })).toBe('one-time');
expect(isScheduled({ title: 'legacy' })).toBe(false);
expect(JOB_TYPES).toEqual(['one-time', 'scheduled']);
});
it('makes a card scheduled when it is given a schedule and no type', () => {
const job = make({ schedule: '0 9 * * 1-5' });
expect(job.type).toBe('scheduled');
expect(job.schedule).toBe('0 9 * * 1-5');
expect(Date.parse(job.nextRunAt)).not.toBeNaN();
expect(job.runCount).toBe(0);
expect(job.lastRunAt).toBeNull();
});
it('gives a one-time card no schedule fields to go stale', () => {
const job = make();
expect(job.schedule).toBeNull();
expect(job.nextRunAt).toBeNull();
});
it('refuses a scheduled card whose cron does not parse, with the parser reason', () => {
expect(createJob({ title: 'x', repoPath: REPO, type: 'scheduled' }).error).toMatch(/needs a cron schedule/i);
expect(createJob({ title: 'x', repoPath: REPO, schedule: 'every friday' }).error).toMatch(/five fields/i);
expect(createJob({ title: 'x', repoPath: REPO, type: 'weekly' }).error).toMatch(/unknown job type/i);
});
it('drops a schedule from a one-time card rather than refusing the save', () => {
// The edit path: switching the form's type back leaves the cron text in the
// box, and failing that save would be baffling.
expect(resolveJobType({ type: 'one-time', schedule: '0 9 * * *' })).toEqual({ type: 'one-time', schedule: null });
});
it('stores a cron that can never fire, but with no next run time', () => {
// "30 February" parses; it just never comes round. Refusing it would mean
// the parser having to know about calendars, and the card says so instead.
const job = make({ schedule: '0 0 30 2 *' });
expect(job.type).toBe('scheduled');
expect(job.nextRunAt).toBeNull();
});
});
// --- Due-ness and dispatch selection ---
describe('isJobDue', () => {
const now = Date.parse('2026-06-10T12:00:00Z');
it('always says yes to a one-time job — being in To do is the whole condition', () => {
expect(isJobDue(make(), now)).toBe(true);
});
it('holds a scheduled job back until its next run time arrives', () => {
const job = { ...make({ schedule: '0 9 * * *' }), nextRunAt: new Date(now + 60_000).toISOString() };
expect(isJobDue(job, now)).toBe(false);
expect(isJobDue(job, now + 60_000)).toBe(true);
});
it('treats a missing or unreadable next run time as due, never as stuck forever', () => {
const job = make({ schedule: '0 9 * * *' });
expect(isJobDue({ ...job, nextRunAt: null }, now)).toBe(true);
expect(isJobDue({ ...job, nextRunAt: 'not a date' }, now)).toBe(true);
});
it('holds a paused card, scheduled or not', () => {
// The guard sits above the scheduled check on purpose: isJobDue is the one
// gate every dispatch route passes through, so a paused card of any kind
// stays put without a second check at each door.
expect(isJobDue({ ...make(), paused: true }, now)).toBe(false);
expect(isJobDue({ title: 'one-time', paused: true }, now)).toBe(false);
expect(isJobDue({ title: 'one-time', paused: false }, now)).toBe(true);
});
});
describe('selectDispatchableJobs with schedules', () => {
const now = Date.parse('2026-06-10T12:00:00Z');
it('never dispatches a schedule itself, due or not — it is fired instead', () => {
const due = { ...make({ schedule: '0 9 * * *' }), nextRunAt: new Date(now - 1000).toISOString() };
expect(selectDispatchableJobs([due], { now })).toEqual([]);
});
it('holds a run to the per-repo cap like any other card', () => {
const busy = { ...make(), id: 'one', state: 'in-progress', agentSessionId: 's1' };
const run = { ...make(), id: 'run', scheduleId: 'sched' };
expect(selectDispatchableJobs([busy, run], { now, maxPerRepo: 1, liveSessionIds: new Set(['s1']) })).toEqual([]);
});
});
describe('scheduleHold', () => {
const schedule = { ...make({ schedule: '@hourly' }), id: 'sched' };
const run = (over) => ({ ...make(), scheduleId: 'sched', ...over });
it('fires when the schedule has no unfinished run', () => {
expect(scheduleHold(schedule, [schedule])).toBeNull();
expect(scheduleHold(schedule, [schedule, run({ state: 'done' })])).toBeNull();
});
it('holds off while the previous run is still queued or going', () => {
expect(scheduleHold(schedule, [run({ state: 'todo' })])).toMatch(/has not started yet/);
expect(scheduleHold(schedule, [run({ state: 'todo', lastError: 'repo gone' })])).toMatch(/could not start: repo gone/);
expect(scheduleHold(schedule, [run({ state: 'in-progress' })])).toMatch(/still going/);
});
it('holds off while a PR run waits in Review, naming the PR', () => {
expect(scheduleHold(schedule, [run({ state: 'review', requiresPr: true, prNumber: 7 })])).toMatch(/PR #7/);
});
it('holds off on a PR run in Review whose PR number is not known yet', () => {
expect(scheduleHold(schedule, [run({ state: 'review', requiresPr: true, prNumber: null })])).toMatch(/previous run's pull request/);
});
it('fires past a no-PR run in Review — the next run supersedes it', () => {
expect(scheduleHold(schedule, [run({ state: 'review', requiresPr: false })])).toBeNull();
});
it('ignores runs of other schedules', () => {
expect(scheduleHold(schedule, [{ ...run({ state: 'in-progress' }), scheduleId: 'other' }])).toBeNull();
});
});
describe('supersededRuns', () => {
const run = (id, postedAt, over = {}) => ({ ...make(), id, postedAt, scheduleId: 'sched', state: 'review', requiresPr: false, ...over });
it('names every no-PR Review run of a schedule but its newest', () => {
const jobs = [run('a', '2026-06-10T01:00:00Z'), run('c', '2026-06-10T03:00:00Z'), run('b', '2026-06-10T02:00:00Z')];
expect(supersededRuns(jobs).map(({ old, by }) => [old.id, by.id])).toEqual([['a', 'c'], ['b', 'c']]);
});
it('lets a newer PR run replace a no-PR run left from before the switch', () => {
const jobs = [run('old', '2026-06-10T01:00:00Z'), run('pr', '2026-06-10T02:00:00Z', { requiresPr: true })];
expect(supersededRuns(jobs).map(({ old, by }) => [old.id, by.id])).toEqual([['old', 'pr']]);
});
it('never supersedes a PR run, a run outside Review, or across schedules', () => {
const jobs = [
run('pr', '2026-06-10T01:00:00Z', { requiresPr: true }),
run('going', '2026-06-10T01:00:00Z', { state: 'in-progress' }),
run('mine', '2026-06-10T01:00:00Z'),
run('theirs', '2026-06-10T02:00:00Z', { scheduleId: 'other' }),
];
expect(supersededRuns(jobs)).toEqual([]);
});
});
describe('createRunJob', () => {
it('posts a one-time card carrying what the schedule says its runs are', () => {
const schedule = { ...make({ title: 'Nightly', schedule: '@daily', detail: 'Check.', agent: 'codex', permissionMode: 'plan', requiresPr: true }), id: 'sched', attachments: [{ name: 'a.png', path: '/x/a.png' }] };
const { job } = createRunJob(schedule);
expect(job.type).toBe('one-time');
expect(job.schedule).toBeNull();
expect(job.scheduleId).toBe('sched');
expect([job.title, job.detail, job.agent, job.permissionMode, job.requiresPr]).toEqual(['Nightly', 'Check.', 'codex', 'plan', true]);
expect(job.attachments).toEqual([]); // copied into the run's own dir by the server
expect(createRunJob({ ...schedule, postedByAgent: 'Viper' }).job.postedByAgent).toBe('Viper');
});
it('gives a run of a default schedule the no-PR prompt', () => {
const { job } = createRunJob({ ...make({ schedule: '@daily' }), id: 's' });
expect(buildJobPrompt(job)).not.toMatch(/\/ship/);
expect(buildJobPrompt(job)).toMatch(/finish_job[\s\S]*summary/);
});
});
describe('a schedule that can never fire again', () => {
// "0 0 30 2 *" parses fine and matches no date that will ever exist, so it
// has no next run time and never will. Treating that absence as "due" would
// dispatch the card on every single scan, for ever.
const never = { ...make({ schedule: '0 0 30 2 *' }), state: 'todo' };
it('is never due, even though it carries no next run time', () => {
expect(never.nextRunAt).toBeNull();
expect(isJobDue(never, Date.now())).toBe(false);
expect(selectDispatchableJobs([never])).toEqual([]);
});
it('still lets a card whose due time merely went missing run and re-arm', () => {
const lost = { ...make({ schedule: '0 9 * * *' }), nextRunAt: null };
expect(isJobDue(lost, Date.now())).toBe(true);
});
});
describe('schedule length', () => {
it('refuses an over-long schedule rather than storing a truncated prefix', () => {
// Truncating first would either hide the error or silently keep a
// valid-looking prefix of something the user did not write.
const tooLong = '0 9 * * ' + '1,'.repeat(100) + '5';
expect(createJob({ title: 'x', repoPath: REPO, schedule: tooLong }).error).toMatch(/too long/i);
});
});
describe('runsToPrune', () => {
const done = (id, doneAt, scheduleId = 's') => ({ ...make(), id, scheduleId, state: 'done', doneAt });
it('keeps each schedule\'s newest finished runs and names the rest, oldest first', () => {
const jobs = [done('a', '2026-01-01'), done('c', '2026-01-03'), done('b', '2026-01-02'), done('x', '2026-01-01', 'other')];
expect(runsToPrune(jobs, 2).map(j => j.id)).toEqual(['a']);
});
it('leaves unfinished runs and one-time cards alone', () => {
const jobs = [done('a', '2026-01-01'), { ...done('live', '2026-01-02'), state: 'review' }, { ...make(), id: 'plain', state: 'done' }];
expect(runsToPrune(jobs, 0).map(j => j.id)).toEqual(['a']);
});
});
describe('parseClosedPr', () => {
const list = (prs) => JSON.stringify(prs);
it('finds the card\'s own PR when it was closed without merging', () => {
expect(parseClosedPr(list([{ number: 7, url: 'u7', state: 'CLOSED', mergedAt: null }]), 7)).toEqual({ url: 'u7', number: 7 });
});
it('never calls a merged PR closed, nor someone else\'s PR, nor bad output', () => {
expect(parseClosedPr(list([{ number: 7, state: 'MERGED', mergedAt: '2026-01-01' }]), 7)).toBeNull();
expect(parseClosedPr(list([{ number: 8, state: 'CLOSED' }]), 7)).toBeNull();
expect(parseClosedPr('not json', 7)).toBeNull();
expect(parseClosedPr(list([{ number: 7, state: 'CLOSED' }]), null)).toBeNull();
});
it('reads gh\'s state case-blind, reads the one object gh pr view prints, and never counts an open PR', () => {
expect(parseClosedPr(list([{ number: 7, state: 'closed' }]), 7)).toEqual({ url: null, number: 7 });
expect(parseClosedPr(list([{ number: 7, state: 'OPEN' }]), 7)).toBeNull();
expect(parseClosedPr(list({ number: 7, state: 'CLOSED' }), 7)).toEqual({ url: null, number: 7 });
expect(parseClosedPr(list({ number: 7, state: 'CLOSED', mergedAt: '2026-01-01' }), 7)).toBeNull();
expect(parseClosedPr('5', 7)).toBeNull();
expect(parseClosedPr(list([null, { number: 7, state: 'CLOSED', url: 'u' }]), 7)).toEqual({ url: 'u', number: 7 });
});
});