Files
Codeman/test/browser-e2e.test.ts
T
arkonandClaude Opus 4.5 40df0748c5 ui: improve respawn controller layout and filter action log noise
- Stack timers row vertically (timers on top, action log below)
- Reduce action log height to 60px since it's now full width
- Filter action log to show only important entries:
  - Commands sent to console
  - Plan-check with action taken
  - Step completions
- Skip timer starts/cancels, detection updates, ai-check status

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 19:59:42 +01:00

417 lines
12 KiB
TypeScript

/**
* Browser E2E Tests for Claudeman Web UI
*
* Uses agent-browser (which wraps Playwright) for browser automation.
* These tests verify the web interface works correctly from a user perspective.
*
* Port allocation: 3150-3153 (see CLAUDE.md test port table)
*
* NOTE: Browser tests require agent-browser daemon to be responsive.
* API-only tests (SSE, Hook, Ralph) run without browser dependency.
*
* Run just API tests: npx vitest run test/browser-e2e.test.ts -t "API"
* Run just browser tests: npx vitest run test/browser-e2e.test.ts -t "Browser"
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import { execSync } from 'node:child_process';
import { WebServer } from '../src/web/server.js';
// ============================================================================
// Browser E2E Tests (require agent-browser daemon)
// ============================================================================
const TEST_PORT_BROWSER = 3150;
const browserBaseUrl = `http://localhost:${TEST_PORT_BROWSER}`;
const BROWSER_TIMEOUT = 30000;
// Helper to run agent-browser commands
function browser(command: string): string {
try {
return execSync(`npx agent-browser ${command}`, {
timeout: BROWSER_TIMEOUT,
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
}).trim();
} catch (error: any) {
if (error.stderr) {
throw new Error(`agent-browser failed: ${error.stderr}`);
}
throw error;
}
}
function browserJson<T = any>(command: string): T {
const result = browser(`${command} --json`);
const parsed = JSON.parse(result);
if (!parsed.success) {
throw new Error(`agent-browser command failed: ${parsed.error || 'unknown error'}`);
}
return parsed.data;
}
async function waitForElement(selector: string, timeout = 10000): Promise<boolean> {
const start = Date.now();
while (Date.now() - start < timeout) {
try {
const count = browserJson<{ count: number }>(`get count "${selector}"`);
if (count.count > 0) return true;
} catch { /* retry */ }
await new Promise(r => setTimeout(r, 500));
}
return false;
}
function getText(selector: string): string {
try {
return browserJson<{ text: string }>(`get text "${selector}"`).text || '';
} catch {
return '';
}
}
function isVisible(selector: string): boolean {
try {
return browserJson<{ visible: boolean }>(`is visible "${selector}"`).visible;
} catch {
return false;
}
}
function closeBrowser() {
try {
browser('close');
} catch { /* ignore */ }
}
describe('Browser E2E Tests', () => {
let server: WebServer;
let createdSessions: string[] = [];
let browserAvailable = false;
beforeAll(async () => {
closeBrowser();
server = new WebServer(TEST_PORT_BROWSER);
await server.start();
await new Promise(r => setTimeout(r, 1000));
// Test if browser is available
try {
browser(`open ${browserBaseUrl}`);
await new Promise(r => setTimeout(r, 2000));
const title = browserJson<{ title: string }>('get title');
browserAvailable = title.title === 'Claudeman';
} catch (e) {
console.warn('Browser not available, skipping browser tests:', (e as Error).message);
browserAvailable = false;
}
}, 60000);
afterAll(async () => {
closeBrowser();
for (const sessionId of createdSessions) {
try {
await fetch(`${browserBaseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
} catch { /* ignore */ }
}
await server.stop();
}, 60000);
it('should load the Claudeman web interface', async () => {
if (!browserAvailable) {
console.log('Skipping: browser not available');
return;
}
const title = browserJson<{ title: string }>('get title');
expect(title.title).toBe('Claudeman');
const logoText = getText('.header-brand .logo');
expect(logoText).toBe('Claudeman');
expect(isVisible('.btn-claude')).toBe(true);
}, 60000);
it('should open and close help modal', async () => {
if (!browserAvailable) return;
browser('click ".help-btn"');
await new Promise(r => setTimeout(r, 500));
expect(isVisible('#helpModal .modal-content')).toBe(true);
expect(getText('#helpModal h3')).toContain('Keyboard Shortcuts');
browser('click "#helpModal .modal-close"');
await new Promise(r => setTimeout(r, 300));
expect(isVisible('#helpModal .modal-content')).toBe(false);
}, 60000);
it('should open settings modal with correct tabs', async () => {
if (!browserAvailable) return;
browser('click ".btn-settings"');
await new Promise(r => setTimeout(r, 500));
expect(isVisible('#appSettingsModal .modal-content')).toBe(true);
const tabCount = browserJson<{ count: number }>('get count "#appSettingsModal .modal-tab-btn"');
expect(tabCount.count).toBe(4);
browser('click "#appSettingsModal .modal-close"');
await new Promise(r => setTimeout(r, 300));
}, 60000);
it('should have all toolbar elements', async () => {
if (!browserAvailable) return;
expect(isVisible('.header-font-controls')).toBe(true);
expect(isVisible('#tabCount')).toBe(true);
expect(isVisible('#quickStartCase')).toBe(true);
const versionText = getText('#versionDisplay');
expect(versionText).toMatch(/v\d+\.\d+/);
}, 60000);
it('should create session and show terminal', async () => {
if (!browserAvailable) return;
browser('click ".btn-claude"');
const tabFound = await waitForElement('.session-tab', 15000);
expect(tabFound).toBe(true);
await new Promise(r => setTimeout(r, 1000));
expect(isVisible('.session-tab.active')).toBe(true);
const xtermVisible = await waitForElement('.xterm', 10000);
expect(xtermVisible).toBe(true);
// Track for cleanup
const response = await fetch(`${browserBaseUrl}/api/sessions`);
const data = await response.json();
if (data.sessions?.length > 0) {
const lastSession = data.sessions[data.sessions.length - 1];
if (lastSession && !createdSessions.includes(lastSession.id)) {
createdSessions.push(lastSession.id);
}
}
}, 90000);
});
// ============================================================================
// API Tests (no browser dependency)
// ============================================================================
describe('SSE Events API', () => {
let server: WebServer;
const TEST_PORT_SSE = 3151;
const sseBaseUrl = `http://localhost:${TEST_PORT_SSE}`;
let createdSessions: string[] = [];
beforeAll(async () => {
server = new WebServer(TEST_PORT_SSE);
await server.start();
await new Promise(r => setTimeout(r, 1000));
}, 30000);
afterAll(async () => {
for (const sessionId of createdSessions) {
try {
await fetch(`${sseBaseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
} catch { /* ignore */ }
}
await server.stop();
}, 60000);
it('should connect to SSE endpoint', async () => {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`${sseBaseUrl}/api/events`, {
signal: controller.signal,
headers: { 'Accept': 'text/event-stream' },
});
expect(response.headers.get('content-type')).toBe('text/event-stream');
} catch (err: any) {
if (err.name !== 'AbortError') throw err;
} finally {
clearTimeout(timeout);
}
});
it('should receive init event on connection', async () => {
const controller = new AbortController();
let receivedData = '';
const timeout = setTimeout(() => controller.abort(), 2000);
try {
const response = await fetch(`${sseBaseUrl}/api/events`, {
signal: controller.signal,
});
const reader = response.body?.getReader();
if (reader) {
while (true) {
const { done, value } = await reader.read();
if (done) break;
receivedData += new TextDecoder().decode(value);
}
}
} catch (err: any) {
if (err.name !== 'AbortError') throw err;
} finally {
clearTimeout(timeout);
}
// Should contain init event
expect(receivedData).toContain('event: init');
expect(receivedData).toContain('"sessions"');
});
});
describe('Hook Events API', () => {
let server: WebServer;
const TEST_PORT_HOOK = 3152;
const hookBaseUrl = `http://localhost:${TEST_PORT_HOOK}`;
let createdSessions: string[] = [];
beforeAll(async () => {
server = new WebServer(TEST_PORT_HOOK);
await server.start();
await new Promise(r => setTimeout(r, 1000));
}, 30000);
afterAll(async () => {
for (const sessionId of createdSessions) {
try {
await fetch(`${hookBaseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
} catch { /* ignore */ }
}
await server.stop();
}, 60000);
it('should accept hook events via API', async () => {
// Create a session
const createRes = await fetch(`${hookBaseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const createData = await createRes.json();
expect(createData.success).toBe(true);
const sessionId = createData.session.id;
createdSessions.push(sessionId);
// Post permission_prompt hook event
const hookRes = await fetch(`${hookBaseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'permission_prompt',
sessionId,
data: { message: 'Test permission prompt' },
}),
});
expect(hookRes.ok).toBe(true);
const hookData = await hookRes.json();
expect(hookData.success).toBe(true);
});
it('should accept idle_prompt hook events', async () => {
const sessionId = createdSessions[0] || 'test-session';
const hookRes = await fetch(`${hookBaseUrl}/api/hook-event`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
event: 'idle_prompt',
sessionId,
data: {},
}),
});
expect(hookRes.ok).toBe(true);
});
});
describe('Ralph API', () => {
let server: WebServer;
const TEST_PORT_RALPH = 3153;
const ralphBaseUrl = `http://localhost:${TEST_PORT_RALPH}`;
let createdSessions: string[] = [];
beforeAll(async () => {
server = new WebServer(TEST_PORT_RALPH);
await server.start();
await new Promise(r => setTimeout(r, 1000));
}, 30000);
afterAll(async () => {
for (const sessionId of createdSessions) {
try {
await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}`, { method: 'DELETE' });
} catch { /* ignore */ }
}
await server.stop();
}, 60000);
it('should enable Ralph tracking via API', async () => {
// Create a session
const createRes = await fetch(`${ralphBaseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
});
const createData = await createRes.json();
expect(createData.success).toBe(true);
const sessionId = createData.session.id;
createdSessions.push(sessionId);
// Enable Ralph tracking
const configRes = await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ enabled: true }),
});
expect(configRes.ok).toBe(true);
// Get Ralph state
const stateRes = await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}/ralph-state`);
expect(stateRes.ok).toBe(true);
const stateData = await stateRes.json();
expect(stateData.success).toBe(true);
expect(stateData.data).toBeDefined();
expect(stateData.data.loop).toBeDefined();
expect(stateData.data.todos).toBeDefined();
});
it('should configure completion phrase via API', async () => {
const sessionId = createdSessions[0];
if (!sessionId) return;
const configRes = await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ completionPhrase: 'DONE' }),
});
expect(configRes.ok).toBe(true);
});
it('should reset Ralph state via API', async () => {
const sessionId = createdSessions[0];
if (!sessionId) return;
const resetRes = await fetch(`${ralphBaseUrl}/api/sessions/${sessionId}/ralph-config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ reset: true }),
});
expect(resetRes.ok).toBe(true);
});
});