chore: version packages

This commit is contained in:
arkon
2026-03-01 17:34:09 +01:00
parent 18217268bf
commit b79ad497f4
23 changed files with 3510 additions and 6 deletions
+515
View File
@@ -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');
});
});
});
+365
View File
@@ -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);
});
});
});
+221
View File
@@ -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);
});
});
});
+141
View File
@@ -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();
});
});
});
+410
View File
@@ -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']);
});
});
});
+196
View File
@@ -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');
});
});
});
+474
View File
@@ -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);
});
});
});
+263
View File
@@ -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();
});
});
});
+91
View File
@@ -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');
});
});
});