Ctrl K
ComunidadGuías…

test/jobs-scheduled.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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 });
  });
});