mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Full product rename across 109 files (~834 occurrences): - Env vars: CLAUDEMAN_* → CODEMAN_* - Data dirs: ~/.claudeman/ → ~/.codeman/, ~/claudeman-cases/ → ~/codeman-cases/ - tmux prefix: claudeman- → codeman- - localStorage: claudeman-* → codeman-* - Package/CLI: claudeman → codeman - GitHub repo: Ark0N/Claudeman → Ark0N/Codeman - systemd service: claudeman-web → codeman-web - Class: ClaudemanApp → CodemanApp Migration infrastructure for seamless transition: - state-store.ts: auto-migrates data directories on startup - tmux-manager.ts: dual-prefix detection (legacy claudeman- sessions) - app.js: localStorage key migration (preserves old keys) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
361 lines
13 KiB
TypeScript
361 lines
13 KiB
TypeScript
import { describe, it, expect, beforeAll, afterAll, afterEach } from 'vitest';
|
|
import { WebServer } from '../src/web/server.js';
|
|
import { existsSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
import { homedir } from 'node:os';
|
|
|
|
const TEST_PORT = 3115;
|
|
const CASES_DIR = join(homedir(), 'codeman-cases');
|
|
|
|
/**
|
|
* Integration tests for complete user flows
|
|
* These tests verify end-to-end scenarios that users would perform
|
|
*/
|
|
describe('Integration Flows', () => {
|
|
let server: WebServer;
|
|
let baseUrl: string;
|
|
const createdCases: string[] = [];
|
|
const createdSessions: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
server = new WebServer(TEST_PORT, false, true);
|
|
await server.start();
|
|
baseUrl = `http://localhost:${TEST_PORT}`;
|
|
});
|
|
|
|
afterEach(() => {
|
|
// Clean up cases created during this test
|
|
while (createdCases.length > 0) {
|
|
const caseName = createdCases.pop()!;
|
|
const casePath = join(CASES_DIR, caseName);
|
|
if (existsSync(casePath)) {
|
|
rmSync(casePath, { recursive: true, force: true });
|
|
}
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Cleanup sessions
|
|
for (const sessionId of createdSessions) {
|
|
try {
|
|
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
|
} catch {}
|
|
}
|
|
await server.stop();
|
|
}, 60000);
|
|
|
|
describe('Quick Start to Interactive Session Flow', () => {
|
|
it('should complete full quick start flow: create case -> start session -> interactive mode', async () => {
|
|
const caseName = `flow-test-${Date.now()}`;
|
|
createdCases.push(caseName);
|
|
|
|
// Step 1: Quick start (creates case + session + starts interactive)
|
|
const quickStartRes = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName }),
|
|
});
|
|
const quickStartData = await quickStartRes.json();
|
|
|
|
expect(quickStartData.success).toBe(true);
|
|
expect(quickStartData.sessionId).toBeDefined();
|
|
expect(quickStartData.caseName).toBe(caseName);
|
|
createdSessions.push(quickStartData.sessionId);
|
|
|
|
// Step 2: Verify session is in interactive mode
|
|
const sessionRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`);
|
|
const sessionData = await sessionRes.json();
|
|
|
|
expect(sessionData.id).toBe(quickStartData.sessionId);
|
|
expect(sessionData.workingDir).toContain(caseName);
|
|
expect(['busy', 'idle', 'running']).toContain(sessionData.status); // May transition quickly in test mode
|
|
|
|
// Step 3: Verify case was created with CLAUDE.md
|
|
const caseRes = await fetch(`${baseUrl}/api/cases/${caseName}`);
|
|
const caseData = await caseRes.json();
|
|
|
|
expect(caseData.name).toBe(caseName);
|
|
expect(caseData.hasClaudeMd).toBe(true);
|
|
});
|
|
|
|
it('should reuse existing case when quick starting with existing case name', async () => {
|
|
const caseName = `reuse-test-${Date.now()}`;
|
|
createdCases.push(caseName);
|
|
|
|
// First quick start - creates the case
|
|
const firstRes = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName }),
|
|
});
|
|
const firstData = await firstRes.json();
|
|
expect(firstData.success).toBe(true);
|
|
createdSessions.push(firstData.sessionId);
|
|
|
|
// Delete the session but keep the case
|
|
await fetch(`${baseUrl}/api/sessions/${firstData.sessionId}`, { method: 'DELETE' });
|
|
|
|
// Second quick start - should reuse the case
|
|
const secondRes = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName }),
|
|
});
|
|
const secondData = await secondRes.json();
|
|
|
|
expect(secondData.success).toBe(true);
|
|
expect(secondData.caseName).toBe(caseName);
|
|
expect(secondData.casePath).toBe(firstData.casePath);
|
|
createdSessions.push(secondData.sessionId);
|
|
});
|
|
});
|
|
|
|
describe('Manual Case and Session Flow', () => {
|
|
it('should complete manual flow: create case -> create session -> start interactive', async () => {
|
|
const caseName = `manual-flow-${Date.now()}`;
|
|
createdCases.push(caseName);
|
|
|
|
// Step 1: Create case
|
|
const caseRes = await fetch(`${baseUrl}/api/cases`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ name: caseName, description: 'Test case for manual flow' }),
|
|
});
|
|
const caseData = await caseRes.json();
|
|
expect(caseData.success).toBe(true);
|
|
|
|
// Step 2: Create session with case directory
|
|
const sessionRes = await fetch(`${baseUrl}/api/sessions`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ workingDir: caseData.data.case.path }),
|
|
});
|
|
const sessionData = await sessionRes.json();
|
|
expect(sessionData.success).toBe(true);
|
|
createdSessions.push(sessionData.session.id);
|
|
|
|
// Step 3: Start interactive mode
|
|
const interactiveRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}/interactive`, {
|
|
method: 'POST',
|
|
});
|
|
const interactiveData = await interactiveRes.json();
|
|
expect(interactiveData.success).toBe(true);
|
|
|
|
// Verify session state
|
|
const verifyRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}`);
|
|
const verifyData = await verifyRes.json();
|
|
expect(['busy', 'idle', 'running']).toContain(verifyData.status);
|
|
expect(verifyData.workingDir).toContain(caseName);
|
|
});
|
|
});
|
|
|
|
describe('Session Input Flow', () => {
|
|
it('should handle input to interactive session', async () => {
|
|
const caseName = `input-flow-${Date.now()}`;
|
|
createdCases.push(caseName);
|
|
|
|
// Quick start to get interactive session
|
|
const quickStartRes = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName }),
|
|
});
|
|
const quickStartData = await quickStartRes.json();
|
|
expect(quickStartData.success).toBe(true);
|
|
createdSessions.push(quickStartData.sessionId);
|
|
|
|
// Wait for Claude to start up
|
|
await new Promise(resolve => setTimeout(resolve, 2000));
|
|
|
|
// Send input
|
|
const inputRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/input`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ input: '/help\n' }),
|
|
});
|
|
const inputData = await inputRes.json();
|
|
expect(inputData.success).toBe(true);
|
|
|
|
// Wait for response
|
|
await new Promise(resolve => setTimeout(resolve, 1000));
|
|
|
|
// Check terminal buffer has content
|
|
const terminalRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/terminal`);
|
|
const terminalData = await terminalRes.json();
|
|
expect(terminalData.terminalBuffer.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should handle terminal resize', async () => {
|
|
const caseName = `resize-flow-${Date.now()}`;
|
|
createdCases.push(caseName);
|
|
|
|
// Quick start
|
|
const quickStartRes = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName }),
|
|
});
|
|
const quickStartData = await quickStartRes.json();
|
|
expect(quickStartData.success).toBe(true);
|
|
createdSessions.push(quickStartData.sessionId);
|
|
|
|
// Resize terminal
|
|
const resizeRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/resize`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ cols: 200, rows: 50 }),
|
|
});
|
|
const resizeData = await resizeRes.json();
|
|
expect(resizeData.success).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('Session Cleanup Flow', () => {
|
|
it('should properly stop and delete session', async () => {
|
|
const caseName = `cleanup-flow-${Date.now()}`;
|
|
createdCases.push(caseName);
|
|
|
|
// Create session via quick start
|
|
const quickStartRes = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName }),
|
|
});
|
|
const quickStartData = await quickStartRes.json();
|
|
expect(quickStartData.success).toBe(true);
|
|
|
|
// Delete the session
|
|
const deleteRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`, {
|
|
method: 'DELETE',
|
|
});
|
|
const deleteData = await deleteRes.json();
|
|
expect(deleteData.success).toBe(true);
|
|
|
|
// Verify session is gone
|
|
const verifyRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`);
|
|
const verifyData = await verifyRes.json();
|
|
expect(verifyData.error).toBe('Session not found');
|
|
});
|
|
});
|
|
|
|
describe('Full Status API Flow', () => {
|
|
it('should return consistent state across status API', async () => {
|
|
const caseName = `status-flow-${Date.now()}`;
|
|
createdCases.push(caseName);
|
|
|
|
// Create a session
|
|
const quickStartRes = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName }),
|
|
});
|
|
const quickStartData = await quickStartRes.json();
|
|
expect(quickStartData.success).toBe(true);
|
|
createdSessions.push(quickStartData.sessionId);
|
|
|
|
// Get full status
|
|
const statusRes = await fetch(`${baseUrl}/api/status`);
|
|
const statusData = await statusRes.json();
|
|
|
|
expect(statusData.sessions).toBeDefined();
|
|
expect(Array.isArray(statusData.sessions)).toBe(true);
|
|
expect(statusData.scheduledRuns).toBeDefined();
|
|
expect(statusData.respawnStatus).toBeDefined();
|
|
expect(statusData.timestamp).toBeDefined();
|
|
|
|
// Verify our session is in the list
|
|
const ourSession = statusData.sessions.find((s: any) => s.id === quickStartData.sessionId);
|
|
expect(ourSession).toBeDefined();
|
|
expect(ourSession.workingDir).toContain(caseName);
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('SSE Event Flow', () => {
|
|
let server: WebServer;
|
|
let baseUrl: string;
|
|
const createdCases: string[] = [];
|
|
const createdSessions: string[] = [];
|
|
|
|
beforeAll(async () => {
|
|
server = new WebServer(TEST_PORT + 1, false, true);
|
|
await server.start();
|
|
baseUrl = `http://localhost:${TEST_PORT + 1}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const sessionId of createdSessions) {
|
|
try {
|
|
await fetch(`${baseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
|
|
} catch {}
|
|
}
|
|
for (const caseName of createdCases) {
|
|
const casePath = join(CASES_DIR, caseName);
|
|
if (existsSync(casePath)) {
|
|
rmSync(casePath, { recursive: true, force: true });
|
|
}
|
|
}
|
|
await server.stop();
|
|
}, 60000);
|
|
|
|
it('should receive all expected events during quick start flow', async () => {
|
|
const caseName = `sse-flow-${Date.now()}`;
|
|
createdCases.push(caseName);
|
|
|
|
// Start SSE connection
|
|
const controller = new AbortController();
|
|
let receivedData = '';
|
|
const receivedEvents: string[] = [];
|
|
|
|
const fetchPromise = fetch(`${baseUrl}/api/events`, {
|
|
signal: controller.signal,
|
|
}).then(async response => {
|
|
const reader = response.body?.getReader();
|
|
if (reader) {
|
|
try {
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
const chunk = new TextDecoder().decode(value);
|
|
receivedData += chunk;
|
|
// Extract event types
|
|
const lines = chunk.split('\n');
|
|
for (const line of lines) {
|
|
if (line.startsWith('event: ')) {
|
|
receivedEvents.push(line.substring(7));
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
});
|
|
|
|
// Wait for connection
|
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
|
|
// Perform quick start
|
|
const quickStartRes = await fetch(`${baseUrl}/api/quick-start`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ caseName }),
|
|
});
|
|
const quickStartData = await quickStartRes.json();
|
|
expect(quickStartData.success).toBe(true);
|
|
createdSessions.push(quickStartData.sessionId);
|
|
|
|
// Wait for events
|
|
await new Promise(resolve => setTimeout(resolve, 500));
|
|
|
|
// Stop SSE
|
|
controller.abort();
|
|
try { await fetchPromise; } catch {}
|
|
|
|
// Verify expected events were received
|
|
expect(receivedEvents).toContain('init');
|
|
expect(receivedEvents).toContain('session:created');
|
|
expect(receivedEvents).toContain('session:interactive');
|
|
// case:created should be emitted for new cases
|
|
expect(receivedEvents).toContain('case:created');
|
|
});
|
|
});
|