import { beforeEach, describe, expect, it, vi } from 'vitest'; const { checkGitHubActionsRunMock } = vi.hoisted(() => ({ checkGitHubActionsRunMock: vi.fn(), })); vi.mock('./github-ci.js', () => ({ checkGitHubActionsRun: checkGitHubActionsRunMock, computeGitHubWatcherDelayMs: vi.fn(() => 15_000), MAX_GITHUB_CONSECUTIVE_ERRORS: 5, parseGitHubCiMetadata: vi.fn((raw: string | null | undefined) => { if (!raw) return null; return JSON.parse(raw); }), serializeGitHubCiMetadata: vi.fn((metadata: unknown) => JSON.stringify(metadata), ), })); vi.mock('./logger.js', () => { const mockLogger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), child: (_bindings?: Record) => mockLogger, }; return { logger: mockLogger, createScopedLogger: (_bindings?: Record) => mockLogger, }; }); import { _initTestDatabase, createPairedTask, createTask, getRecentChatMessages, getTaskById, } from './db.js'; import { runGithubCiTask } from './task-scheduler-github.js'; import type { SchedulerDependencies } from './task-scheduler-types.js'; import type { ScheduledTask } from './types.js'; function buildSchedulerDeps(overrides: { sendMessage?: SchedulerDependencies['sendMessage']; sendTrackedMessage?: SchedulerDependencies['sendTrackedMessage']; editTrackedMessage?: SchedulerDependencies['editTrackedMessage']; enqueueMessageCheck?: (chatJid: string, ipcDir?: string) => void; }): SchedulerDependencies { return { serviceAgentType: 'codex', roomBindings: () => ({}), getSessions: () => ({}), queue: { enqueueMessageCheck: overrides.enqueueMessageCheck ?? vi.fn(), } as any, onProcess: () => {}, sendMessage: overrides.sendMessage ?? vi.fn(async () => {}), sendTrackedMessage: overrides.sendTrackedMessage, editTrackedMessage: overrides.editTrackedMessage, }; } function createReviewReadyPairedTask(now: string): void { createPairedTask({ id: 'paired-review-ready', chat_jid: 'shared@g.us', group_folder: 'shared-group', owner_service_id: 'codex-main', reviewer_service_id: 'claude-review', owner_agent_type: 'codex', reviewer_agent_type: 'claude-code', arbiter_agent_type: 'codex', title: null, source_ref: null, plan_notes: null, review_requested_at: null, round_trip_count: 1, owner_failure_count: 0, owner_step_done_streak: 0, finalize_step_done_count: 0, task_done_then_user_reopen_count: 0, empty_step_done_streak: 0, status: 'review_ready', arbiter_verdict: null, arbiter_requested_at: null, completion_reason: null, created_at: now, updated_at: now, }); } function createGithubWatcherTask(overrides: { id?: string; roomRole?: ScheduledTask['room_role']; statusMessageId?: string | null; statusStartedAt?: string | null; now: string; }): ScheduledTask { const taskId = overrides.id ?? 'task-github-owner-complete'; createTask({ id: taskId, group_folder: 'shared-group', chat_jid: 'shared@g.us', agent_type: 'codex', room_role: overrides.roomRole, ci_provider: 'github', ci_metadata: JSON.stringify({ repo: 'owner/repo', run_id: 777777, }), prompt: ` [BACKGROUND CI WATCH] Watch target: GitHub Actions run 777777 Check instructions: Managed by host-driven watcher. `.trim(), schedule_type: 'interval', schedule_value: '15000', context_mode: 'isolated', next_run: overrides.now, status: 'active', status_message_id: overrides.statusMessageId, status_started_at: overrides.statusStartedAt, created_at: overrides.now, }); return getTaskById(taskId)!; } describe('GitHub CI watcher scheduler', () => { beforeEach(() => { _initTestDatabase(); checkGitHubActionsRunMock.mockReset(); }); it('queues an owner turn after an owner CI watcher completes in a paired room', async () => { checkGitHubActionsRunMock.mockResolvedValueOnce({ terminal: true, resultSummary: '성공: owner/repo run 777777', completionMessage: 'CI 완료: GitHub Actions run 777777\n판정: 성공', }); const now = '2026-02-22T00:00:00.000Z'; createReviewReadyPairedTask(now); const task = createGithubWatcherTask({ now, roomRole: 'owner' }); const sendMessage = vi.fn(async () => {}); const enqueueMessageCheck = vi.fn(); await runGithubCiTask( task, buildSchedulerDeps({ sendMessage, enqueueMessageCheck }), ); expect(sendMessage).toHaveBeenCalledWith( 'shared@g.us', 'CI 완료: GitHub Actions run 777777\n판정: 성공', ); expect(enqueueMessageCheck).toHaveBeenCalledWith( 'shared@g.us', expect.stringContaining('shared-group'), ); expect(getRecentChatMessages('shared@g.us', 5).at(-1)).toMatchObject({ sender: 'ci-watcher', sender_name: 'CI watcher', is_bot_message: false, message_source_kind: 'trusted_external_bot', content: '[CI watcher completed]\nCI 완료: GitHub Actions run 777777\n판정: 성공', }); expect(getTaskById(task.id)).toBeUndefined(); }); it('queues an owner turn even when the paired task already closed before CI completion', async () => { checkGitHubActionsRunMock.mockResolvedValueOnce({ terminal: true, resultSummary: '성공: owner/repo run 777777', completionMessage: 'CI 완료: GitHub Actions run 777777\n판정: 성공', }); const now = '2026-02-22T00:00:00.000Z'; const task = createGithubWatcherTask({ now, roomRole: 'owner' }); const enqueueMessageCheck = vi.fn(); await runGithubCiTask(task, buildSchedulerDeps({ enqueueMessageCheck })); expect(enqueueMessageCheck).toHaveBeenCalledWith( 'shared@g.us', expect.stringContaining('shared-group'), ); expect(getRecentChatMessages('shared@g.us', 5).at(-1)).toMatchObject({ sender: 'ci-watcher', sender_name: 'CI watcher', is_bot_message: false, message_source_kind: 'trusted_external_bot', content: '[CI watcher completed]\nCI 완료: GitHub Actions run 777777\n판정: 성공', }); expect(getTaskById(task.id)).toBeUndefined(); }); it('does not queue owner wake-up for non-owner CI watchers', async () => { checkGitHubActionsRunMock.mockResolvedValueOnce({ terminal: true, resultSummary: '성공: owner/repo run 777777', completionMessage: 'CI 완료: GitHub Actions run 777777\n판정: 성공', }); const now = '2026-02-22T00:00:00.000Z'; createReviewReadyPairedTask(now); const task = createGithubWatcherTask({ now, roomRole: 'reviewer' }); const enqueueMessageCheck = vi.fn(); await runGithubCiTask(task, buildSchedulerDeps({ enqueueMessageCheck })); expect(enqueueMessageCheck).not.toHaveBeenCalled(); expect(getRecentChatMessages('shared@g.us', 5)).toEqual([]); expect(getTaskById(task.id)).toBeUndefined(); }); it('edits the tracked watcher status and still wakes owner without a duplicate bot completion message', async () => { checkGitHubActionsRunMock.mockResolvedValueOnce({ terminal: true, resultSummary: '성공: owner/repo run 777777', completionMessage: 'CI 완료: GitHub Actions run 777777\n판정: 성공', }); const now = '2026-02-22T00:00:00.000Z'; createReviewReadyPairedTask(now); const task = createGithubWatcherTask({ now, roomRole: 'owner', statusMessageId: 'status-msg-1', statusStartedAt: now, }); const sendMessage = vi.fn(async () => {}); const sendTrackedMessage = vi.fn(async () => 'status-msg-new'); const editTrackedMessage = vi.fn(async () => {}); const enqueueMessageCheck = vi.fn(); await runGithubCiTask( task, buildSchedulerDeps({ sendMessage, sendTrackedMessage, editTrackedMessage, enqueueMessageCheck, }), ); expect(editTrackedMessage).toHaveBeenCalledWith( 'shared@g.us', 'status-msg-1', expect.stringContaining('CI 감시 종료: GitHub Actions run 777777'), ); expect(sendTrackedMessage).not.toHaveBeenCalled(); expect(sendMessage).not.toHaveBeenCalled(); expect(enqueueMessageCheck).toHaveBeenCalledWith( 'shared@g.us', expect.stringContaining('shared-group'), ); expect(getRecentChatMessages('shared@g.us', 5).at(-1)).toMatchObject({ sender: 'ci-watcher', sender_name: 'CI watcher', is_bot_message: false, message_source_kind: 'trusted_external_bot', content: '[CI watcher completed]\nCI 완료: GitHub Actions run 777777\n판정: 성공', }); expect(getTaskById(task.id)).toBeUndefined(); }); });