mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-05 15:09:42 +02:00
chore: version packages
This commit is contained in:
@@ -0,0 +1,515 @@
|
||||
/**
|
||||
* @fileoverview Tests for case-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 { registerCaseRoutes } from '../../src/web/routes/case-routes.js';
|
||||
|
||||
// Mock filesystem modules
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
existsSync: vi.fn(() => false),
|
||||
mkdirSync: vi.fn(),
|
||||
writeFileSync: vi.fn(),
|
||||
readdirSync: vi.fn(() => []),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: vi.fn(async () => []),
|
||||
readFile: vi.fn(async () => {
|
||||
const err = new Error('ENOENT') as NodeJS.ErrnoException;
|
||||
err.code = 'ENOENT';
|
||||
throw err;
|
||||
}),
|
||||
writeFile: vi.fn(async () => {}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/templates/claude-md.js', () => ({
|
||||
generateClaudeMd: vi.fn(() => '# CLAUDE.md\nGenerated content'),
|
||||
}));
|
||||
|
||||
vi.mock('../../src/hooks-config.js', () => ({
|
||||
writeHooksConfig: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
// Import mocked modules for test control
|
||||
import { existsSync, mkdirSync, readdirSync } from 'node:fs';
|
||||
import fs from 'node:fs/promises';
|
||||
|
||||
const mockedExistsSync = vi.mocked(existsSync);
|
||||
const mockedMkdirSync = vi.mocked(mkdirSync);
|
||||
const mockedReaddirSync = vi.mocked(readdirSync);
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
|
||||
describe('case-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerCaseRoutes);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default: existsSync returns false, readFile throws ENOENT
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/cases ==========
|
||||
|
||||
describe('GET /api/cases', () => {
|
||||
it('returns empty array when no cases exist', async () => {
|
||||
mockedReaddir.mockRejectedValue(new Error('ENOENT'));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns cases from CASES_DIR', async () => {
|
||||
mockedReaddir.mockResolvedValue([
|
||||
{ name: 'my-case', isDirectory: () => true },
|
||||
{ name: 'other-case', isDirectory: () => true },
|
||||
{ name: 'readme.txt', isDirectory: () => false },
|
||||
] as never);
|
||||
// No CLAUDE.md exists
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toHaveLength(2);
|
||||
expect(body[0].name).toBe('my-case');
|
||||
expect(body[1].name).toBe('other-case');
|
||||
expect(body[0].hasClaudeMd).toBe(false);
|
||||
});
|
||||
|
||||
it('includes hasClaudeMd flag', async () => {
|
||||
mockedReaddir.mockResolvedValue([
|
||||
{ name: 'case-with-md', isDirectory: () => true },
|
||||
] as never);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body[0].hasClaudeMd).toBe(true);
|
||||
});
|
||||
|
||||
it('includes linked cases from linked-cases.json', async () => {
|
||||
// CASES_DIR readdir returns one case
|
||||
mockedReaddir.mockResolvedValue([
|
||||
{ name: 'regular-case', isDirectory: () => true },
|
||||
] as never);
|
||||
// linked-cases.json is read second (after CASES_DIR readdir)
|
||||
let readCallCount = 0;
|
||||
mockedReadFile.mockImplementation(async () => {
|
||||
readCallCount++;
|
||||
if (readCallCount === 1) {
|
||||
return JSON.stringify({ 'linked-project': '/home/user/projects/linked' });
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
// existsSync: path exists for linked case, CLAUDE.md check
|
||||
mockedExistsSync.mockImplementation((p: string) => {
|
||||
if (typeof p === 'string' && p.includes('linked')) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// Should have both regular and linked cases
|
||||
expect(body.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/cases ==========
|
||||
|
||||
describe('POST /api/cases', () => {
|
||||
it('rejects invalid case name', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases',
|
||||
payload: { name: 'invalid case name!!' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing name', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects path traversal in name', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases',
|
||||
payload: { name: '../etc' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects duplicate case name', async () => {
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases',
|
||||
payload: { name: 'existing-case' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('creates case directory with CLAUDE.md and hooks config', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases',
|
||||
payload: { name: 'new-case', description: 'A new test case' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.case.name).toBe('new-case');
|
||||
expect(body.data.case.path).toContain('new-case');
|
||||
|
||||
// Verify directory creation
|
||||
expect(mockedMkdirSync).toHaveBeenCalled();
|
||||
|
||||
// Verify broadcast
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:created', expect.objectContaining({ name: 'new-case' }));
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/cases/link ==========
|
||||
|
||||
describe('POST /api/cases/link', () => {
|
||||
it('rejects invalid request body', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/link',
|
||||
payload: { name: 'bad name!' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing fields', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/link',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns not found when folder does not exist', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/link',
|
||||
payload: { name: 'my-project', path: '/nonexistent/path' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('rejects when case name already exists in CASES_DIR', async () => {
|
||||
// First call (expandedPath check) returns true, second (casePath check) also returns true
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/link',
|
||||
payload: { name: 'existing-case', path: '/home/user/project' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('already exists');
|
||||
});
|
||||
|
||||
it('links folder successfully', async () => {
|
||||
// expandedPath exists (first call), casePath does not (second call)
|
||||
let callIdx = 0;
|
||||
mockedExistsSync.mockImplementation(() => {
|
||||
callIdx++;
|
||||
return callIdx === 1; // first: folder exists, second: case dir doesn't
|
||||
});
|
||||
// linked-cases.json doesn't exist yet
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/cases/link',
|
||||
payload: { name: 'linked-project', path: '/home/user/project' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.case.name).toBe('linked-project');
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('case:linked', expect.objectContaining({ name: 'linked-project' }));
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/cases/:name ==========
|
||||
|
||||
describe('GET /api/cases/:name', () => {
|
||||
it('returns linked case info', async () => {
|
||||
mockedReadFile.mockResolvedValue(JSON.stringify({ 'my-case': '/home/user/my-case' }) as never);
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.name).toBe('my-case');
|
||||
expect(body.linked).toBe(true);
|
||||
});
|
||||
|
||||
it('returns CASES_DIR case when no linked case found', async () => {
|
||||
// linked-cases.json read fails
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
// case dir exists
|
||||
mockedExistsSync.mockReturnValue(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/regular-case',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.name).toBe('regular-case');
|
||||
});
|
||||
|
||||
it('returns error when case not found anywhere', async () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/cases/:name/fix-plan ==========
|
||||
|
||||
describe('GET /api/cases/:name/fix-plan', () => {
|
||||
it('returns exists=false when no fix plan file', async () => {
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/fix-plan',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.exists).toBe(false);
|
||||
expect(body.content).toBeNull();
|
||||
expect(body.todos).toEqual([]);
|
||||
});
|
||||
|
||||
it('parses fix plan with todos and stats', async () => {
|
||||
const fixPlanContent = [
|
||||
'# Fix Plan',
|
||||
'## High Priority',
|
||||
'- [ ] Fix critical bug',
|
||||
'- [-] Working on auth',
|
||||
'- [x] Setup database',
|
||||
'## Standard',
|
||||
'- [ ] Add logging',
|
||||
'## Completed',
|
||||
'- [x] Initial setup',
|
||||
].join('\n');
|
||||
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
mockedExistsSync.mockImplementation((p: string) => {
|
||||
if (typeof p === 'string' && p.includes('fix_plan')) return true;
|
||||
return false;
|
||||
});
|
||||
|
||||
// Override readFile for the fix plan read
|
||||
mockedReadFile.mockImplementation(async (p: string) => {
|
||||
if (typeof p === 'string' && p.includes('fix_plan')) {
|
||||
return fixPlanContent as never;
|
||||
}
|
||||
throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' });
|
||||
});
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/fix-plan',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.exists).toBe(true);
|
||||
expect(body.todos.length).toBeGreaterThan(0);
|
||||
expect(body.stats.total).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/cases/:caseName/ralph-wizard/files ==========
|
||||
|
||||
describe('GET /api/cases/:caseName/ralph-wizard/files', () => {
|
||||
it('returns error when wizard directory not found', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/ralph-wizard/files',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('rejects path traversal in case name', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/..%2F..%2Fetc/ralph-wizard/files',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns wizard files when directory exists', async () => {
|
||||
mockedExistsSync.mockImplementation((p: string) => {
|
||||
if (typeof p === 'string' && p.includes('ralph-wizard')) return true;
|
||||
if (typeof p === 'string' && p.includes('prompt.md')) return true;
|
||||
if (typeof p === 'string' && p.includes('result.json')) return true;
|
||||
return false;
|
||||
});
|
||||
mockedReaddirSync.mockReturnValue([
|
||||
{ name: 'research', isDirectory: () => true },
|
||||
{ name: 'planner', isDirectory: () => true },
|
||||
] as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/ralph-wizard/files',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.files).toHaveLength(2);
|
||||
expect(body.data.files[0].agentType).toBe('research');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/cases/:caseName/ralph-wizard/file/:filePath ==========
|
||||
|
||||
describe('GET /api/cases/:caseName/ralph-wizard/file/:filePath', () => {
|
||||
it('returns error for missing file', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedReadFile.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' }));
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns markdown file content', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedReadFile.mockResolvedValue('# Research Prompt\nContent here' as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.content).toContain('Research Prompt');
|
||||
expect(body.data.isJson).toBe(false);
|
||||
});
|
||||
|
||||
it('parses JSON file content', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
const jsonContent = JSON.stringify({ plan: 'test plan', steps: [1, 2, 3] });
|
||||
mockedReadFile.mockResolvedValue(jsonContent as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/ralph-wizard/file/planner%2Fresult.json',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.isJson).toBe(true);
|
||||
expect(body.data.parsed.plan).toBe('test plan');
|
||||
});
|
||||
|
||||
it('sets no-cache headers', async () => {
|
||||
mockedExistsSync.mockReturnValue(false);
|
||||
mockedReadFile.mockResolvedValue('content' as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/cases/my-case/ralph-wizard/file/research%2Fprompt.md',
|
||||
});
|
||||
expect(res.headers['cache-control']).toContain('no-store');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* @fileoverview Tests for file-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 { registerFileRoutes } from '../../src/web/routes/file-routes.js';
|
||||
|
||||
// Mock fs/promises for file operations
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
default: {
|
||||
readdir: vi.fn(async () => []),
|
||||
readFile: vi.fn(async () => 'file content'),
|
||||
stat: vi.fn(async () => ({ size: 100, isFile: () => true })),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock realpathSync for symlink resolution
|
||||
vi.mock('node:fs', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs')>();
|
||||
return {
|
||||
...actual,
|
||||
realpathSync: vi.fn((p: string) => p),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock fileStreamManager
|
||||
vi.mock('../../src/file-stream-manager.js', () => ({
|
||||
fileStreamManager: {
|
||||
createStream: vi.fn(async () => ({ success: true, streamId: 'stream-1' })),
|
||||
closeStream: vi.fn(() => true),
|
||||
},
|
||||
}));
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import { realpathSync } from 'node:fs';
|
||||
import { fileStreamManager } from '../../src/file-stream-manager.js';
|
||||
|
||||
const mockedReaddir = vi.mocked(fs.readdir);
|
||||
const mockedReadFile = vi.mocked(fs.readFile);
|
||||
const mockedStat = vi.mocked(fs.stat);
|
||||
const mockedRealpathSync = vi.mocked(realpathSync);
|
||||
const mockedFileStreamManager = vi.mocked(fileStreamManager);
|
||||
|
||||
describe('file-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerFileRoutes);
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Default: realpathSync returns the path unchanged
|
||||
mockedRealpathSync.mockImplementation((p: string) => p as never);
|
||||
// Default stat
|
||||
mockedStat.mockResolvedValue({ size: 100, isFile: () => true } as never);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/files ==========
|
||||
|
||||
describe('GET /api/sessions/:id/files', () => {
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent/files',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns file tree for valid session', async () => {
|
||||
mockedReaddir.mockResolvedValue([
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'package.json', isDirectory: () => false, name_: 'package.json' },
|
||||
] as never);
|
||||
// Nested readdir for src/ returns empty
|
||||
mockedReaddir.mockResolvedValueOnce([
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
{ name: 'package.json', isDirectory: () => false },
|
||||
] as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.root).toBe(harness.ctx._session.workingDir);
|
||||
expect(body.data.tree).toBeDefined();
|
||||
});
|
||||
|
||||
it('respects depth parameter', async () => {
|
||||
mockedReaddir.mockResolvedValue([] as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?depth=2`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('excludes hidden files by default', async () => {
|
||||
mockedReaddir.mockResolvedValue([
|
||||
{ name: '.hidden', isDirectory: () => false },
|
||||
{ name: 'visible.ts', isDirectory: () => false },
|
||||
] as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// Hidden files should be excluded
|
||||
expect(body.data.totalFiles).toBe(1);
|
||||
});
|
||||
|
||||
it('includes hidden files when showHidden=true', async () => {
|
||||
mockedReaddir.mockResolvedValue([
|
||||
{ name: '.hidden', isDirectory: () => false },
|
||||
{ name: 'visible.ts', isDirectory: () => false },
|
||||
] as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?showHidden=true`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.data.totalFiles).toBe(2);
|
||||
});
|
||||
|
||||
it('excludes node_modules and .git directories', async () => {
|
||||
let callCount = 0;
|
||||
mockedReaddir.mockImplementation(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
return [
|
||||
{ name: 'node_modules', isDirectory: () => true },
|
||||
{ name: '.git', isDirectory: () => true },
|
||||
{ name: 'src', isDirectory: () => true },
|
||||
] as never;
|
||||
}
|
||||
return [] as never;
|
||||
});
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/files?showHidden=true`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
// node_modules and .git are in excludeDirs set — only src should be counted
|
||||
expect(body.data.totalDirectories).toBe(1); // only src
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/file-content ==========
|
||||
|
||||
describe('GET /api/sessions/:id/file-content', () => {
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent/file-content?path=test.ts',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns error for missing path parameter', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('Missing path');
|
||||
});
|
||||
|
||||
it('returns text file content', async () => {
|
||||
const fileContent = 'const x = 1;\nconst y = 2;\n';
|
||||
mockedReadFile.mockResolvedValue(fileContent as never);
|
||||
mockedStat.mockResolvedValue({ size: fileContent.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=src/test.ts`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.content).toBe(fileContent);
|
||||
expect(body.data.extension).toBe('ts');
|
||||
});
|
||||
|
||||
it('returns binary metadata for image files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 1024 } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=logo.png`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.type).toBe('image');
|
||||
expect(body.data.url).toContain('file-raw');
|
||||
});
|
||||
|
||||
it('rejects path traversal attempts', async () => {
|
||||
// realpathSync resolves the symlink to a path outside workingDir
|
||||
mockedRealpathSync.mockReturnValue('/etc/passwd' as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=../../etc/passwd`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects files that are too large', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 20 * 1024 * 1024 } as never); // 20MB
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=large-file.txt`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('too large');
|
||||
});
|
||||
|
||||
it('truncates content when exceeding line limit', async () => {
|
||||
const lines = Array.from({ length: 600 }, (_, i) => `line ${i + 1}`).join('\n');
|
||||
mockedReadFile.mockResolvedValue(lines as never);
|
||||
mockedStat.mockResolvedValue({ size: lines.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=big.txt&lines=100`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.truncated).toBe(true);
|
||||
expect(body.data.totalLines).toBe(600);
|
||||
});
|
||||
|
||||
it('returns file not found when realpathSync throws', async () => {
|
||||
mockedRealpathSync.mockImplementation(() => {
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-content?path=nonexistent.ts`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/file-raw ==========
|
||||
|
||||
describe('GET /api/sessions/:id/file-raw', () => {
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent/file-raw?path=test.png',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('returns 400 for missing path parameter', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw`,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('serves raw file with correct content type', async () => {
|
||||
const content = Buffer.from('fake png data');
|
||||
mockedReadFile.mockResolvedValue(content as never);
|
||||
mockedStat.mockResolvedValue({ size: content.length } as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=image.png`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers['content-type']).toBe('image/png');
|
||||
});
|
||||
|
||||
it('rejects path traversal in raw file serving', async () => {
|
||||
mockedRealpathSync.mockReturnValue('/etc/shadow' as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=../../etc/shadow`,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects overly large raw files', async () => {
|
||||
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024 } as never); // 100MB
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=huge.bin`,
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/sessions/:id/tail-file/:streamId ==========
|
||||
|
||||
describe('DELETE /api/sessions/:id/tail-file/:streamId', () => {
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/sessions/nonexistent/tail-file/stream-1',
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('closes an existing stream', async () => {
|
||||
mockedFileStreamManager.closeStream.mockReturnValue(true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/tail-file/stream-1`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(mockedFileStreamManager.closeStream).toHaveBeenCalledWith('stream-1');
|
||||
});
|
||||
|
||||
it('returns false for unknown stream', async () => {
|
||||
mockedFileStreamManager.closeStream.mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/tail-file/nonexistent`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* @fileoverview Tests for hook-event-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 { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js';
|
||||
|
||||
describe('hook-event-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerHookEventRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== POST /api/hook-event ==========
|
||||
|
||||
describe('POST /api/hook-event', () => {
|
||||
it('accepts a valid hook event and broadcasts it', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'stop',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: null,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith(
|
||||
'hook:stop',
|
||||
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
|
||||
);
|
||||
});
|
||||
|
||||
it('sends push notifications for hook events', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'idle_prompt',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: null,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.sendPushNotifications).toHaveBeenCalledWith(
|
||||
'hook:idle_prompt',
|
||||
expect.objectContaining({ sessionId: harness.ctx._sessionId }),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns 404 for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'stop',
|
||||
sessionId: 'nonexistent-session',
|
||||
data: null,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toBeDefined();
|
||||
});
|
||||
|
||||
it('rejects invalid event type', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'invalid_event_type',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: null,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing sessionId', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'stop',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('signals respawn controller on stop event', async () => {
|
||||
const mockController = {
|
||||
signalStopHook: vi.fn(),
|
||||
signalElicitation: vi.fn(),
|
||||
signalIdlePrompt: vi.fn(),
|
||||
};
|
||||
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'stop',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: null,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockController.signalStopHook).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signals respawn controller on elicitation_dialog event', async () => {
|
||||
const mockController = {
|
||||
signalStopHook: vi.fn(),
|
||||
signalElicitation: vi.fn(),
|
||||
signalIdlePrompt: vi.fn(),
|
||||
};
|
||||
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'elicitation_dialog',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: null,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockController.signalElicitation).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('signals respawn controller on idle_prompt event', async () => {
|
||||
const mockController = {
|
||||
signalStopHook: vi.fn(),
|
||||
signalElicitation: vi.fn(),
|
||||
signalIdlePrompt: vi.fn(),
|
||||
};
|
||||
harness.ctx.respawnControllers.set(harness.ctx._sessionId, mockController as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'idle_prompt',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: null,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockController.signalIdlePrompt).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records hook event in run summary tracker', async () => {
|
||||
const mockTracker = { recordHookEvent: vi.fn() };
|
||||
harness.ctx.runSummaryTrackers.set(harness.ctx._sessionId, mockTracker as never);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'stop',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: { tool_name: 'bash' },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(mockTracker.recordHookEvent).toHaveBeenCalledWith(
|
||||
'stop',
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('starts transcript watcher when transcript_path is provided', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'stop',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: { transcript_path: '/home/user/.claude/transcript.jsonl' },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(harness.ctx.startTranscriptWatcher).toHaveBeenCalledWith(
|
||||
harness.ctx._sessionId,
|
||||
'/home/user/.claude/transcript.jsonl',
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts valid data payload with extra fields', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/hook-event',
|
||||
payload: {
|
||||
event: 'permission_prompt',
|
||||
sessionId: harness.ctx._sessionId,
|
||||
data: { tool_name: 'bash', command: 'ls -la' },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* @fileoverview Tests for mux-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 { registerMuxRoutes } from '../../src/web/routes/mux-routes.js';
|
||||
|
||||
describe('mux-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Add mux methods that mux-routes needs but mock-route-context doesn't provide
|
||||
harness = await createRouteTestHarness(registerMuxRoutes);
|
||||
harness.ctx.mux.getSessionsWithStats = vi.fn(async () => [
|
||||
{ name: 'codeman-abc', pid: 1234, created: Date.now() },
|
||||
]);
|
||||
harness.ctx.mux.isAvailable = vi.fn(() => true);
|
||||
harness.ctx.mux.reconcileSessions = vi.fn(async () => ({
|
||||
orphaned: [],
|
||||
missing: [],
|
||||
reconciled: 0,
|
||||
}));
|
||||
harness.ctx.mux.startStatsCollection = vi.fn();
|
||||
harness.ctx.mux.stopStatsCollection = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/mux-sessions ==========
|
||||
|
||||
describe('GET /api/mux-sessions', () => {
|
||||
it('returns mux sessions and availability', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/mux-sessions',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.muxAvailable).toBe(true);
|
||||
expect(body.sessions).toHaveLength(1);
|
||||
expect(body.sessions[0].name).toBe('codeman-abc');
|
||||
});
|
||||
|
||||
it('returns muxAvailable false when mux is unavailable', async () => {
|
||||
harness.ctx.mux.isAvailable = vi.fn(() => false);
|
||||
harness.ctx.mux.getSessionsWithStats = vi.fn(async () => []);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/mux-sessions',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.muxAvailable).toBe(false);
|
||||
expect(body.sessions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/mux-sessions/:sessionId ==========
|
||||
|
||||
describe('DELETE /api/mux-sessions/:sessionId', () => {
|
||||
it('kills a mux session and returns success', async () => {
|
||||
harness.ctx.mux.killSession = vi.fn(async () => true);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/mux-sessions/codeman-abc',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.mux.killSession).toHaveBeenCalledWith('codeman-abc');
|
||||
});
|
||||
|
||||
it('returns success false when kill fails', async () => {
|
||||
harness.ctx.mux.killSession = vi.fn(async () => false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/mux-sessions/nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/mux-sessions/reconcile ==========
|
||||
|
||||
describe('POST /api/mux-sessions/reconcile', () => {
|
||||
it('returns reconciliation result', async () => {
|
||||
const reconcileResult = { orphaned: ['old-session'], missing: [], reconciled: 1 };
|
||||
harness.ctx.mux.reconcileSessions = vi.fn(async () => reconcileResult);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/mux-sessions/reconcile',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.orphaned).toEqual(['old-session']);
|
||||
expect(body.reconciled).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/mux-sessions/stats/start ==========
|
||||
|
||||
describe('POST /api/mux-sessions/stats/start', () => {
|
||||
it('starts stats collection', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/mux-sessions/stats/start',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.mux.startStatsCollection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/mux-sessions/stats/stop ==========
|
||||
|
||||
describe('POST /api/mux-sessions/stats/stop', () => {
|
||||
it('stops stats collection', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/mux-sessions/stats/stop',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.mux.stopStatsCollection).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,410 @@
|
||||
/**
|
||||
* @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(200);
|
||||
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(200);
|
||||
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', 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),
|
||||
} 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).toHaveLength(2);
|
||||
expect(body.data[1].version).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(200);
|
||||
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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* @fileoverview Tests for push-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 { registerPushRoutes } from '../../src/web/routes/push-routes.js';
|
||||
|
||||
describe('push-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerPushRoutes);
|
||||
// Push routes expect ctx.pushStore to be a real object (not null)
|
||||
harness.ctx.pushStore = {
|
||||
getPublicKey: vi.fn(() => 'test-vapid-public-key-base64'),
|
||||
addSubscription: vi.fn((record: Record<string, unknown>) => record),
|
||||
updatePreferences: vi.fn(() => true),
|
||||
removeSubscription: vi.fn(() => true),
|
||||
} as never;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/push/vapid-key ==========
|
||||
|
||||
describe('GET /api/push/vapid-key', () => {
|
||||
it('returns the VAPID public key', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/push/vapid-key',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.publicKey).toBe('test-vapid-public-key-base64');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/push/subscribe ==========
|
||||
|
||||
describe('POST /api/push/subscribe', () => {
|
||||
it('creates a push subscription', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/push/subscribe',
|
||||
payload: {
|
||||
endpoint: 'https://push.example.com/send/abc123',
|
||||
keys: {
|
||||
p256dh: 'test-p256dh-key',
|
||||
auth: 'test-auth-key',
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.id).toBeDefined();
|
||||
expect(harness.ctx.pushStore.addSubscription).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
endpoint: 'https://push.example.com/send/abc123',
|
||||
keys: { p256dh: 'test-p256dh-key', auth: 'test-auth-key' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts optional userAgent and pushPreferences', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/push/subscribe',
|
||||
payload: {
|
||||
endpoint: 'https://push.example.com/send/abc123',
|
||||
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
|
||||
userAgent: 'TestBrowser/1.0',
|
||||
pushPreferences: { 'session:idle': true, 'session:error': false },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.pushStore.addSubscription).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
userAgent: 'TestBrowser/1.0',
|
||||
pushPreferences: { 'session:idle': true, 'session:error': false },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects invalid subscription (missing endpoint)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/push/subscribe',
|
||||
payload: {
|
||||
keys: { p256dh: 'test-p256dh', auth: 'test-auth' },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid subscription (missing keys)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/push/subscribe',
|
||||
payload: {
|
||||
endpoint: 'https://push.example.com/send/abc123',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== PUT /api/push/subscribe/:id ==========
|
||||
|
||||
describe('PUT /api/push/subscribe/:id', () => {
|
||||
it('updates push preferences for a subscription', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/push/subscribe/sub-123',
|
||||
payload: {
|
||||
pushPreferences: { 'session:idle': true, 'session:error': true },
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.pushStore.updatePreferences).toHaveBeenCalledWith('sub-123', {
|
||||
'session:idle': true,
|
||||
'session:error': true,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns 404 for unknown subscription', async () => {
|
||||
(harness.ctx.pushStore.updatePreferences as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/push/subscribe/nonexistent',
|
||||
payload: {
|
||||
pushPreferences: { 'session:idle': true },
|
||||
},
|
||||
});
|
||||
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 (missing pushPreferences)', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/push/subscribe/sub-123',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/push/subscribe/:id ==========
|
||||
|
||||
describe('DELETE /api/push/subscribe/:id', () => {
|
||||
it('removes a push subscription', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/push/subscribe/sub-123',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.pushStore.removeSubscription).toHaveBeenCalledWith('sub-123');
|
||||
});
|
||||
|
||||
it('returns 404 for unknown subscription', async () => {
|
||||
(harness.ctx.pushStore.removeSubscription as ReturnType<typeof vi.fn>).mockReturnValue(false);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/push/subscribe/nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toContain('not found');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,474 @@
|
||||
/**
|
||||
* @fileoverview Tests for ralph-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 { registerRalphRoutes } from '../../src/web/routes/ralph-routes.js';
|
||||
|
||||
/** Create a mock ralph tracker with all methods used by ralph-routes */
|
||||
function createMockRalphTracker() {
|
||||
return {
|
||||
enabled: false,
|
||||
enable: vi.fn(),
|
||||
disable: vi.fn(),
|
||||
reset: vi.fn(),
|
||||
fullReset: vi.fn(),
|
||||
enableAutoEnable: vi.fn(),
|
||||
disableAutoEnable: vi.fn(),
|
||||
startLoop: vi.fn(),
|
||||
setMaxIterations: vi.fn(),
|
||||
resetCircuitBreaker: vi.fn(),
|
||||
generateFixPlanMarkdown: vi.fn(() => '# Fix Plan\n\n- [ ] Task 1\n'),
|
||||
importFixPlanMarkdown: vi.fn(() => 3),
|
||||
lastStatusBlock: null,
|
||||
circuitBreakerStatus: { state: 'CLOSED', consecutiveFailures: 0 },
|
||||
cumulativeStats: { totalIterations: 0, totalSuccess: 0 },
|
||||
exitGateMet: false,
|
||||
todos: [
|
||||
{ id: '1', content: 'Task 1', done: false },
|
||||
{ id: '2', content: 'Task 2', done: true },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe('ralph-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerRalphRoutes);
|
||||
// Add updateRalphEnabled to mock mux (used by ralph-config route)
|
||||
(harness.ctx.mux as Record<string, unknown>).updateRalphEnabled = vi.fn();
|
||||
// Set up mock ralph tracker on the session
|
||||
(harness.ctx._session as Record<string, unknown>).ralphTracker = createMockRalphTracker();
|
||||
(harness.ctx._session as Record<string, unknown>).ralphLoopState = { enabled: false };
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/ralph-config ==========
|
||||
|
||||
describe('POST /api/sessions/:id/ralph-config', () => {
|
||||
it('returns success for valid config', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
});
|
||||
|
||||
it('enables ralph tracker', async () => {
|
||||
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(tracker.enable).toHaveBeenCalled();
|
||||
expect(tracker.enableAutoEnable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('disables ralph tracker', async () => {
|
||||
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { enabled: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(tracker.disable).toHaveBeenCalled();
|
||||
expect(tracker.disableAutoEnable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists ralph enabled state via mux', async () => {
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect((harness.ctx.mux as Record<string, unknown>).updateRalphEnabled).toHaveBeenCalledWith(
|
||||
harness.ctx._sessionId,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('broadcasts ralph loop update', async () => {
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(harness.ctx.broadcast).toHaveBeenCalledWith('session:ralphLoopUpdate', {
|
||||
sessionId: harness.ctx._sessionId,
|
||||
state: expect.anything(),
|
||||
});
|
||||
});
|
||||
|
||||
it('handles reset option', async () => {
|
||||
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { reset: true },
|
||||
});
|
||||
expect(tracker.reset).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('configures completion phrase and max iterations', async () => {
|
||||
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { completionPhrase: 'DONE', maxIterations: 10 },
|
||||
});
|
||||
expect(tracker.startLoop).toHaveBeenCalledWith('DONE', 10);
|
||||
});
|
||||
|
||||
it('sets max iterations independently', async () => {
|
||||
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { maxIterations: 50 },
|
||||
});
|
||||
expect(tracker.setMaxIterations).toHaveBeenCalledWith(50);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/ralph-config',
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects opencode sessions', async () => {
|
||||
harness.ctx._session.mode = 'opencode';
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid request body', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { enabled: 'not-boolean' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('handles disableAutoEnable flag', async () => {
|
||||
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
|
||||
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { disableAutoEnable: true },
|
||||
});
|
||||
expect(tracker.disableAutoEnable).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('persists session state after config change', async () => {
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-config`,
|
||||
payload: { enabled: true },
|
||||
});
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/ralph-circuit-breaker/reset ==========
|
||||
|
||||
describe('POST /api/sessions/:id/ralph-circuit-breaker/reset', () => {
|
||||
it('resets circuit breaker for valid session', async () => {
|
||||
const tracker = (harness.ctx._session as Record<string, unknown>).ralphTracker as ReturnType<typeof createMockRalphTracker>;
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-circuit-breaker/reset`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(tracker.resetCircuitBreaker).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/ralph-circuit-breaker/reset',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/ralph-status ==========
|
||||
|
||||
describe('GET /api/sessions/:id/ralph-status', () => {
|
||||
it('returns ralph status data', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-status`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toBeDefined();
|
||||
expect(body.data.circuitBreaker).toBeDefined();
|
||||
expect(body.data.cumulativeStats).toBeDefined();
|
||||
expect(body.data.exitGateMet).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent/ralph-status',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/sessions/:id/fix-plan ==========
|
||||
|
||||
describe('GET /api/sessions/:id/fix-plan', () => {
|
||||
it('returns generated fix plan markdown', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.content).toContain('# Fix Plan');
|
||||
expect(body.data.todoCount).toBe(2);
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/sessions/nonexistent/fix-plan',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/fix-plan/import ==========
|
||||
|
||||
describe('POST /api/sessions/:id/fix-plan/import', () => {
|
||||
it('imports fix plan content', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
|
||||
payload: { content: '# Plan\n\n- [ ] Task A\n- [ ] Task B\n' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data.importedCount).toBe(3);
|
||||
});
|
||||
|
||||
it('persists session state after import', async () => {
|
||||
await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
|
||||
payload: { content: '- [ ] Task' },
|
||||
});
|
||||
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/fix-plan/import',
|
||||
payload: { content: 'test' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing content field', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/import`,
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/ralph-prompt/write ==========
|
||||
|
||||
describe('POST /api/sessions/:id/ralph-prompt/write', () => {
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/ralph-prompt/write',
|
||||
payload: { content: 'test prompt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error when session has no working directory', async () => {
|
||||
harness.ctx._session.workingDir = '';
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`,
|
||||
payload: { content: 'test prompt' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing content field', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/ralph-prompt/write`,
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/fix-plan/write ==========
|
||||
|
||||
describe('POST /api/sessions/:id/fix-plan/write', () => {
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/fix-plan/write',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error when session has no working directory', async () => {
|
||||
harness.ctx._session.workingDir = '';
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/write`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/sessions/:id/fix-plan/read ==========
|
||||
|
||||
describe('POST /api/sessions/:id/fix-plan/read', () => {
|
||||
it('returns error for unknown session', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/sessions/nonexistent/fix-plan/read',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('returns error when session has no working directory', async () => {
|
||||
harness.ctx._session.workingDir = '';
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/sessions/${harness.ctx._sessionId}/fix-plan/read`,
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/ralph-loop/start ==========
|
||||
|
||||
describe('POST /api/ralph-loop/start', () => {
|
||||
it('rejects invalid request body', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ralph-loop/start',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects when max sessions reached', async () => {
|
||||
// Fill up sessions to max
|
||||
for (let i = 0; i < 50; i++) {
|
||||
harness.ctx.sessions.set(`session-${i}`, {} as never);
|
||||
}
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ralph-loop/start',
|
||||
payload: {
|
||||
taskDescription: 'test task',
|
||||
completionPhrase: 'DONE',
|
||||
caseName: 'testcase',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid case name format', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/ralph-loop/start',
|
||||
payload: {
|
||||
taskDescription: 'test task',
|
||||
caseName: '../escape-path',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* @fileoverview Tests for scheduled-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 { registerScheduledRoutes } from '../../src/web/routes/scheduled-routes.js';
|
||||
|
||||
describe('scheduled-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerScheduledRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/scheduled ==========
|
||||
|
||||
describe('GET /api/scheduled', () => {
|
||||
it('returns empty array when no scheduled runs exist', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/scheduled',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns all scheduled runs', async () => {
|
||||
const run1 = {
|
||||
id: 'run-1',
|
||||
prompt: 'test prompt 1',
|
||||
workingDir: '/tmp/test',
|
||||
durationMinutes: 60,
|
||||
startedAt: Date.now(),
|
||||
endAt: Date.now() + 3600000,
|
||||
status: 'running' as const,
|
||||
sessionId: null,
|
||||
completedTasks: 0,
|
||||
totalCost: 0,
|
||||
logs: [],
|
||||
};
|
||||
const run2 = {
|
||||
id: 'run-2',
|
||||
prompt: 'test prompt 2',
|
||||
workingDir: '/tmp/test2',
|
||||
durationMinutes: 120,
|
||||
startedAt: Date.now(),
|
||||
endAt: Date.now() + 7200000,
|
||||
status: 'completed' as const,
|
||||
sessionId: 'sess-1',
|
||||
completedTasks: 5,
|
||||
totalCost: 1.5,
|
||||
logs: ['started'],
|
||||
};
|
||||
harness.ctx.scheduledRuns.set('run-1', run1);
|
||||
harness.ctx.scheduledRuns.set('run-2', run2);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/scheduled',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== POST /api/scheduled ==========
|
||||
|
||||
describe('POST /api/scheduled', () => {
|
||||
it('creates a scheduled run with valid input', async () => {
|
||||
const mockRun = {
|
||||
id: 'new-run',
|
||||
prompt: 'do something',
|
||||
workingDir: '/tmp',
|
||||
durationMinutes: 60,
|
||||
startedAt: Date.now(),
|
||||
endAt: Date.now() + 3600000,
|
||||
status: 'running' as const,
|
||||
sessionId: null,
|
||||
completedTasks: 0,
|
||||
totalCost: 0,
|
||||
logs: [],
|
||||
};
|
||||
harness.ctx.startScheduledRun = vi.fn(async () => mockRun);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/scheduled',
|
||||
payload: {
|
||||
prompt: 'do something',
|
||||
durationMinutes: 60,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.run.id).toBe('new-run');
|
||||
});
|
||||
|
||||
it('rejects empty prompt', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/scheduled',
|
||||
payload: {
|
||||
prompt: '',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects missing prompt', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/scheduled',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects invalid workingDir with shell metacharacters', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/scheduled',
|
||||
payload: {
|
||||
prompt: 'test prompt',
|
||||
workingDir: '/tmp/test;rm -rf /',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts optional durationMinutes', async () => {
|
||||
const mockRun = {
|
||||
id: 'run-default-duration',
|
||||
prompt: 'test',
|
||||
workingDir: process.cwd(),
|
||||
durationMinutes: 60,
|
||||
startedAt: Date.now(),
|
||||
endAt: Date.now() + 3600000,
|
||||
status: 'running' as const,
|
||||
sessionId: null,
|
||||
completedTasks: 0,
|
||||
totalCost: 0,
|
||||
logs: [],
|
||||
};
|
||||
harness.ctx.startScheduledRun = vi.fn(async () => mockRun);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/scheduled',
|
||||
payload: {
|
||||
prompt: 'test',
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
// Should default to 60 minutes
|
||||
expect(harness.ctx.startScheduledRun).toHaveBeenCalledWith(
|
||||
'test',
|
||||
expect.any(String),
|
||||
60,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ========== DELETE /api/scheduled/:id ==========
|
||||
|
||||
describe('DELETE /api/scheduled/:id', () => {
|
||||
it('deletes an existing scheduled run', async () => {
|
||||
const run = {
|
||||
id: 'run-to-delete',
|
||||
prompt: 'test',
|
||||
workingDir: '/tmp',
|
||||
durationMinutes: 60,
|
||||
startedAt: Date.now(),
|
||||
endAt: Date.now() + 3600000,
|
||||
status: 'running' as const,
|
||||
sessionId: null,
|
||||
completedTasks: 0,
|
||||
totalCost: 0,
|
||||
logs: [],
|
||||
};
|
||||
harness.ctx.scheduledRuns.set('run-to-delete', run);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/scheduled/run-to-delete',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(harness.ctx.stopScheduledRun).toHaveBeenCalledWith('run-to-delete');
|
||||
});
|
||||
|
||||
it('returns error for nonexistent scheduled run', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'DELETE',
|
||||
url: '/api/scheduled/nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/scheduled/:id ==========
|
||||
|
||||
describe('GET /api/scheduled/:id', () => {
|
||||
it('returns an existing scheduled run', async () => {
|
||||
const run = {
|
||||
id: 'run-1',
|
||||
prompt: 'test prompt',
|
||||
workingDir: '/tmp/test',
|
||||
durationMinutes: 60,
|
||||
startedAt: Date.now(),
|
||||
endAt: Date.now() + 3600000,
|
||||
status: 'running' as const,
|
||||
sessionId: null,
|
||||
completedTasks: 0,
|
||||
totalCost: 0,
|
||||
logs: [],
|
||||
};
|
||||
harness.ctx.scheduledRuns.set('run-1', run);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/scheduled/run-1',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.id).toBe('run-1');
|
||||
expect(body.prompt).toBe('test prompt');
|
||||
});
|
||||
|
||||
it('returns error for nonexistent scheduled run', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/scheduled/nonexistent',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(false);
|
||||
expect(body.error).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @fileoverview Tests for team-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 { registerTeamRoutes } from '../../src/web/routes/team-routes.js';
|
||||
|
||||
describe('team-routes', () => {
|
||||
let harness: RouteTestHarness;
|
||||
|
||||
beforeEach(async () => {
|
||||
harness = await createRouteTestHarness(registerTeamRoutes);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await harness.app.close();
|
||||
});
|
||||
|
||||
// ========== GET /api/teams ==========
|
||||
|
||||
describe('GET /api/teams', () => {
|
||||
it('returns empty array when no teams exist', async () => {
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns teams from teamWatcher', async () => {
|
||||
const mockTeams = [
|
||||
{ name: 'team-alpha', leadSessionId: 'session-1', members: [{ name: 'lead', agentType: 'team-lead' }] },
|
||||
{ name: 'team-beta', leadSessionId: 'session-2', members: [] },
|
||||
];
|
||||
harness.ctx.teamWatcher.getTeams.mockReturnValue(mockTeams);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[0].name).toBe('team-alpha');
|
||||
expect(body.data[1].name).toBe('team-beta');
|
||||
});
|
||||
});
|
||||
|
||||
// ========== GET /api/teams/:name/tasks ==========
|
||||
|
||||
describe('GET /api/teams/:name/tasks', () => {
|
||||
it('returns empty array for unknown team', async () => {
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams/nonexistent/tasks',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toEqual([]);
|
||||
});
|
||||
|
||||
it('returns tasks for a team', async () => {
|
||||
const mockTasks = [
|
||||
{ id: '1', subject: 'Implement feature', status: 'in_progress', owner: 'dev-1' },
|
||||
{ id: '2', subject: 'Write tests', status: 'pending', owner: null },
|
||||
];
|
||||
harness.ctx.teamWatcher.getTeamTasks.mockReturnValue(mockTasks);
|
||||
|
||||
const res = await harness.app.inject({
|
||||
method: 'GET',
|
||||
url: '/api/teams/team-alpha/tasks',
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = JSON.parse(res.body);
|
||||
expect(body.success).toBe(true);
|
||||
expect(body.data).toHaveLength(2);
|
||||
expect(body.data[0].subject).toBe('Implement feature');
|
||||
expect(body.data[1].status).toBe('pending');
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user