Files
EJClaw/src/web-dashboard-room-routes.test.ts

282 lines
8.6 KiB
TypeScript

import { gunzipSync } from 'node:zlib';
import { describe, expect, it } from 'vitest';
import type {
PairedTurnAttemptRecord,
PairedTurnRecord,
WorkItem,
} from './db.js';
import type { StatusSnapshot } from './status-dashboard.js';
import type { NewMessage, PairedTask, PairedTurnOutput } from './types.js';
import {
handleRoomTimelineRoute,
type RoomsTimelineRouteDependencies,
} from './web-dashboard-room-routes.js';
function jsonResponse(value: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(value), {
...init,
headers: {
'content-type': 'application/json; charset=utf-8',
...(init?.headers as Record<string, string> | undefined),
},
});
}
function makePairedTask(overrides: Partial<PairedTask> = {}): PairedTask {
return {
id: 'paired-room-1',
chat_jid: 'dc:ops',
group_folder: 'ops-room',
owner_service_id: 'codex-main',
reviewer_service_id: 'claude-reviewer',
owner_agent_type: 'codex',
reviewer_agent_type: 'claude-code',
arbiter_agent_type: 'codex',
title: 'Dashboard PR',
source_ref: null,
plan_notes: null,
review_requested_at: null,
round_trip_count: 2,
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: 'in_review',
arbiter_verdict: null,
arbiter_requested_at: null,
completion_reason: null,
created_at: '2026-04-26T04:00:00.000Z',
updated_at: '2026-04-26T05:20:00.000Z',
...overrides,
};
}
function snapshot(updatedAt = '2026-04-26T05:22:00.000Z'): StatusSnapshot {
return {
serviceId: 'codex-main',
agentType: 'codex',
assistantName: 'Codex',
updatedAt,
entries: [
{
jid: 'dc:ops',
name: '#ops',
folder: 'ops-room',
agentType: 'codex',
status: 'processing',
elapsedMs: 120_000,
pendingMessages: true,
pendingTasks: 1,
},
],
};
}
function roomDeps(
overrides: Partial<RoomsTimelineRouteDependencies> = {},
): RoomsTimelineRouteDependencies {
return {
statusMaxAgeMs: 1234,
readSnapshots: () => [snapshot()],
loadLatestPairedTaskForChat: () => null,
loadPairedTurnsForTask: () => [],
loadLatestPairedTurnForTask: () => null,
loadPairedTurnAttempts: () => [],
loadPairedTurnOutputs: () => [],
loadRecentPairedTurnOutputsForChat: () => [],
loadRecentDeliveredWorkItemsForChat: () => [],
loadRecentChatMessages: () => [],
...overrides,
};
}
function workItem(overrides: Partial<WorkItem> = {}): WorkItem {
return {
id: 9001,
group_folder: 'ops-room',
chat_jid: 'dc:ops',
agent_type: 'codex',
service_id: 'codex-main',
delivery_role: 'owner',
status: 'delivered',
start_seq: null,
end_seq: null,
result_payload: 'owner final output',
attachments: [],
delivery_attempts: 1,
delivery_message_id: 'discord-owner-final',
last_error: null,
created_at: '2026-04-26T05:18:00.000Z',
updated_at: '2026-04-26T05:18:30.000Z',
delivered_at: '2026-04-26T05:18:30.000Z',
...overrides,
};
}
describe('web dashboard room routes', () => {
it('serves room timeline with paired progress and recent messages', async () => {
const pairedTask = makePairedTask();
const turns: PairedTurnRecord[] = [
{
turn_id: 'paired-room-1:reviewer-turn',
task_id: pairedTask.id,
task_updated_at: pairedTask.updated_at,
role: 'reviewer',
intent_kind: 'reviewer-turn',
state: 'queued',
executor_service_id: null,
executor_agent_type: null,
attempt_no: 0,
created_at: '2026-04-26T05:18:30.000Z',
updated_at: '2026-04-26T05:21:00.000Z',
completed_at: null,
last_error: null,
progress_text: 'checking current output',
progress_updated_at: '2026-04-26T05:20:00.000Z',
},
];
const attempts: PairedTurnAttemptRecord[] = [
{
attempt_id: 'paired-room-1:reviewer-turn:attempt:2',
parent_attempt_id: null,
parent_handoff_id: null,
continuation_handoff_id: null,
turn_id: 'paired-room-1:reviewer-turn',
task_id: pairedTask.id,
task_updated_at: pairedTask.updated_at,
role: 'reviewer',
intent_kind: 'reviewer-turn',
state: 'running',
executor_service_id: 'claude-reviewer',
executor_agent_type: 'claude-code',
active_run_id: 'run-reviewer-1',
attempt_no: 2,
created_at: '2026-04-26T05:19:00.000Z',
updated_at: '2026-04-26T05:21:00.000Z',
completed_at: null,
last_error: 'OPENAI_API_KEY=plain-secret-value',
},
];
const outputs: PairedTurnOutput[] = [
{
id: 1,
task_id: pairedTask.id,
turn_number: 1,
role: 'owner',
output_text: 'owner final output',
verdict: 'step_done',
created_at: '2026-04-26T05:18:00.000Z',
},
];
const messages: NewMessage[] = [
{
id: 'msg-1',
chat_jid: 'dc:ops',
sender: 'u1',
sender_name: '눈쟁이',
content: '진행 어디까지야? BOT_TOKEN=plain-secret-value',
timestamp: '2026-04-26T05:17:00.000Z',
is_from_me: false,
is_bot_message: false,
message_source_kind: 'human',
},
];
const requestedMessageLimits: number[] = [];
const response = handleRoomTimelineRoute({
url: new URL(
`http://localhost/api/rooms/${encodeURIComponent('dc:ops')}/timeline`,
),
request: new Request('http://localhost'),
jsonResponse,
...roomDeps({
loadLatestPairedTaskForChat: () => pairedTask,
loadPairedTurnsForTask: () => turns,
loadPairedTurnAttempts: () => attempts,
loadPairedTurnOutputs: () => outputs,
loadRecentDeliveredWorkItemsForChat: () => [workItem()],
loadRecentChatMessages: (_jid, limit) => {
requestedMessageLimits.push(limit ?? 20);
return messages;
},
}),
});
expect(response?.status).toBe(200);
const body = (await response?.json()) as {
pairedTask: {
currentTurn: { lastError: string; progressText: string };
outputs: Array<{ outputText: string }>;
};
messages: Array<{ content: string; senderName: string }>;
};
expect(body.pairedTask.currentTurn).toMatchObject({
lastError: 'OPENAI_API_KEY=<redacted>',
progressText: 'checking current output',
});
expect(body.pairedTask.outputs).toEqual([]);
expect(requestedMessageLimits).toEqual([8]);
expect(body.messages).toEqual([
expect.objectContaining({
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
senderName: '눈쟁이',
}),
expect.objectContaining({
content: 'owner final output',
senderName: 'owner',
}),
]);
expect(body.messages[0]).toMatchObject({
content: expect.stringContaining('BOT_TOKEN=<redacted>'),
senderName: '눈쟁이',
});
});
it('serves cached room timelines and falls through outside room routes', async () => {
const pairedTask = makePairedTask({ id: 'paired-cache-1' });
const response = handleRoomTimelineRoute({
url: new URL('http://localhost/api/rooms-timeline'),
request: new Request('http://localhost/api/rooms-timeline', {
headers: { 'accept-encoding': 'gzip' },
}),
jsonResponse,
...roomDeps({
readSnapshots: () => [snapshot('2026-04-26T06:22:00.000Z')],
loadLatestPairedTaskForChat: () => pairedTask,
loadLatestPairedTurnForTask: () => null,
loadRecentChatMessages: () => [
{
id: 'msg-cache',
chat_jid: 'dc:ops',
sender: 'u1',
sender_name: '눈쟁이',
content: 'cache message',
timestamp: '2026-04-26T06:17:00.000Z',
is_from_me: false,
is_bot_message: false,
message_source_kind: 'human',
},
],
}),
});
expect(response?.status).toBe(200);
expect(response?.headers.get('content-encoding')).toBe('gzip');
const bytes = new Uint8Array(await response!.arrayBuffer());
const body = JSON.parse(
new TextDecoder().decode(gunzipSync(bytes)),
) as Record<string, { pairedTask: { id: string } }>;
expect(body['dc:ops']?.pairedTask.id).toBe('paired-cache-1');
expect(
handleRoomTimelineRoute({
url: new URL('http://localhost/api/overview'),
request: new Request('http://localhost/api/overview'),
jsonResponse,
...roomDeps(),
}),
).toBeNull();
});
});