Ctrl K
ComunidadGuías…

test/schedule-status.test.js

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

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