From 079ecdad3e10a8df89ea88ce9ef71ebf924d0dc1 Mon Sep 17 00:00:00 2001 From: catlog22 Date: Sun, 28 Dec 2025 23:47:13 +0800 Subject: [PATCH] test(commands): add unit tests for session and server commands Solution-ID: SOL-1735386000001 Issue-ID: ISS-1766921318981-15 Task-ID: T4 --- ccw/tests/serve-command.test.ts | 87 ++++++++++++++++++++++ ccw/tests/session-command.test.ts | 117 ++++++++++++++++++++++++++++++ ccw/tests/stop-command.test.ts | 110 ++++++++++++++++++++++++++++ ccw/tests/view-command.test.ts | 99 +++++++++++++++++++++++++ 4 files changed, 413 insertions(+) create mode 100644 ccw/tests/serve-command.test.ts create mode 100644 ccw/tests/session-command.test.ts create mode 100644 ccw/tests/stop-command.test.ts create mode 100644 ccw/tests/view-command.test.ts diff --git a/ccw/tests/serve-command.test.ts b/ccw/tests/serve-command.test.ts new file mode 100644 index 00000000..eed848ea --- /dev/null +++ b/ccw/tests/serve-command.test.ts @@ -0,0 +1,87 @@ +/** + * Unit tests for Serve command module (ccw serve) + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Uses Node's built-in test runner (node:test). + * - Disables browser launch and captures SIGINT handler to shut down server cleanly. + */ + +import { afterEach, before, describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +class ExitError extends Error { + code?: number; + + constructor(code?: number) { + super(`process.exit(${code ?? 'undefined'})`); + this.code = code; + } +} + +const serveCommandPath = new URL('../dist/commands/serve.js', import.meta.url).href; + +describe('serve command module', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let serveModule: any; + + before(async () => { + serveModule = await import(serveCommandPath); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('starts server with browser disabled and shuts down via captured SIGINT handler', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'ccw-serve-workspace-')); + + try { + let sigintHandler: (() => void) | null = null; + + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + + const exitCodes: Array = []; + mock.method(process as any, 'exit', (code?: number) => { + exitCodes.push(code); + }); + + const originalOn = process.on.bind(process); + mock.method(process as any, 'on', (event: string, handler: any) => { + if (event === 'SIGINT') { + sigintHandler = handler; + return process; + } + return originalOn(event, handler); + }); + + await serveModule.serveCommand({ port: 56790, browser: false, path: workspace }); + assert.ok(sigintHandler, 'Expected serveCommand to register SIGINT handler'); + + sigintHandler?.(); + await new Promise((resolve) => setTimeout(resolve, 300)); + + assert.ok(exitCodes.includes(0)); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); + + it('fails fast on invalid path', async () => { + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + mock.method(process as any, 'exit', (code?: number) => { + throw new ExitError(code); + }); + + await assert.rejects( + serveModule.serveCommand({ port: 56791, browser: false, path: 'Z:\\this-path-should-not-exist' }), + (err: any) => err instanceof ExitError && err.code === 1, + ); + }); +}); + diff --git a/ccw/tests/session-command.test.ts b/ccw/tests/session-command.test.ts new file mode 100644 index 00000000..ea4ea665 --- /dev/null +++ b/ccw/tests/session-command.test.ts @@ -0,0 +1,117 @@ +/** + * Unit tests for Session command module (ccw session) + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Uses Node's built-in test runner (node:test). + * - Runs in an isolated temp workspace to avoid touching repo `.workflow`. + */ + +import { after, afterEach, before, beforeEach, describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import http from 'node:http'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +class ExitError extends Error { + code?: number; + + constructor(code?: number) { + super(`process.exit(${code ?? 'undefined'})`); + this.code = code; + } +} + +const sessionCommandPath = new URL('../dist/commands/session.js', import.meta.url).href; + +function stubHttpRequest(): void { + mock.method(http, 'request', (_options: any, cb?: any) => { + const res = { statusCode: 204, on(_event: string, _handler: any) {} }; + if (cb) cb(res); + + const req: any = { + on(event: string, handler: any) { + if (event === 'socket') handler({ unref() {} }); + return req; + }, + write() {}, + end() {}, + destroy() {}, + }; + return req; + }); +} + +describe('session command module', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let sessionModule: any; + + const originalCwd = process.cwd(); + const testWorkspace = mkdtempSync(join(tmpdir(), 'ccw-session-command-')); + + before(async () => { + process.chdir(testWorkspace); + sessionModule = await import(sessionCommandPath); + }); + + beforeEach(() => { + stubHttpRequest(); + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + after(() => { + process.chdir(originalCwd); + rmSync(testWorkspace, { recursive: true, force: true }); + }); + + it('init requires session id', async () => { + mock.method(process as any, 'exit', (code?: number) => { + throw new ExitError(code); + }); + + await assert.rejects( + sessionModule.sessionCommand('init', [], {}), + (err: any) => err instanceof ExitError && err.code === 1, + ); + }); + + it('init + write + read roundtrip (plan)', async () => { + mock.method(process as any, 'exit', (code?: number) => { + throw new ExitError(code); + }); + + const sessionId = 'WFS-TEST-1'; + + await sessionModule.sessionCommand('init', [sessionId], { location: 'active' }); + + // Write plan content + await sessionModule.sessionCommand('write', [sessionId, 'IMPL_PLAN.md', '# Plan'], {}); + + // Verify file exists in temp workspace + const sessionDir = join(testWorkspace, '.workflow', 'active', sessionId); + assert.ok(existsSync(join(sessionDir, 'workflow-session.json'))); + assert.ok(existsSync(join(sessionDir, 'IMPL_PLAN.md'))); + assert.equal(readFileSync(join(sessionDir, 'IMPL_PLAN.md'), 'utf8').trim(), '# Plan'); + + // Read returns raw content + const outputs: string[] = []; + mock.method(console, 'log', (...args: any[]) => { + outputs.push(args.map(String).join(' ')); + }); + + await sessionModule.sessionCommand('read', [sessionId, 'IMPL_PLAN.md'], { raw: true }); + assert.ok(outputs.some((l) => l.includes('# Plan'))); + + // List shows the active session + outputs.length = 0; + await sessionModule.sessionCommand('list', [], { location: 'both', metadata: true }); + assert.ok(outputs.some((l) => l.includes(sessionId))); + }); +}); + diff --git a/ccw/tests/stop-command.test.ts b/ccw/tests/stop-command.test.ts new file mode 100644 index 00000000..e35fd24f --- /dev/null +++ b/ccw/tests/stop-command.test.ts @@ -0,0 +1,110 @@ +/** + * Unit tests for Stop command module (ccw stop) + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Uses Node's built-in test runner (node:test). + * - Mocks fetch and child_process.exec to avoid real netstat/taskkill. + */ + +import { after, afterEach, before, describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { createRequire } from 'node:module'; + +const stopCommandPath = new URL('../dist/commands/stop.js', import.meta.url).href; + +describe('stop command module', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let stopModule: any; + + const require = createRequire(import.meta.url); + const childProcess = require('child_process'); + const originalExec = childProcess.exec; + const execCalls: string[] = []; + + before(async () => { + // Patch child_process.exec BEFORE importing stop module (it captures exec at module init). + childProcess.exec = (command: string, cb: any) => { + execCalls.push(command); + if (/^netstat -ano/i.test(command)) { + const stdout = 'TCP 0.0.0.0:56792 0.0.0.0:0 LISTENING 4242\r\n'; + cb(null, stdout, ''); + return {} as any; + } + if (/^taskkill /i.test(command)) { + cb(null, '', ''); + return {} as any; + } + cb(new Error(`Unexpected exec: ${command}`), '', ''); + return {} as any; + }; + + stopModule = await import(stopCommandPath); + }); + + afterEach(() => { + execCalls.length = 0; + mock.restoreAll(); + }); + + after(() => { + childProcess.exec = originalExec; + }); + + it('gracefully stops when CCW server responds to health check', async () => { + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + const exitCodes: Array = []; + mock.method(process as any, 'exit', (code?: number) => { + exitCodes.push(code); + }); + + mock.method(globalThis as any, 'fetch', async (url: string, init?: any) => { + if (url.includes('/api/health')) { + return { ok: true }; + } + if (url.includes('/api/shutdown')) { + return { ok: true }; + } + throw new Error(`Unexpected fetch: ${url} ${JSON.stringify(init)}`); + }); + + await stopModule.stopCommand({ port: 56792 }); + assert.ok(exitCodes.includes(0)); + assert.ok(!exitCodes.includes(1)); + }); + + it('force-kills process when port is in use and --force is set (mocked)', async () => { + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + const exitCodes: Array = []; + mock.method(process as any, 'exit', (code?: number) => { + exitCodes.push(code); + }); + + // No server responding, fall back to netstat/taskkill + mock.method(globalThis as any, 'fetch', async () => null); + + await stopModule.stopCommand({ port: 56792, force: true }); + assert.ok(execCalls.some((c) => /^taskkill /i.test(c))); + assert.ok(exitCodes.includes(0)); + assert.ok(!exitCodes.includes(1)); + }); + + it('does not kill process when --force is not set (exits 0 with guidance)', async () => { + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + const exitCodes: Array = []; + mock.method(process as any, 'exit', (code?: number) => { + exitCodes.push(code); + }); + + mock.method(globalThis as any, 'fetch', async () => null); + + await stopModule.stopCommand({ port: 56792, force: false }); + assert.ok(execCalls.some((c) => /^netstat -ano/i.test(c))); + assert.ok(!execCalls.some((c) => /^taskkill /i.test(c))); + assert.ok(exitCodes.includes(0)); + assert.ok(!exitCodes.includes(1)); + }); +}); diff --git a/ccw/tests/view-command.test.ts b/ccw/tests/view-command.test.ts new file mode 100644 index 00000000..bb9e23a2 --- /dev/null +++ b/ccw/tests/view-command.test.ts @@ -0,0 +1,99 @@ +/** + * Unit tests for View command module (ccw view) + * + * Notes: + * - Targets the runtime implementation shipped in `ccw/dist`. + * - Uses Node's built-in test runner (node:test). + * - Mocks fetch + SIGINT handling to avoid opening browsers and leaving servers running. + */ + +import { afterEach, before, describe, it, mock } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +class ExitError extends Error { + code?: number; + + constructor(code?: number) { + super(`process.exit(${code ?? 'undefined'})`); + this.code = code; + } +} + +const viewCommandPath = new URL('../dist/commands/view.js', import.meta.url).href; + +describe('view command module', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let viewModule: any; + + before(async () => { + viewModule = await import(viewCommandPath); + }); + + afterEach(() => { + mock.restoreAll(); + }); + + it('opens URL without launching browser when server is running', async () => { + const logs: string[] = []; + mock.method(console, 'log', (...args: any[]) => logs.push(args.map(String).join(' '))); + mock.method(console, 'error', (...args: any[]) => logs.push(args.map(String).join(' '))); + mock.method(process as any, 'exit', (code?: number) => { + throw new ExitError(code); + }); + + mock.method(globalThis as any, 'fetch', async (url: string) => { + if (url.includes('/api/health')) { + return { ok: true }; + } + if (url.includes('/api/switch-path')) { + return { + ok: true, + json: async () => ({ success: true, path: 'C:\\test-workspace' }), + }; + } + throw new Error(`Unexpected fetch: ${url}`); + }); + + await viewModule.viewCommand({ port: 3456, browser: false }); + assert.ok(logs.some((l) => l.includes('Server already running'))); + assert.ok(logs.some((l) => l.includes('URL: http://localhost:3456/'))); + }); + + it('starts server when not running (browser disabled) and can be shut down via captured SIGINT handler', async () => { + const workspace = mkdtempSync(join(tmpdir(), 'ccw-view-workspace-')); + + try { + let sigintHandler: (() => void) | null = null; + + mock.method(console, 'log', () => {}); + mock.method(console, 'error', () => {}); + mock.method(process as any, 'exit', (_code?: number) => {}); + + const originalOn = process.on.bind(process); + mock.method(process as any, 'on', (event: string, handler: any) => { + if (event === 'SIGINT') { + sigintHandler = handler; + return process; + } + return originalOn(event, handler); + }); + + mock.method(globalThis as any, 'fetch', async (_url: string) => { + // Make health check fail so viewCommand triggers serveCommand + throw new Error('Server not running'); + }); + + await viewModule.viewCommand({ port: 56789, browser: false, path: workspace }); + assert.ok(sigintHandler, 'Expected serveCommand to register SIGINT handler'); + + sigintHandler?.(); + await new Promise((resolve) => setTimeout(resolve, 200)); + } finally { + rmSync(workspace, { recursive: true, force: true }); + } + }); +}); +