Files
Codeman/test/scheduler-time.test.ts
T
KrisandClaude Opus 4.8 6ae86b53f6 feat(scheduler): add cron-style scheduled jobs (backend)
Adds a saved/named scheduling layer on top of Codeman's existing session
primitives. Distinct from the legacy run-now ScheduledRun concept.

- types/scheduler.ts: ScheduledJob + ScheduledJobRun
- state-store: persist scheduledJobs/scheduledJobRuns in ~/.codeman/state.json
- scheduler/scheduler-time.ts: pure once/interval/daily/weekly next-run math
- scheduler/scheduler-service.ts: CRUD, Run Now, due-checker tick, run history;
  reuses SessionPort (create -> start -> writeViaMux) for launches
- web/routes/scheduler-routes.ts: /api/scheduler/jobs CRUD + run + history
- web/schemas.ts: zod validation with schedule-type-aware refinements
- web/sse-events.ts: scheduler:* events
- server.ts: wire service into route context + 30s background tick loop
- test/scheduler-time.test.ts: 14 unit tests for next-run calculations

Phase 1 discovery recorded in SCHEDULER_DISCOVERY.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rp7JhmQXcYJhmxFMdZuuah
2026-06-27 08:10:40 +05:30

129 lines
4.9 KiB
TypeScript

/**
* Unit tests for the scheduler's pure next-run-time calculations.
* Timezone-independent: daily/weekly expectations are asserted via local
* Date getters rather than hardcoded epoch values.
*/
import { describe, it, expect } from 'vitest';
import { parseHHMM, computeNextRunAt, dueKeyFor } from '../src/scheduler/scheduler-time.js';
import type { ScheduledJob } from '../src/types/scheduler.js';
function baseJob(partial: Partial<ScheduledJob>): ScheduledJob {
return {
id: 'j1',
name: 'test',
agentType: 'claude',
workingDir: '/tmp',
promptMode: 'inline_text',
promptText: 'hi',
inputMode: 'typed',
scheduleType: 'once',
enabled: true,
concurrencyPolicy: 'warn_only',
createdAt: 0,
updatedAt: 0,
lastRunAt: null,
nextRunAt: null,
lastStatus: null,
lastDueKey: null,
...partial,
};
}
describe('parseHHMM', () => {
it('parses valid 24h times', () => {
expect(parseHHMM('09:30')).toEqual({ hours: 9, minutes: 30 });
expect(parseHHMM('23:59')).toEqual({ hours: 23, minutes: 59 });
expect(parseHHMM('0:00')).toEqual({ hours: 0, minutes: 0 });
});
it('rejects invalid times', () => {
expect(parseHHMM('24:00')).toBeNull();
expect(parseHHMM('12:60')).toBeNull();
expect(parseHHMM('9:5')).toBeNull(); // minutes must be 2 digits
expect(parseHHMM('abc')).toBeNull();
expect(parseHHMM(undefined)).toBeNull();
});
});
describe('computeNextRunAt — once', () => {
it('returns runAt even when already in the past (missed one-time job still fires)', () => {
const job = baseJob({ scheduleType: 'once', runAt: 1000 });
expect(computeNextRunAt(job, 500)).toBe(1000);
expect(computeNextRunAt(job, 5000)).toBe(1000);
});
it('returns null once completed', () => {
const job = baseJob({ scheduleType: 'once', runAt: 1000, completedOnce: true });
expect(computeNextRunAt(job, 500)).toBeNull();
});
it('returns null with no runAt', () => {
expect(computeNextRunAt(baseJob({ scheduleType: 'once' }), 0)).toBeNull();
});
});
describe('computeNextRunAt — interval', () => {
it('adds intervalMinutes to the after time', () => {
const job = baseJob({ scheduleType: 'interval', intervalMinutes: 60 });
expect(computeNextRunAt(job, 1000)).toBe(1000 + 60 * 60_000);
});
it('returns null with no/invalid interval', () => {
expect(computeNextRunAt(baseJob({ scheduleType: 'interval' }), 0)).toBeNull();
expect(computeNextRunAt(baseJob({ scheduleType: 'interval', intervalMinutes: 0 }), 0)).toBeNull();
});
});
describe('computeNextRunAt — daily', () => {
it('schedules today when the time is still ahead', () => {
const after = new Date(2026, 0, 1, 10, 0, 0).getTime();
const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!;
const d = new Date(next);
expect(d.getHours()).toBe(14);
expect(d.getMinutes()).toBe(30);
expect(d.getDate()).toBe(1);
expect(next).toBeGreaterThan(after);
});
it('rolls to tomorrow when the time has passed', () => {
const after = new Date(2026, 0, 1, 16, 0, 0).getTime();
const next = computeNextRunAt(baseJob({ scheduleType: 'daily', dailyTime: '14:30' }), after)!;
const d = new Date(next);
expect(d.getHours()).toBe(14);
expect(d.getDate()).toBe(2);
expect(next).toBeGreaterThan(after);
});
it('returns null with no time', () => {
expect(computeNextRunAt(baseJob({ scheduleType: 'daily' }), 0)).toBeNull();
});
});
describe('computeNextRunAt — weekly', () => {
it('finds the next selected weekday at the configured time', () => {
const after = new Date(2026, 0, 1, 12, 0, 0).getTime();
const targetDay = (new Date(after).getDay() + 2) % 7;
const job = baseJob({ scheduleType: 'weekly', weeklyDays: [targetDay], weeklyTime: '08:00' });
const next = computeNextRunAt(job, after)!;
const d = new Date(next);
expect(d.getDay()).toBe(targetDay);
expect(d.getHours()).toBe(8);
expect(next).toBeGreaterThan(after);
// Within the coming week.
expect(next - after).toBeLessThanOrEqual(7 * 24 * 60 * 60_000);
});
it('picks the soonest of multiple selected days', () => {
const after = new Date(2026, 0, 1, 12, 0, 0).getTime();
const soon = (new Date(after).getDay() + 1) % 7;
const later = (new Date(after).getDay() + 3) % 7;
const job = baseJob({ scheduleType: 'weekly', weeklyDays: [later, soon], weeklyTime: '09:00' });
const next = computeNextRunAt(job, after)!;
expect(new Date(next).getDay()).toBe(soon);
});
it('returns null with no days or no time', () => {
expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyTime: '09:00' }), 0)).toBeNull();
expect(computeNextRunAt(baseJob({ scheduleType: 'weekly', weeklyDays: [1] }), 0)).toBeNull();
});
});
describe('dueKeyFor', () => {
it('combines job id and fire time', () => {
expect(dueKeyFor('j1', 123)).toBe('j1:123');
});
});