import { describe, expect, it, vi } from 'vitest'; import os from 'os'; import path from 'path'; import { mkdtemp, rm, mkdir, writeFile } from 'fs/promises'; import { computeNextRunAt, expandCommandGoalObjective, formatScheduledSessionTitle, parseScheduledCommandPrompt, createScheduledTasksRuntime, } from './runtime.js'; import { createProjectConfigRuntime } from '../projects/project-config.js'; describe('scheduled-tasks runtime helpers', () => { it('computes next daily run in timezone', () => { const nowUtc = Date.UTC(2025, 0, 1, 8, 0, 0); const next = computeNextRunAt({ enabled: true, schedule: { kind: 'daily', times: ['09:30'], timezone: 'UTC', }, }, nowUtc); expect(next).toBe(Date.UTC(2025, 0, 1, 9, 30, 0)); }); it('computes weekly next run using weekdays', () => { // Monday 2025-01-06 10:00:00 UTC const nowUtc = Date.UTC(2025, 0, 6, 10, 0, 0); const next = computeNextRunAt({ enabled: true, schedule: { kind: 'weekly', times: ['09:00'], weekdays: [1, 3], timezone: 'UTC', }, }, nowUtc); // Wednesday 2025-01-08 09:00:00 UTC expect(next).toBe(Date.UTC(2025, 0, 8, 9, 0, 0)); }); it('picks nearest time from multiple daily times', () => { const nowUtc = Date.UTC(2025, 0, 1, 9, 20, 0); const next = computeNextRunAt({ enabled: true, schedule: { kind: 'daily', times: ['09:15', '09:45', '18:00'], timezone: 'UTC', }, }, nowUtc); expect(next).toBe(Date.UTC(2025, 0, 1, 9, 45, 0)); }); it('computes one-time next run for future date', () => { const nowUtc = Date.UTC(2026, 3, 15, 10, 0, 0); const next = computeNextRunAt({ enabled: true, schedule: { kind: 'once', date: '2026-04-16', time: '13:30', timezone: 'UTC', }, }, nowUtc); expect(next).toBe(Date.UTC(2026, 3, 16, 13, 30, 0)); }); it('returns null for past one-time schedule', () => { const nowUtc = Date.UTC(2026, 3, 16, 14, 0, 0); const next = computeNextRunAt({ enabled: true, schedule: { kind: 'once', date: '2026-04-16', time: '13:30', timezone: 'UTC', }, }, nowUtc); expect(next).toBeNull(); }); it('formats session title with timestamp suffix', () => { const title = formatScheduledSessionTitle({ name: 'Morning Sync', schedule: { timezone: 'UTC' }, }, Date.UTC(2025, 2, 10, 7, 5, 0)); expect(title).toBe('Morning Sync 2025-03-10 07:05'); }); it('parses slash command prompt for scheduled command mode', () => { expect(parseScheduledCommandPrompt('/review src/components')).toEqual({ command: 'review', arguments: 'src/components', }); }); it('returns null when prompt is not a slash command', () => { expect(parseScheduledCommandPrompt('Summarize open issues')).toBeNull(); expect(parseScheduledCommandPrompt('/')).toBeNull(); }); it('expands command arguments into the goal objective', () => { expect(expandCommandGoalObjective( 'Run the issue pipeline for $ARGUMENTS. Verify $ARGUMENTS is represented by the PR.', 'LIN-123 --draft', )).toBe('Run the issue pipeline for LIN-123 --draft. Verify LIN-123 --draft is represented by the PR.'); expect(expandCommandGoalObjective(undefined, 'LIN-123')).toBeNull(); expect(expandCommandGoalObjective('Move $1 to $2', '"src old" dist extra')).toBe('Move src old to dist extra'); expect(expandCommandGoalObjective('Review the requested scope.', 'auth module')) .toBe('Review the requested scope.\n\nauth module'); }); }); describe('scheduled-tasks runtime syncProject wiring', () => { const createTempProject = async () => { const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'oc-runtime-loop-')); const repoPath = path.join(tempRoot, 'repo'); await mkdir(path.join(repoPath, '.agents', 'loops'), { recursive: true }); return { tempRoot, repoPath, cleanup: async () => { await rm(tempRoot, { recursive: true, force: true }); }, }; }; const createProjectConfig = async (tempRoot) => createProjectConfigRuntime({ fsPromises: await import('fs/promises'), path, projectsDirPath: path.join(tempRoot, 'config'), createTaskID: () => 'task-fixed-id', }); const createRuntimeDeps = (overrides = {}) => ({ buildOpenCodeUrl: () => 'http://localhost', getOpenCodeAuthHeaders: () => ({}), waitForOpenCodeReady: async () => {}, ...overrides, }); it('reconciles discovered loops when the project path is known', async () => { const { tempRoot, repoPath, cleanup } = await createTempProject(); try { await writeFile(path.join(repoPath, '.agents', 'loops', 'daily.md'), `--- name: daily schedule: "0 9 * * *" enabled: true model: openai/gpt-5 --- Run daily. `, 'utf8'); const projectConfigRuntime = await createProjectConfig(tempRoot); const runtime = createScheduledTasksRuntime({ ...createRuntimeDeps(), projectConfigRuntime, listProjects: async () => [{ id: 'proj', path: repoPath }], }); await runtime.syncProject('proj'); const tasks = await projectConfigRuntime.listScheduledTasks('proj'); expect(tasks).toHaveLength(1); expect(tasks[0].id).toBe('loop:project:daily'); expect(tasks[0].loopFile).toBe(path.join(repoPath, '.agents', 'loops', 'daily.md')); // syncTaskSchedule computed and persisted the next run for the enabled task. expect(tasks[0].state.nextRunAt).toBeGreaterThan(0); } finally { await cleanup(); } }); it('falls back to plain listing when the project path cannot be resolved', async () => { const { tempRoot, cleanup } = await createTempProject(); try { const projectConfigRuntime = await createProjectConfig(tempRoot); const reconcileSpy = vi.spyOn(projectConfigRuntime, 'reconcileLoopTasks'); const listSpy = vi.spyOn(projectConfigRuntime, 'listScheduledTasks'); const runtime = createScheduledTasksRuntime({ ...createRuntimeDeps(), projectConfigRuntime, // Project not registered -> ensureProjectPath cannot resolve a path. listProjects: async () => [], }); await runtime.syncProject('proj'); expect(reconcileSpy).not.toHaveBeenCalled(); expect(listSpy).toHaveBeenCalledWith('proj'); expect(await projectConfigRuntime.listScheduledTasks('proj')).toEqual([]); reconcileSpy.mockRestore(); listSpy.mockRestore(); } finally { await cleanup(); } }); });