import { execFile } from 'node:child_process'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import http from 'node:http'; import { tmpdir } from 'node:os'; import { dirname, join, resolve as pathResolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { promisify } from 'node:util'; import { afterEach, describe, expect, it } from 'vitest'; const execFileP = promisify(execFile); const __dirname = dirname(fileURLToPath(import.meta.url)); const DAEMON_ROOT = pathResolve(__dirname, '..'); const REPO_ROOT = pathResolve(__dirname, '../../..'); const CLI_SRC = pathResolve(__dirname, '../src/cli.ts'); const TSX_CLI = pathResolve(REPO_ROOT, 'node_modules/tsx/dist/cli.mjs'); interface CapturedRequest { method: string; url: string; body: string; } interface StubServer { baseUrl: string; requests: CapturedRequest[]; close: () => Promise; } let stub: StubServer | null = null; let tempRoot = ''; afterEach(async () => { if (stub) await stub.close(); stub = null; if (tempRoot) rmSync(tempRoot, { recursive: true, force: true }); tempRoot = ''; }); async function startProjectStubServer(): Promise { const requests: CapturedRequest[] = []; const server = http.createServer((req, res) => { let raw = ''; req.on('data', (chunk) => { raw += chunk; }); req.on('end', () => { const captured: CapturedRequest = { method: req.method ?? '', url: req.url ?? '', body: raw, }; requests.push(captured); res.setHeader('content-type', 'application/json'); if (captured.method === 'POST' && captured.url === '/api/projects/source-project/design-system-copy') { res.statusCode = 201; res.end(JSON.stringify({ project: { id: 'design-copy-1', name: 'Design Copy' }, designSystemId: 'user:design-copy-1', conversationId: 'conversation-design-copy', })); return; } if (captured.method === 'POST' && captured.url === '/api/projects/source-project/duplicate') { res.statusCode = 201; res.end(JSON.stringify({ project: { id: 'duplicate-1', name: 'Duplicate Copy' }, conversationId: 'conversation-duplicate', })); return; } res.statusCode = 404; res.end(JSON.stringify({ error: { code: 'unexpected-request', message: captured.url } })); }); }); await new Promise((resolveListen) => server.listen(0, '127.0.0.1', resolveListen)); const addr = server.address(); if (!addr || typeof addr === 'string') throw new Error('stub server has no address'); return { baseUrl: `http://127.0.0.1:${addr.port}`, requests, close: () => new Promise((resolveClose, rejectClose) => { server.close((err) => (err ? rejectClose(err) : resolveClose())); }), }; } async function runCli(args: string[]): Promise<{ stdout: string; stderr: string; code: number | null }> { const env: NodeJS.ProcessEnv = { ...process.env }; delete env.NODE_OPTIONS; try { const { stdout, stderr } = await execFileP(process.execPath, [TSX_CLI, CLI_SRC, ...args], { cwd: DAEMON_ROOT, env, timeout: 15_000, maxBuffer: 4 * 1024 * 1024, }); return { stdout, stderr, code: 0 }; } catch (err) { const failed = err as { stdout?: string; stderr?: string; code?: number | null }; return { stdout: failed.stdout ?? '', stderr: failed.stderr ?? '', code: failed.code ?? 1, }; } } describe('od project CLI', () => { it('creates a design-system project with prompt-file content and JSON output', async () => { stub = await startProjectStubServer(); tempRoot = mkdtempSync(join(tmpdir(), 'od-project-cli-')); const promptPath = join(tempRoot, 'prompt.md'); writeFileSync(promptPath, 'Use this workspace as the brand source.\n', 'utf8'); const result = await runCli([ 'project', 'create-design-system', 'source-project', '--name', 'Design Copy', '--prompt-file', promptPath, '--json', '--daemon-url', stub.baseUrl, ]); expect(result.code).toBe(0); expect(result.stderr).toBe(''); expect(JSON.parse(result.stdout)).toEqual({ project: { id: 'design-copy-1', name: 'Design Copy' }, designSystemId: 'user:design-copy-1', conversationId: 'conversation-design-copy', }); expect(stub.requests).toHaveLength(1); expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/projects/source-project/design-system-copy', }); expect(JSON.parse(stub.requests[0]!.body)).toEqual({ name: 'Design Copy', pendingPrompt: 'Use this workspace as the brand source.\n', }); }); it('duplicates a project and prints the human-readable result', async () => { stub = await startProjectStubServer(); const result = await runCli([ 'project', 'duplicate', 'source-project', '--name', 'Duplicate Copy', '--daemon-url', stub.baseUrl, ]); expect(result.code).toBe(0); expect(result.stderr).toBe(''); expect(result.stdout).toBe( '[project] duplicated source-project as duplicate-1 (conversation conversation-duplicate)\n', ); expect(stub.requests).toHaveLength(1); expect(stub.requests[0]).toMatchObject({ method: 'POST', url: '/api/projects/source-project/duplicate', }); expect(JSON.parse(stub.requests[0]!.body)).toEqual({ name: 'Duplicate Copy' }); }); });