test(ci): run the unit suite in CI + frontend-syntax gate; green pre-existing test debt

- CI: add a 'test' job running the unit suite via config/vitest.ci.config.ts. Excludes browser (Playwright/chromium) and perf tests (timing-flaky), like the existing test/mobile suite. Safe in CI: TmuxManager no-ops shell commands under VITEST (test/setup.ts).
- Add scripts/check-frontend-syntax.mjs (node --check on src/web/public/*.js), wired into the lint job — catches a class of frontend SyntaxError that passes lint today (lint globs only TS).
- Add test/security-regression.test.ts (wired Host/Origin guard, self-update CSRF, CSP/security headers, text/plain raw body, WS anti-CSWSH) + test/sse-registry-parity.test.ts (backend<->frontend SSE registry parity).
- Green pre-existing test debt surfaced by the new gate: stale 'Session not found' asserts -> 'not found' substring; drop tests for removed helpers (isError now internal; createSuccessResponse deleted); file-stream-manager: mock realpathSync + fix stale /tmp assertion; sse-subscription-filter: lifecycle events broadcast to all clients (only terminal stream filtered); session.test.ts: mkdir /tmp/test; skip one interactive-respawn test needing a real PTY (covered by respawn-controller.test.ts).
- Full non-mobile suite verified green locally (2680 passed, 12 skipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
arkon
2026-06-09 20:02:15 +02:00
co-authored by Claude Opus 4.8
parent 36bc22a3d5
commit d5f91e4cd7
14 changed files with 428 additions and 121 deletions
+34 -3
View File
@@ -31,6 +31,9 @@ jobs:
- name: Lint
run: npm run lint
- name: Frontend JS syntax check
run: npm run check:frontend-syntax
- name: Format check
run: npm run format:check
@@ -60,6 +63,34 @@ jobs:
cat /tmp/boot.log
exit 1
# Note: The test suite is intentionally excluded from CI.
# Tests spawn real tmux sessions and require a full system environment.
# Run tests locally with: npx vitest run test/<file>.test.ts
test:
name: Unit & integration tests
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Install tmux
run: |
if ! command -v tmux >/dev/null; then
sudo apt-get update -qq
sudo apt-get install -y tmux
fi
- name: Run unit & integration tests
# Excludes the browser-driven mobile suite (test/mobile/**); see config/vitest.ci.config.ts.
# Safe in CI: TmuxManager no-ops all shell commands under VITEST (test/setup.ts).
run: npm run test:ci
# Note: The browser-driven mobile suite (test/mobile/**) is excluded from CI —
# it needs a live server + chromium + environment-specific PNG baselines.
# Run it locally/manually. All other tests run via the `test` job above.
+33
View File
@@ -0,0 +1,33 @@
import { resolve } from 'node:path';
import { defineConfig, configDefaults } from 'vitest/config';
const root = resolve(import.meta.dirname, '..');
/**
* CI test config — same as vitest.config.ts but EXCLUDES the browser-driven
* mobile suite (test/mobile/**). Those are Playwright visual-regression tests
* that need a live server + chromium + environment-specific PNG baselines, so
* they are run/maintained separately and are not part of the CI gate.
*
* Keep the rest in sync with config/vitest.config.ts.
*/
export default defineConfig({
test: {
root,
globals: true,
environment: 'node',
include: ['test/**/*.test.ts'],
exclude: [
...configDefaults.exclude,
'test/mobile/**', // browser/visual (Playwright + chromium)
'test/perf-*.test.ts', // timing-sensitive perf benchmarks (flaky in CI)
'test/inline-rename.test.ts', // browser (Playwright)
'test/opencode-resize.test.ts', // browser (Playwright)
'test/webgl-fallback.test.ts', // browser (Playwright)
],
setupFiles: ['./test/setup.ts'],
fileParallelism: false,
testTimeout: 30000,
teardownTimeout: 60000,
},
});
+2
View File
@@ -19,6 +19,8 @@
"test": "vitest run --config config/vitest.config.ts",
"test:watch": "vitest --config config/vitest.config.ts",
"test:coverage": "vitest run --config config/vitest.config.ts --coverage",
"test:ci": "vitest run --config config/vitest.ci.config.ts",
"check:frontend-syntax": "node scripts/check-frontend-syntax.mjs",
"typecheck": "tsc --noEmit",
"lint": "eslint --config config/eslint.config.js 'src/**/*.ts'",
"lint:fix": "eslint --config config/eslint.config.js 'src/**/*.ts' --fix",
+40
View File
@@ -0,0 +1,40 @@
#!/usr/bin/env node
/**
* Frontend JS syntax check.
*
* CI's `npm run lint` only lints TypeScript under src/, and `tsc` excludes the
* frontend — so a plain SyntaxError in a shipped `src/web/public` script (loaded
* as a bare <script>, no bundler) passes CI green yet breaks the whole module at
* load.
* (This is exactly how PR #112's duplicate-`const` error in session-ui.js slipped
* through.) This runs `node --check` (parse-only; browser globals don't matter)
* on every shipped frontend script so that class of bug fails fast.
*/
import { readdirSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const PUBLIC_DIR = join(ROOT, 'src', 'web', 'public');
const files = readdirSync(PUBLIC_DIR)
.filter((f) => f.endsWith('.js'))
.map((f) => join(PUBLIC_DIR, f));
let failed = 0;
for (const file of files) {
try {
execFileSync(process.execPath, ['--check', file], { stdio: 'pipe' });
} catch (err) {
failed++;
const msg = err.stderr ? err.stderr.toString() : String(err);
console.error(`✗ syntax error in ${file.replace(ROOT + '/', '')}:\n${msg}`);
}
}
if (failed > 0) {
console.error(`\n${failed} frontend file(s) failed the syntax check.`);
process.exit(1);
}
console.log(`✓ ${files.length} frontend JS files parse cleanly`);
+4 -33
View File
@@ -5,11 +5,7 @@
*/
import { describe, it, expect } from 'vitest';
import {
ApiErrorCode,
createErrorResponse,
createSuccessResponse,
} from '../src/types.js';
import { ApiErrorCode, createErrorResponse } from '../src/types.js';
describe('API Response Structures', () => {
describe('SessionState Structure', () => {
@@ -480,34 +476,9 @@ describe('API Response Structures', () => {
});
describe('Response Validation', () => {
describe('SessionResponse', () => {
it('should have success property', () => {
const response = createSuccessResponse({ id: 'session-1' });
expect(response).toHaveProperty('success');
expect(response.success).toBe(true);
});
it('should have data property on success', () => {
const response = createSuccessResponse({ id: 'session-1', status: 'idle' });
expect(response).toHaveProperty('data');
expect(response.data?.id).toBe('session-1');
});
});
describe('QuickStartResponse', () => {
it('should include session and case info on success', () => {
const response = createSuccessResponse({
sessionId: 'session-1',
casePath: '/path/to/case',
caseName: 'test-case',
});
expect(response.success).toBe(true);
expect(response.data?.sessionId).toBeDefined();
expect(response.data?.casePath).toBeDefined();
expect(response.data?.caseName).toBeDefined();
});
});
// (SessionResponse / QuickStartResponse success-envelope tests removed — the
// createSuccessResponse helper they exercised no longer exists. Error-envelope
// coverage remains below.)
describe('Error Responses', () => {
it('should include error code', () => {
+21 -19
View File
@@ -41,14 +41,14 @@ describe('Edge Cases and Error Handling', () => {
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
it('should handle getting non-existent session gracefully', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent-id-12345`);
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
it('should handle running prompt on non-existent session', async () => {
@@ -59,7 +59,7 @@ describe('Edge Cases and Error Handling', () => {
});
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
it('should handle input to non-existent session', async () => {
@@ -70,7 +70,7 @@ describe('Edge Cases and Error Handling', () => {
});
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
it('should handle resize on non-existent session', async () => {
@@ -81,7 +81,7 @@ describe('Edge Cases and Error Handling', () => {
});
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
it('should handle interactive mode on non-existent session', async () => {
@@ -90,21 +90,21 @@ describe('Edge Cases and Error Handling', () => {
});
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
it('should handle terminal buffer request on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/terminal`);
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
it('should handle output request on non-existent session', async () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent/output`);
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
});
@@ -212,7 +212,7 @@ describe('Edge Cases and Error Handling', () => {
});
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
it('should handle stopping non-existent respawn controller', async () => {
@@ -232,7 +232,7 @@ describe('Edge Cases and Error Handling', () => {
});
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
});
@@ -272,13 +272,15 @@ describe('Concurrent Session Handling', () => {
it('should handle multiple sessions simultaneously', async () => {
// Create multiple sessions concurrently
const createPromises = Array(5).fill(null).map(() =>
fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
}).then(r => r.json())
);
const createPromises = Array(5)
.fill(null)
.map(() =>
fetch(`${baseUrl}/api/sessions`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir: '/tmp' }),
}).then((r) => r.json())
);
const results = await Promise.all(createPromises);
@@ -327,12 +329,12 @@ describe('Concurrent Session Handling', () => {
const caseNames = ['concurrent-test-1', 'concurrent-test-2', 'concurrent-test-3'];
const createdCases: string[] = [];
const quickStartPromises = caseNames.map(name =>
const quickStartPromises = caseNames.map((name) =>
fetch(`${baseUrl}/api/quick-start`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName: `${name}-${Date.now()}` }),
}).then(r => r.json())
}).then((r) => r.json())
);
const results = await Promise.all(quickStartPromises);
+10 -12
View File
@@ -24,6 +24,10 @@ vi.mock('node:fs', async (importOriginal) => {
...orig,
existsSync: vi.fn(() => true),
statSync: vi.fn(() => ({ size: 1024 })),
// createStream re-resolves symlinks via realpathSync right before spawn (TOCTOU
// guard); the test fixtures are non-existent paths, so the real realpathSync would
// throw. Mock it as identity so the re-check passes.
realpathSync: vi.fn((p: string) => p),
};
});
@@ -92,11 +96,9 @@ describe('FileStreamManager', () => {
onError: vi.fn(),
});
expect(mockSpawn).toHaveBeenCalledWith(
'tail',
['-f', '-n', '50', expect.stringContaining('/var/log/app.log')],
{ stdio: ['ignore', 'pipe', 'pipe'] },
);
expect(mockSpawn).toHaveBeenCalledWith('tail', ['-f', '-n', '50', expect.stringContaining('/var/log/app.log')], {
stdio: ['ignore', 'pipe', 'pipe'],
});
});
it('should use custom lines parameter', async () => {
@@ -113,11 +115,7 @@ describe('FileStreamManager', () => {
onError: vi.fn(),
});
expect(mockSpawn).toHaveBeenCalledWith(
'tail',
['-f', '-n', '100', expect.any(String)],
expect.any(Object),
);
expect(mockSpawn).toHaveBeenCalledWith('tail', ['-f', '-n', '100', expect.any(String)], expect.any(Object));
});
it('should reject when file does not exist', async () => {
@@ -448,7 +446,7 @@ describe('FileStreamManager', () => {
expect(result.success).toBe(true);
});
it('should allow paths in /tmp', async () => {
it('should reject paths in /tmp (world-writable, intentionally excluded)', async () => {
const proc = createMockProcess();
mockSpawn.mockReturnValue(proc);
@@ -461,7 +459,7 @@ describe('FileStreamManager', () => {
onError: vi.fn(),
});
expect(result.success).toBe(true);
expect(result.success).toBe(false);
});
it('should handle stat errors gracefully', async () => {
+9 -7
View File
@@ -165,7 +165,7 @@ describe('Integration Flows', () => {
createdSessions.push(quickStartData.sessionId);
// Wait for Claude to start up
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
// Send input
const inputRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/input`, {
@@ -177,7 +177,7 @@ describe('Integration Flows', () => {
expect(inputData.success).toBe(true);
// Wait for response
await new Promise(resolve => setTimeout(resolve, 1000));
await new Promise((resolve) => setTimeout(resolve, 1000));
// Check terminal buffer has content
const terminalRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}/terminal`);
@@ -234,7 +234,7 @@ describe('Integration Flows', () => {
// 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');
expect(verifyData.error).toContain('not found');
});
});
@@ -309,7 +309,7 @@ describe('SSE Event Flow', () => {
const fetchPromise = fetch(`${baseUrl}/api/events`, {
signal: controller.signal,
}).then(async response => {
}).then(async (response) => {
const reader = response.body?.getReader();
if (reader) {
try {
@@ -331,7 +331,7 @@ describe('SSE Event Flow', () => {
});
// Wait for connection
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
// Perform quick start
const quickStartRes = await fetch(`${baseUrl}/api/quick-start`, {
@@ -344,11 +344,13 @@ describe('SSE Event Flow', () => {
createdSessions.push(quickStartData.sessionId);
// Wait for events
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// Stop SSE
controller.abort();
try { await fetchPromise; } catch {}
try {
await fetchPromise;
} catch {}
// Verify expected events were received
expect(receivedEvents).toContain('init');
+167
View File
@@ -0,0 +1,167 @@
/**
* Security regression tests for the 2026-06-09 hardening (v0.9.5).
*
* These assert the fixes as WIRED into the running Fastify server — complementing
* the pure-function coverage in network-host-guard.test.ts. A regression that
* unwires the guard (or drops a header) would pass the pure-function tests but
* fail here. Covers:
* - Host-header allowlist (anti DNS-rebinding) — onRequest, before routing
* - cross-site Origin/CSRF guard on state-changing methods (incl. self-update)
* - security response headers (CSP, X-Frame-Options, X-Content-Type-Options; HSTS gated on https)
* - text/plain bodies kept RAW (the closed "simple request" CSRF vector)
* - WebSocket anti-CSWSH (Origin/Host validated on upgrade → close 4003)
*
* Port: 3167
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import http from 'node:http';
import WebSocket from 'ws';
import { WebServer } from '../src/web/server.js';
import { TmuxManager } from '../src/tmux-manager.js';
const PORT = 3167;
vi.spyOn(TmuxManager, 'isTmuxAvailable').mockReturnValue(true);
interface RawResponse {
status: number;
headers: http.IncomingHttpHeaders;
body: string;
}
/** Raw HTTP request with full control over Host/Origin headers (fetch/undici rewrites Host). */
function raw(method: string, path: string, headers: Record<string, string> = {}, body?: string): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const req = http.request({ host: '127.0.0.1', port: PORT, path, method, headers }, (res) => {
let data = '';
res.on('data', (c) => (data += c));
res.on('end', () => resolve({ status: res.statusCode ?? 0, headers: res.headers, body: data }));
});
req.on('error', reject);
if (body !== undefined) req.write(body);
req.end();
});
}
/** Open a WS to `path` with optional Origin and resolve with the close code the server sends. */
function wsCloseCode(path: string, origin?: string): Promise<number> {
return new Promise((resolve, reject) => {
const ws = new WebSocket(`ws://127.0.0.1:${PORT}${path}`, {
headers: origin ? { origin } : {},
});
const timer = setTimeout(() => {
try {
ws.terminate();
} catch {
/* ignore */
}
reject(new Error('WS did not close within timeout'));
}, 8000);
ws.on('close', (code) => {
clearTimeout(timer);
resolve(code);
});
ws.on('error', () => {
/* a close frame with the code follows; let the close handler resolve */
});
});
}
let server: WebServer;
beforeAll(async () => {
delete process.env.CODEMAN_PASSWORD; // guard must work even on the no-auth default
server = new WebServer(PORT, false, true);
await server.start();
});
afterAll(async () => {
await server.stop();
});
describe('Host-header allowlist (anti DNS-rebinding), wired', () => {
it('rejects a rebound custom Host with 403', async () => {
const res = await raw('GET', '/api/status', { Host: 'evil.attacker.example' });
expect(res.status).toBe(403);
expect(res.body).toContain('host not allowed');
});
it('allows a loopback Host', async () => {
const res = await raw('GET', '/api/status', { Host: `localhost:${PORT}` });
expect(res.status).toBe(200);
});
it('allows an IP-literal Host (default when none specified)', async () => {
const res = await raw('GET', '/api/status');
expect(res.status).toBe(200);
});
});
describe('cross-site Origin / CSRF guard, wired', () => {
// Probe a non-existent route: the onRequest guard runs BEFORE routing, so a blocked
// request 403s while an allowed one falls through to 404 — no side effects either way.
const PROBE = '/api/__csrf_probe__';
it('blocks a state-changing request from a foreign Origin with 403', async () => {
const res = await raw('POST', PROBE, { Origin: 'https://evil.attacker.example' });
expect(res.status).toBe(403);
expect(res.body).toContain('cross-site request blocked');
});
it('allows a state-changing request with NO Origin (curl / CLI / hooks)', async () => {
const res = await raw('POST', PROBE);
expect(res.status).not.toBe(403); // 404 (route not found) — guard let it through
});
it('allows a state-changing request from a same-site Origin', async () => {
const res = await raw('POST', PROBE, { Origin: `http://localhost:${PORT}` });
expect(res.status).not.toBe(403);
});
it('does NOT block safe methods (GET) from a foreign Origin', async () => {
const res = await raw('GET', '/api/status', { Origin: 'https://evil.attacker.example' });
expect(res.status).toBe(200);
});
it('blocks the self-update route (POST /api/system/update) from a foreign Origin', async () => {
const res = await raw('POST', '/api/system/update', { Origin: 'https://evil.attacker.example' });
expect(res.status).toBe(403);
expect(res.body).toContain('cross-site request blocked');
});
});
describe('security response headers, wired', () => {
it('sets CSP, X-Frame-Options, and X-Content-Type-Options', async () => {
const res = await raw('GET', '/api/status');
expect(res.headers['content-security-policy']).toContain("default-src 'self'");
expect(res.headers['x-frame-options']).toBe('SAMEORIGIN');
expect(res.headers['x-content-type-options']).toBe('nosniff');
});
it('does NOT set HSTS over plain http (it is gated on https)', async () => {
const res = await raw('GET', '/api/status');
expect(res.headers['strict-transport-security']).toBeUndefined();
});
});
describe('text/plain bodies are kept raw (closed simple-request CSRF vector)', () => {
it('does not auto-JSON-parse a text/plain body into a JSON route', async () => {
// Same-site (no Origin) so the CSRF guard allows it; the body is valid JSON but
// arrives as a raw STRING, so the JSON schema validation must reject it.
const res = await raw('POST', '/api/sessions', { 'Content-Type': 'text/plain' }, '{"workingDir":"/tmp"}');
expect(res.status).not.toBe(200); // not parsed as an object → validation error, no session created
expect(res.status).toBe(400);
});
});
describe('WebSocket anti-CSWSH', () => {
it('closes a WS upgrade from a foreign Origin with code 4003', async () => {
const code = await wsCloseCode('/ws/sessions/nonexistent/terminal', 'https://evil.attacker.example');
expect(code).toBe(4003);
});
it('passes the Origin check with no Origin (then closes 4004 for the unknown session)', async () => {
const code = await wsCloseCode('/ws/sessions/nonexistent/terminal');
expect(code).toBe(4004); // reached the session lookup → origin check passed
});
});
+13 -7
View File
@@ -69,7 +69,7 @@ describe('Session Cleanup', () => {
// Verify session is gone
const getRes = await fetch(`${baseUrl}/api/sessions/${quickStartData.sessionId}`);
const getData = await getRes.json();
expect(getData.error).toBe('Session not found');
expect(getData.error).toContain('not found');
});
it('should cleanup multiple sessions when deleted', async () => {
@@ -109,7 +109,13 @@ describe('Session Cleanup', () => {
});
describe('Respawn Controller Cleanup', () => {
it('should cleanup respawn controller when session is deleted', async () => {
// TODO(test-harness): this exercises POST /interactive-respawn, but under the VITEST
// tmux no-op the session never becomes truly interactive, so the respawn controller
// has nothing to drive and unregisters before the GET — `enabled` reads false. It
// needs a real interactive session. Respawn-controller cleanup is covered by
// respawn-controller.test.ts (MockSession). Re-enable if interactive-respawn gains a
// test-mode path that keeps the controller registered.
it.skip('should cleanup respawn controller when session is deleted', async () => {
const caseName = `respawn-cleanup-${Date.now()}`;
createdCases.push(caseName);
@@ -140,7 +146,7 @@ describe('Session Cleanup', () => {
});
// Wait for cleanup to complete (exit event handler)
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// Verify respawn controller is cleaned up (enabled: false when controller doesn't exist)
const respawnAfterRes = await fetch(`${baseUrl}/api/sessions/${sessionData.session.id}/respawn`);
@@ -232,11 +238,11 @@ describe('Resource Management', () => {
expect(deleteData.success).toBe(true);
// Small delay to allow async cleanup to complete
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
}
// Wait a bit more for all cleanup to complete
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// Verify created sessions were deleted (account for restored sessions from other tests)
const listRes = await fetch(`${baseUrl}/api/sessions`);
@@ -265,7 +271,7 @@ describe('Resource Management', () => {
expect(createData.success).toBe(true);
// Wait for some terminal output - Claude startup time can vary
await new Promise(resolve => setTimeout(resolve, 2000));
await new Promise((resolve) => setTimeout(resolve, 2000));
// Get terminal buffer before stop - may or may not have content depending on timing
const terminalRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/terminal`);
@@ -281,6 +287,6 @@ describe('Resource Management', () => {
// Session should be gone
const afterRes = await fetch(`${baseUrl}/api/sessions/${createData.sessionId}/terminal`);
const afterData = await afterRes.json();
expect(afterData.error).toBe('Session not found');
expect(afterData.error).toContain('not found');
});
});
+10 -6
View File
@@ -1,4 +1,5 @@
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { mkdirSync } from 'node:fs';
import { WebServer } from '../src/web/server.js';
const TEST_PORT = 3102;
@@ -8,6 +9,9 @@ describe('Interactive Session Lifecycle', () => {
let baseUrl: string;
beforeAll(async () => {
// The 'custom working directory' test creates a session in /tmp/test; workingDir
// validation requires the dir to exist, so ensure it does (idempotent, CI-safe).
mkdirSync('/tmp/test', { recursive: true });
server = new WebServer(TEST_PORT, false, true);
await server.start();
baseUrl = `http://localhost:${TEST_PORT}`;
@@ -15,7 +19,7 @@ describe('Interactive Session Lifecycle', () => {
afterAll(async () => {
await server.stop();
}, 60000); // Extended timeout for cleanup
}, 60000); // Extended timeout for cleanup
describe('Session Creation', () => {
it('should create session with default working directory', async () => {
@@ -70,7 +74,7 @@ describe('Interactive Session Lifecycle', () => {
const response = await fetch(`${baseUrl}/api/sessions/non-existent-id`);
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
});
@@ -96,7 +100,7 @@ describe('Interactive Session Lifecycle', () => {
// Verify it's gone
const getRes = await fetch(`${baseUrl}/api/sessions/${sessionId}`);
const getData = await getRes.json();
expect(getData.error).toBe('Session not found');
expect(getData.error).toContain('not found');
});
it('should return error when deleting non-existent session', async () => {
@@ -106,7 +110,7 @@ describe('Interactive Session Lifecycle', () => {
const data = await response.json();
expect(data.success).toBe(false);
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
});
@@ -178,7 +182,7 @@ describe('Interactive Session Lifecycle', () => {
});
const data = await response.json();
expect(data.error).toBe('Session not found');
expect(data.error).toContain('not found');
});
});
@@ -198,7 +202,7 @@ describe('Interactive Session Lifecycle', () => {
});
// Wait a bit for interactive session to start
await new Promise(resolve => setTimeout(resolve, 500));
await new Promise((resolve) => setTimeout(resolve, 500));
// Send input
const response = await fetch(`${baseUrl}/api/sessions/${sessionId}/input`, {
+67
View File
@@ -0,0 +1,67 @@
/**
* SSE event registry parity — backend ⇄ frontend.
*
* CLAUDE.md mandates that the backend SSE registry (src/web/sse-events.ts) and the
* frontend SSE_EVENTS object (src/web/public/constants.js) stay in sync. They are
* hand-maintained in two files with no build-time link, so this asserts the set of
* event-string VALUES matches exactly — a drift here means the UI silently ignores
* (or never receives) an event.
*
* No port needed (pure file/module comparison).
*/
import { describe, it, expect } from 'vitest';
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import * as SseEvents from '../src/web/sse-events.js';
/** Every `export const X = '...' as const` in sse-events.ts is an event string. */
function backendEventValues(): Set<string> {
return new Set(Object.values(SseEvents).filter((v): v is string => typeof v === 'string'));
}
/** Extract the string values from the `const SSE_EVENTS = { ... }` block in constants.js. */
function frontendEventValues(): Set<string> {
const file = resolve(import.meta.dirname, '..', 'src', 'web', 'public', 'constants.js');
const src = readFileSync(file, 'utf8');
const start = src.indexOf('const SSE_EVENTS = {');
expect(start, 'SSE_EVENTS object not found in constants.js').toBeGreaterThanOrEqual(0);
// The object is closed by the first line that is exactly "};" after the declaration.
const after = src.slice(start);
const end = after.indexOf('\n};');
expect(end, 'SSE_EVENTS closing "};" not found').toBeGreaterThan(0);
const block = after.slice(0, end);
const values = new Set<string>();
// Match `KEY: 'value'` / `KEY: "value"` pairs (skip commented lines).
for (const line of block.split('\n')) {
const code = line.replace(/\/\/.*$/, '');
const m = code.match(/:\s*['"]([^'"]+)['"]/);
if (m) values.add(m[1]);
}
return values;
}
describe('SSE event registry parity (backend ⇄ frontend)', () => {
const backend = backendEventValues();
const frontend = frontendEventValues();
it('extracts a non-trivial number of events from both sources', () => {
// Guard against a parsing regression silently making this test vacuous.
expect(backend.size).toBeGreaterThan(100);
expect(frontend.size).toBeGreaterThan(100);
});
it('has no backend events missing from the frontend SSE_EVENTS registry', () => {
const missing = [...backend].filter((e) => !frontend.has(e)).sort();
expect(missing, `events in sse-events.ts but not constants.js SSE_EVENTS: ${missing.join(', ')}`).toEqual([]);
});
it('has no frontend events missing from the backend sse-events.ts registry', () => {
const extra = [...frontend].filter((e) => !backend.has(e)).sort();
expect(extra, `events in constants.js SSE_EVENTS but not sse-events.ts: ${extra.join(', ')}`).toEqual([]);
});
it('the two registries are exactly equal in size', () => {
expect(frontend.size).toBe(backend.size);
});
});
+10 -5
View File
@@ -186,10 +186,13 @@ describe('SSE Subscription Filtering', () => {
expect(unfilteredCreated).toBeDefined();
expect((unfilteredCreated?.data as any).id).toBe(sessionId);
// Filtered client (subscribed to nonexistent-session) should NOT receive session:created
// because session:created has an `id` field that doesn't match the filter
// Lifecycle/metadata events (session:created/updated/deleted, ralph:*, etc.) are
// intentionally broadcast to ALL clients regardless of the ?sessions= filter — only
// the high-volume terminal stream is filtered per-session (see sse-stream-manager
// broadcast(): "Subscription filtering is intentionally NOT applied here"). So the
// filtered client still receives session:created.
const filteredCreated = filteredEvents.find((e) => e.event === 'session:created');
expect(filteredCreated).toBeUndefined();
expect(filteredCreated).toBeDefined();
// Both should have received the init event (it has no sessionId)
expect(unfilteredEvents.find((e) => e.event === 'init')).toBeDefined();
@@ -314,9 +317,11 @@ describe('SSE Subscription Filtering', () => {
const deleted1 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session1.id);
expect(deleted1).toBeDefined();
// Should NOT receive session:deleted for session2
// Lifecycle events are broadcast to all clients regardless of filter, so a client
// subscribed to session1 still receives session2's session:deleted (only terminal
// output is filtered per-session).
const deleted2 = events.find((e) => e.event === 'session:deleted' && (e.data as any).id === session2.id);
expect(deleted2).toBeUndefined();
expect(deleted2).toBeDefined();
});
it('should support subscribing to multiple sessions', async () => {
+8 -29
View File
@@ -13,7 +13,6 @@ import {
createInitialState,
ApiErrorCode,
DEFAULT_CONFIG,
isError,
getErrorMessage,
} from '../src/types.js';
@@ -265,33 +264,8 @@ describe('types utility functions', () => {
});
});
describe('isError', () => {
it('should return true for Error instances', () => {
expect(isError(new Error('test'))).toBe(true);
expect(isError(new TypeError('test'))).toBe(true);
expect(isError(new RangeError('test'))).toBe(true);
expect(isError(new SyntaxError('test'))).toBe(true);
});
it('should return false for non-Error values', () => {
expect(isError('error string')).toBe(false);
expect(isError(123)).toBe(false);
expect(isError(null)).toBe(false);
expect(isError(undefined)).toBe(false);
expect(isError({})).toBe(false);
expect(isError({ message: 'fake error' })).toBe(false);
});
it('should return false for arrays', () => {
expect(isError([])).toBe(false);
expect(isError([new Error('test')])).toBe(false);
});
it('should return false for functions', () => {
expect(isError(() => {})).toBe(false);
expect(isError(Error)).toBe(false);
});
});
// (isError is now an internal helper in src/types/api.ts — no longer a public
// export; it is covered indirectly via getErrorMessage below.)
describe('getErrorMessage', () => {
it('should extract message from Error objects', () => {
@@ -359,7 +333,12 @@ describe('types utility functions', () => {
describe('TaskStatus', () => {
it('should support all status values', () => {
const statuses: Array<'pending' | 'running' | 'completed' | 'failed'> = ['pending', 'running', 'completed', 'failed'];
const statuses: Array<'pending' | 'running' | 'completed' | 'failed'> = [
'pending',
'running',
'completed',
'failed',
];
expect(statuses).toHaveLength(4);
});
});