diff --git a/ccw/tests/integration/status-routes.test.ts b/ccw/tests/integration/status-routes.test.ts new file mode 100644 index 00000000..904c21ee --- /dev/null +++ b/ccw/tests/integration/status-routes.test.ts @@ -0,0 +1,143 @@ +/** + * Integration tests for status routes. + * + * Notes: + * - Targets runtime implementation shipped in `ccw/dist`. + * - Exercises real HTTP request/response flow via a minimal test server. + */ + +import { after, before, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; + +const statusRoutesUrl = new URL('../../dist/core/routes/status-routes.js', import.meta.url); +statusRoutesUrl.searchParams.set('t', String(Date.now())); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mod: any; + +type JsonResponse = { status: number; json: any; text: string }; + +async function requestJson(baseUrl: string, method: string, path: string): Promise { + const url = new URL(path, baseUrl); + + return new Promise((resolve, reject) => { + const req = http.request( + url, + { method, headers: { Accept: 'application/json' } }, + (res) => { + let body = ''; + res.on('data', (chunk) => { + body += chunk.toString(); + }); + res.on('end', () => { + let json: any = null; + try { + json = body ? JSON.parse(body) : null; + } catch { + json = null; + } + resolve({ status: res.statusCode || 0, json, text: body }); + }); + }, + ); + req.on('error', reject); + req.end(); + }); +} + +function handlePostRequest(req: http.IncomingMessage, res: http.ServerResponse, handler: (body: unknown) => Promise): void { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', async () => { + try { + const parsed = body ? JSON.parse(body) : {}; + const result = await handler(parsed); + + if (result?.error) { + res.writeHead(result.status || 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: result.error })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } + } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err?.message || String(err) })); + } + }); +} + +describe('status routes integration', async () => { + let server: http.Server | null = null; + let baseUrl = ''; + + before(async () => { + mod = await import(statusRoutesUrl.href); + + server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', 'http://localhost'); + const pathname = url.pathname; + + const ctx = { + pathname, + url, + req, + res, + initialPath: process.cwd(), + handlePostRequest, + broadcastToClients() {}, + }; + + try { + const handled = await mod.handleStatusRoutes(ctx); + if (!handled) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not Found' })); + } + } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err?.message || String(err) })); + } + }); + + await new Promise((resolve) => { + server!.listen(0, () => resolve()); + }); + + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + baseUrl = `http://127.0.0.1:${port}`; + }); + + after(async () => { + if (!server) return; + await new Promise((resolve) => server!.close(() => resolve())); + }); + + it('GET /api/status/all returns aggregated status payload', async () => { + const res = await requestJson(baseUrl, 'GET', '/api/status/all'); + assert.equal(res.status, 200); + assert.ok(res.json); + + for (const key of ['cli', 'codexLens', 'semantic', 'ccwInstall', 'timestamp']) { + assert.ok(Object.prototype.hasOwnProperty.call(res.json, key), `missing key: ${key}`); + } + + assert.equal(typeof res.json.timestamp, 'string'); + assert.ok(res.json.timestamp.length > 0); + + assert.equal(typeof res.json.ccwInstall.installed, 'boolean'); + assert.equal(Array.isArray(res.json.ccwInstall.missingFiles), true); + assert.equal(typeof res.json.ccwInstall.installPath, 'string'); + }); + + it('returns 404 for unknown routes', async () => { + const res = await requestJson(baseUrl, 'GET', '/api/status/unknown'); + assert.equal(res.status, 404); + assert.ok(res.json?.error); + }); +}); + diff --git a/ccw/tests/integration/system-routes.test.ts b/ccw/tests/integration/system-routes.test.ts new file mode 100644 index 00000000..53db1969 --- /dev/null +++ b/ccw/tests/integration/system-routes.test.ts @@ -0,0 +1,241 @@ +/** + * Integration tests for system routes (data/health/recent-paths/switch-path/shutdown). + * + * Notes: + * - Targets runtime implementation shipped in `ccw/dist`. + * - Uses a temporary CCW data directory (CCW_DATA_DIR) to isolate recent-paths writes. + */ + +import { after, before, describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const CCW_HOME = mkdtempSync(join(tmpdir(), 'ccw-system-routes-home-')); +const PROJECT_ROOT = mkdtempSync(join(tmpdir(), 'ccw-system-routes-project-')); + +const systemRoutesUrl = new URL('../../dist/core/routes/system-routes.js', import.meta.url); +systemRoutesUrl.searchParams.set('t', String(Date.now())); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let mod: any; + +const originalEnv = { CCW_DATA_DIR: process.env.CCW_DATA_DIR }; + +type JsonResponse = { status: number; json: any; text: string }; + +async function requestJson( + baseUrl: string, + method: string, + path: string, + body?: unknown, +): Promise { + const url = new URL(path, baseUrl); + const payload = body === undefined ? null : Buffer.from(JSON.stringify(body), 'utf8'); + + return new Promise((resolve, reject) => { + const req = http.request( + url, + { + method, + headers: { + Accept: 'application/json', + ...(payload + ? { 'Content-Type': 'application/json', 'Content-Length': String(payload.length) } + : {}), + }, + }, + (res) => { + let responseBody = ''; + res.on('data', (chunk) => { + responseBody += chunk.toString(); + }); + res.on('end', () => { + let json: any = null; + try { + json = responseBody ? JSON.parse(responseBody) : null; + } catch { + json = null; + } + resolve({ status: res.statusCode || 0, json, text: responseBody }); + }); + }, + ); + req.on('error', reject); + if (payload) req.write(payload); + req.end(); + }); +} + +function handlePostRequest(req: http.IncomingMessage, res: http.ServerResponse, handler: (body: unknown) => Promise): void { + let body = ''; + req.on('data', (chunk) => { + body += chunk.toString(); + }); + req.on('end', async () => { + try { + const parsed = body ? JSON.parse(body) : {}; + const result = await handler(parsed); + + if (result?.error) { + res.writeHead(result.status || 500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: result.error })); + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(result)); + } + } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err?.message || String(err) })); + } + }); +} + +async function createServer(initialPath: string): Promise<{ server: http.Server; baseUrl: string }> { + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || '/', 'http://localhost'); + const pathname = url.pathname; + + const ctx = { + pathname, + url, + req, + res, + initialPath, + handlePostRequest, + broadcastToClients() {}, + server, + }; + + try { + const handled = await mod.handleSystemRoutes(ctx); + if (!handled) { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Not Found' })); + } + } catch (err: any) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: err?.message || String(err) })); + } + }); + + await new Promise((resolve) => server.listen(0, () => resolve())); + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + return { server, baseUrl: `http://127.0.0.1:${port}` }; +} + +describe('system routes integration', async () => { + before(async () => { + process.env.CCW_DATA_DIR = CCW_HOME; + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + mod = await import(systemRoutesUrl.href); + }); + + after(() => { + mock.restoreAll(); + process.env.CCW_DATA_DIR = originalEnv.CCW_DATA_DIR; + rmSync(CCW_HOME, { recursive: true, force: true }); + rmSync(PROJECT_ROOT, { recursive: true, force: true }); + }); + + it('GET /api/health returns ok payload', async () => { + const { server, baseUrl } = await createServer(PROJECT_ROOT); + try { + const res = await requestJson(baseUrl, 'GET', '/api/health'); + assert.equal(res.status, 200); + assert.equal(res.json.status, 'ok'); + assert.equal(typeof res.json.timestamp, 'number'); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('GET /api/data returns default workflow data when .workflow is missing', async () => { + const { server, baseUrl } = await createServer(PROJECT_ROOT); + try { + const res = await requestJson(baseUrl, 'GET', `/api/data?path=${encodeURIComponent(PROJECT_ROOT)}`); + assert.equal(res.status, 200); + + assert.equal(Array.isArray(res.json.activeSessions), true); + assert.equal(Array.isArray(res.json.archivedSessions), true); + assert.ok(typeof res.json.generatedAt === 'string' && res.json.generatedAt.length > 0); + assert.ok(typeof res.json.projectPath === 'string' && res.json.projectPath.length > 0); + assert.equal(Array.isArray(res.json.recentPaths), true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('GET /api/recent-paths returns an array and /api/switch-path validates paths', async () => { + const { server, baseUrl } = await createServer(PROJECT_ROOT); + try { + const recent = await requestJson(baseUrl, 'GET', '/api/recent-paths'); + assert.equal(recent.status, 200); + assert.equal(Array.isArray(recent.json.paths), true); + + const missing = await requestJson(baseUrl, 'GET', '/api/switch-path'); + assert.equal(missing.status, 400); + assert.ok(String(missing.json.error).includes('Path is required')); + + const invalidPath = join(PROJECT_ROOT, 'does-not-exist'); + const invalid = await requestJson(baseUrl, 'GET', `/api/switch-path?path=${encodeURIComponent(invalidPath)}`); + assert.equal(invalid.status, 404); + assert.ok(String(invalid.json.error).includes('Path does not exist')); + + const ok = await requestJson(baseUrl, 'GET', `/api/switch-path?path=${encodeURIComponent(PROJECT_ROOT)}`); + assert.equal(ok.status, 200); + assert.equal(ok.json.success, true); + assert.ok(typeof ok.json.path === 'string' && ok.json.path.length > 0); + assert.equal(Array.isArray(ok.json.recentPaths), true); + } finally { + await new Promise((resolve) => server.close(() => resolve())); + } + }); + + it('POST /api/shutdown responds and triggers graceful shutdown callbacks', async () => { + const { server, baseUrl } = await createServer(PROJECT_ROOT); + + const exitCalls: Array = []; + const originalSetTimeout = global.setTimeout; + + // Avoid the 3s forced-exit timer from calling the real process.exit later. + (global as any).setTimeout = ((fn: any, delay: number, ...args: any[]) => { + if (delay === 3000) { + return originalSetTimeout(() => {}, 0); + } + return originalSetTimeout(fn, delay, ...args); + }) as any; + + // Keep server open; mark that close was requested. + const originalClose = server.close.bind(server); + let closeRequested = 0; + (server as any).close = ((cb?: any) => { + closeRequested += 1; + if (cb) cb(); + return server; + }) as any; + + mock.method(process as any, 'exit', (code?: number) => { + exitCalls.push(code); + }); + + try { + const res = await requestJson(baseUrl, 'POST', '/api/shutdown', {}); + assert.equal(res.status, 200); + assert.equal(res.json.status, 'shutting_down'); + + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(closeRequested, 1); + assert.ok(exitCalls.includes(0)); + } finally { + (global as any).setTimeout = originalSetTimeout; + (server as any).close = originalClose as any; + await new Promise((resolve) => originalClose(() => resolve())); + } + }); +}); +