mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
A 15-agent audit of the merged tree confirmed 9 envelope/contract bugs;
all fixed here, with live-server contract tests added:
Blockers (fresh-install quick start broken):
- session-ui.js runClaude/runShell unwrapped .data from the /api/cases/:name
404 error envelope (which has no data key), so a not-yet-created case threw
TypeError instead of triggering the auto-create fallback. Now '?.data ?? {}'.
Contract violations on the new stable surface:
- Unknown /api routes returned HTTP 404 with {success:true,...} (Fastify's
default not-found payload was wrapped by the envelope hook). Added a
setNotFoundHandler returning the standard error envelope for /api paths.
- POST /api/events/subscribe 400 body became {success:true,data:{error}};
now createErrorResponse(INVALID_INPUT).
- POST /api/clipboard validation error lacked errorCode and shipped HTTP 200;
now createErrorResponse(INVALID_INPUT) -> 400.
- POST /api/run catch path returned bare {success:false,sessionId,error}
(HTTP 200, no errorCode); now OPERATION_FAILED envelope -> 422 with the
dead session id in the message.
- DELETE tail-file/:streamId returned {success: closed}, colliding with the
envelope discriminator; now returns {closed}.
Dead/regressed UI paths:
- Plan history modal could never open: route returned the bare history array
under data while the frontend read data.data.history/currentVersion. Route
now returns {history, currentVersion}; modal task count fixed to stats.total.
- Self-update error toast read j.error.message from the string-typed envelope
error, always falling back to the generic message; now reads the string.
Cleanup:
- Removed the stale QuickStartResponse type (unreferenced; documented the
pre-envelope shape and invited success-key collisions).
Tests: new test/http-contract.test.ts boots a real WebServer (port 3168) and
pins the envelope, /api/v1 alias, error statuses, and the /api 404 shape —
the route-test harness does not install the server-level hook, so these need
the live server. Updated file-routes/plan-routes/scheduled-runs tests to the
fixed shapes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
411 lines
14 KiB
TypeScript
411 lines
14 KiB
TypeScript
/**
|
|
* @fileoverview Tests for plan-routes route handlers.
|
|
*
|
|
* Uses app.inject() — no real HTTP ports needed.
|
|
* Port: N/A (app.inject doesn't open ports)
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { createRouteTestHarness, type RouteTestHarness } from './_route-test-utils.js';
|
|
import { registerPlanRoutes } from '../../src/web/routes/plan-routes.js';
|
|
|
|
describe('plan-routes', () => {
|
|
let harness: RouteTestHarness;
|
|
|
|
beforeEach(async () => {
|
|
harness = await createRouteTestHarness(registerPlanRoutes);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await harness.app.close();
|
|
});
|
|
|
|
// ========== POST /api/cancel-plan-generation ==========
|
|
|
|
describe('POST /api/cancel-plan-generation', () => {
|
|
it('returns not found when orchestratorId does not exist', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/cancel-plan-generation',
|
|
payload: { orchestratorId: 'plan-nonexistent' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('not found');
|
|
});
|
|
|
|
it('cancels a specific orchestrator', async () => {
|
|
const mockOrchestrator = { cancel: vi.fn(async () => {}) };
|
|
harness.ctx.activePlanOrchestrators.set('plan-123', mockOrchestrator as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/cancel-plan-generation',
|
|
payload: { orchestratorId: 'plan-123' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.cancelled).toBe('plan-123');
|
|
expect(mockOrchestrator.cancel).toHaveBeenCalled();
|
|
expect(harness.ctx.activePlanOrchestrators.has('plan-123')).toBe(false);
|
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith('plan:cancelled', { orchestratorId: 'plan-123' });
|
|
});
|
|
|
|
it('cancels all active orchestrators when no id provided', async () => {
|
|
const mock1 = { cancel: vi.fn(async () => {}) };
|
|
const mock2 = { cancel: vi.fn(async () => {}) };
|
|
harness.ctx.activePlanOrchestrators.set('plan-a', mock1 as never);
|
|
harness.ctx.activePlanOrchestrators.set('plan-b', mock2 as never);
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/cancel-plan-generation',
|
|
payload: {},
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.cancelled).toContain('plan-a');
|
|
expect(body.data.cancelled).toContain('plan-b');
|
|
expect(mock1.cancel).toHaveBeenCalled();
|
|
expect(mock2.cancel).toHaveBeenCalled();
|
|
expect(harness.ctx.activePlanOrchestrators.size).toBe(0);
|
|
});
|
|
|
|
it('rejects invalid body', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/cancel-plan-generation',
|
|
payload: { orchestratorId: 12345 }, // should be string
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ========== PATCH /api/sessions/:id/plan/task/:taskId ==========
|
|
|
|
describe('PATCH /api/sessions/:id/plan/task/:taskId', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'PATCH',
|
|
url: '/api/sessions/nonexistent/plan/task/task-1',
|
|
payload: { status: 'completed' },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns error when ralph tracker not available', async () => {
|
|
// Default mock session has ralphTracker = null
|
|
const res = await harness.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
|
|
payload: { status: 'completed' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('Ralph tracker');
|
|
});
|
|
|
|
it('updates a plan task successfully', async () => {
|
|
const mockTask = { id: 'task-1', content: 'Do something', status: 'completed', attempts: 1 };
|
|
harness.ctx._session.ralphTracker = {
|
|
updatePlanTask: vi.fn(() => ({ success: true, task: mockTask })),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
|
|
payload: { status: 'completed' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.id).toBe('task-1');
|
|
expect(body.data.status).toBe('completed');
|
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
|
'session:planTaskUpdate',
|
|
expect.objectContaining({ sessionId: harness.ctx._sessionId, taskId: 'task-1' })
|
|
);
|
|
});
|
|
|
|
it('returns not found for unknown task id', async () => {
|
|
harness.ctx._session.ralphTracker = {
|
|
updatePlanTask: vi.fn(() => ({ success: false, error: 'Task not found' })),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/nonexistent`,
|
|
payload: { status: 'failed', error: 'Something broke' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('not found');
|
|
});
|
|
|
|
it('rejects invalid body', async () => {
|
|
harness.ctx._session.ralphTracker = {
|
|
updatePlanTask: vi.fn(),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'PATCH',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/task/task-1`,
|
|
payload: { status: 'invalid_status' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
});
|
|
|
|
// ========== POST /api/sessions/:id/plan/checkpoint ==========
|
|
|
|
describe('POST /api/sessions/:id/plan/checkpoint', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/nonexistent/plan/checkpoint',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns error when ralph tracker not available', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/checkpoint`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('Ralph tracker');
|
|
});
|
|
|
|
it('generates a checkpoint review', async () => {
|
|
const mockCheckpoint = {
|
|
completedCount: 5,
|
|
totalCount: 10,
|
|
failedTasks: [],
|
|
recommendations: ['Continue with P1 tasks'],
|
|
};
|
|
harness.ctx._session.ralphTracker = {
|
|
generateCheckpointReview: vi.fn(() => mockCheckpoint),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/checkpoint`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.completedCount).toBe(5);
|
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
|
'session:planCheckpoint',
|
|
expect.objectContaining({ sessionId: harness.ctx._sessionId })
|
|
);
|
|
});
|
|
});
|
|
|
|
// ========== GET /api/sessions/:id/plan/history ==========
|
|
|
|
describe('GET /api/sessions/:id/plan/history', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: '/api/sessions/nonexistent/plan/history',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns error when ralph tracker not available', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/history`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('Ralph tracker');
|
|
});
|
|
|
|
it('returns plan version history with the current version', async () => {
|
|
const mockHistory = [
|
|
{ version: 1, timestamp: Date.now() - 60000, itemCount: 10 },
|
|
{ version: 2, timestamp: Date.now(), itemCount: 12 },
|
|
];
|
|
harness.ctx._session.ralphTracker = {
|
|
getPlanHistory: vi.fn(() => mockHistory),
|
|
planVersion: 2,
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'GET',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/history`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.history).toHaveLength(2);
|
|
expect(body.data.history[1].version).toBe(2);
|
|
expect(body.data.currentVersion).toBe(2);
|
|
});
|
|
});
|
|
|
|
// ========== POST /api/sessions/:id/plan/rollback/:version ==========
|
|
|
|
describe('POST /api/sessions/:id/plan/rollback/:version', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/nonexistent/plan/rollback/1',
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns error when ralph tracker not available', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/rollback/1`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('Ralph tracker');
|
|
});
|
|
|
|
it('rolls back to a previous version', async () => {
|
|
const mockPlan = [{ id: 'task-1', content: 'Step 1', status: 'pending' }];
|
|
harness.ctx._session.ralphTracker = {
|
|
rollbackToVersion: vi.fn(() => ({ success: true, plan: mockPlan })),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/rollback/1`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data).toHaveLength(1);
|
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
|
'session:planRollback',
|
|
expect.objectContaining({ sessionId: harness.ctx._sessionId, version: 1 })
|
|
);
|
|
});
|
|
|
|
it('returns error for nonexistent version', async () => {
|
|
harness.ctx._session.ralphTracker = {
|
|
rollbackToVersion: vi.fn(() => ({ success: false, error: 'Version not found' })),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/rollback/999`,
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('not found');
|
|
});
|
|
});
|
|
|
|
// ========== POST /api/sessions/:id/plan/task ==========
|
|
|
|
describe('POST /api/sessions/:id/plan/task', () => {
|
|
it('returns 404 for unknown session', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: '/api/sessions/nonexistent/plan/task',
|
|
payload: { content: 'New task' },
|
|
});
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
|
|
it('returns error when ralph tracker not available', async () => {
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
|
|
payload: { content: 'New task' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
expect(body.error).toContain('Ralph tracker');
|
|
});
|
|
|
|
it('adds a new task to the plan', async () => {
|
|
const addedTask = { id: 'new-1', content: 'New task', status: 'pending', priority: 'P1' };
|
|
harness.ctx._session.ralphTracker = {
|
|
addPlanTask: vi.fn(() => ({ task: addedTask })),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
|
|
payload: { content: 'New task', priority: 'P1' },
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.id).toBe('new-1');
|
|
expect(body.data.content).toBe('New task');
|
|
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
|
'session:planTaskAdded',
|
|
expect.objectContaining({ sessionId: harness.ctx._sessionId })
|
|
);
|
|
});
|
|
|
|
it('rejects invalid body (missing content)', async () => {
|
|
harness.ctx._session.ralphTracker = {
|
|
addPlanTask: vi.fn(),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
|
|
payload: { priority: 'P1' },
|
|
});
|
|
expect(res.statusCode).toBe(400);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(false);
|
|
});
|
|
|
|
it('accepts optional fields', async () => {
|
|
const addedTask = {
|
|
id: 'new-2',
|
|
content: 'Task with deps',
|
|
status: 'pending',
|
|
priority: 'P0',
|
|
dependencies: ['task-1'],
|
|
verificationCriteria: 'Tests pass',
|
|
};
|
|
harness.ctx._session.ralphTracker = {
|
|
addPlanTask: vi.fn(() => ({ task: addedTask })),
|
|
} as never;
|
|
|
|
const res = await harness.app.inject({
|
|
method: 'POST',
|
|
url: `/api/sessions/${harness.ctx._sessionId}/plan/task`,
|
|
payload: {
|
|
content: 'Task with deps',
|
|
priority: 'P0',
|
|
verificationCriteria: 'Tests pass',
|
|
dependencies: ['task-1'],
|
|
insertAfter: 'task-0',
|
|
},
|
|
});
|
|
expect(res.statusCode).toBe(200);
|
|
const body = JSON.parse(res.body);
|
|
expect(body.success).toBe(true);
|
|
expect(body.data.dependencies).toEqual(['task-1']);
|
|
});
|
|
});
|
|
});
|