diff --git a/ccw/tests/integration/cli-executor/setup.test.ts b/ccw/tests/integration/cli-executor/setup.test.ts new file mode 100644 index 00000000..ad8b7a4e --- /dev/null +++ b/ccw/tests/integration/cli-executor/setup.test.ts @@ -0,0 +1,93 @@ +/** + * Integration test infrastructure for cli-executor. + * + * Notes: + * - Verifies mock project generation and stub CLI endpoint wiring. + * - Uses a temporary CCW data directory (CCW_DATA_DIR) to isolate config writes. + */ + +import { after, describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { existsSync, readdirSync, rmSync, statSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + closeCliHistoryStores, + createTestEndpoint, + setupTestEnv, + setupTestProject, + snapshotEnv, + restoreEnv, +} from './setup.ts'; + +function countFiles(dir: string): number { + let total = 0; + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) { + total += countFiles(p); + } else if (entry.isFile()) { + total += 1; + } else if (entry.isSymbolicLink()) { + try { + if (statSync(p).isFile()) total += 1; + } catch { + // ignore broken links + } + } + } + return total; +} + +describe('cli-executor integration: test infrastructure', () => { + after(async () => { + await closeCliHistoryStores(); + }); + + it('setupTestProject creates a mock project with 10+ files', () => { + const project = setupTestProject(); + try { + assert.equal(existsSync(project.projectDir), true); + assert.equal(existsSync(project.sharedDir), true); + assert.ok(project.sampleFiles.length >= 10); + assert.ok(countFiles(project.projectDir) >= 10); + } finally { + project.cleanup(); + } + }); + + it('createTestEndpoint writes tool command shim files', () => { + const env = setupTestEnv(['gemini']); + try { + const ep = createTestEndpoint('gemini', { binDir: env.binDir }); + assert.equal(existsSync(ep.commandPath), true); + } finally { + env.restore(); + env.cleanup(); + } + }); + + it('setupTestEnv isolates CCW_DATA_DIR and can be cleaned up', async () => { + const pathKey = Object.keys(process.env).find((k) => k.toLowerCase() === 'path') || 'Path'; + const snap = snapshotEnv(['CCW_DATA_DIR', pathKey]); + + const env = setupTestEnv(['gemini', 'qwen', 'codex']); + try { + assert.equal(process.env.CCW_DATA_DIR, env.ccwHome); + const pathVal = process.env[pathKey] || ''; + assert.ok(pathVal.startsWith(env.binDir)); + assert.equal(existsSync(env.ccwHome), true); + assert.equal(existsSync(env.binDir), true); + } finally { + await closeCliHistoryStores(); + env.restore(); + env.cleanup(); + restoreEnv(snap); + } + + assert.equal(process.env.CCW_DATA_DIR, snap.CCW_DATA_DIR); + assert.equal(process.env[pathKey], snap[pathKey]); + rmSync(env.ccwHome, { recursive: true, force: true }); + rmSync(env.binDir, { recursive: true, force: true }); + }); +}); diff --git a/ccw/tests/integration/cli-executor/setup.ts b/ccw/tests/integration/cli-executor/setup.ts new file mode 100644 index 00000000..d6b1070c --- /dev/null +++ b/ccw/tests/integration/cli-executor/setup.ts @@ -0,0 +1,229 @@ +import assert from 'node:assert/strict'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { delimiter, dirname, join, relative, resolve as resolvePath } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +export type CliToolName = 'gemini' | 'qwen' | 'codex'; + +export const DEFAULT_INTEGRATION_TEST_TIMEOUT_MS = 5 * 60 * 1000; + +const THIS_DIR = dirname(fileURLToPath(import.meta.url)); +export const CLI_TOOL_STUB_PATH = join(THIS_DIR, 'tool-stub.js'); + +export type EnvSnapshot = Record; + +function getPathKey(): string { + if (process.platform !== 'win32') return 'PATH'; + const existing = Object.keys(process.env).find((k) => k.toLowerCase() === 'path'); + return existing || 'Path'; +} + +export function snapshotEnv(keys: string[]): EnvSnapshot { + const snapshot: EnvSnapshot = {}; + for (const key of keys) snapshot[key] = process.env[key]; + return snapshot; +} + +export function restoreEnv(snapshot: EnvSnapshot): void { + for (const [key, value] of Object.entries(snapshot)) { + if (value === undefined) { + delete process.env[key]; + } else { + process.env[key] = value; + } + } +} + +export interface TestProject { + baseDir: string; + projectDir: string; + sharedDir: string; + sampleFiles: string[]; + cleanup: () => void; +} + +function writeFixtureFile(rootDir: string, filePath: string, content: string): void { + const absPath = join(rootDir, filePath); + mkdirSync(dirname(absPath), { recursive: true }); + writeFileSync(absPath, content, 'utf8'); +} + +export function setupTestProject(): TestProject { + const baseDir = mkdtempSync(join(tmpdir(), 'ccw-cli-executor-int-')); + const projectDir = join(baseDir, 'project'); + const sharedDir = join(baseDir, 'shared'); + mkdirSync(projectDir, { recursive: true }); + mkdirSync(sharedDir, { recursive: true }); + + const sampleFiles: string[] = [ + 'src/index.ts', + 'src/utils/math.ts', + 'src/utils/strings.ts', + 'src/services/api.ts', + 'src/models/user.ts', + 'src/models/order.ts', + 'scripts/build.ts', + 'py/main.py', + 'py/utils.py', + 'py/models.py', + 'py/services/api.py', + 'py/tests/test_basic.py', + ]; + + const sharedFiles: string[] = ['shared.ts', 'shared.py', 'constants.ts']; + + for (const relPath of sampleFiles) { + const ext = relPath.split('.').pop(); + const content = + ext === 'py' + ? `# ${relPath}\n\ndef hello(name: str) -> str:\n return f\"hello {name}\"\n` + : `// ${relPath}\nexport function hello(name: string): string {\n return \`hello \${name}\`;\n}\n`; + writeFixtureFile(projectDir, relPath, content); + } + + for (const relPath of sharedFiles) { + const ext = relPath.split('.').pop(); + const content = + ext === 'py' + ? `# shared/${relPath}\n\ndef shared() -> str:\n return \"shared\"\n` + : `// shared/${relPath}\nexport const SHARED = 'shared';\n`; + writeFixtureFile(sharedDir, relPath, content); + } + + return { + baseDir, + projectDir, + sharedDir, + sampleFiles, + cleanup() { + rmSync(baseDir, { recursive: true, force: true }); + }, + }; +} + +export interface TestEndpoint { + tool: CliToolName; + binDir: string; + commandPath: string; +} + +function writeExecutable(filePath: string, content: string): void { + mkdirSync(dirname(filePath), { recursive: true }); + writeFileSync(filePath, content, 'utf8'); + try { + chmodSync(filePath, 0o755); + } catch { + // ignore (Windows) + } +} + +export function createTestEndpoint(tool: CliToolName, options?: { binDir?: string }): TestEndpoint { + const binDir = options?.binDir ?? mkdtempSync(join(tmpdir(), 'ccw-cli-executor-bin-')); + const isWindows = process.platform === 'win32'; + + const commandPath = isWindows ? join(binDir, `${tool}.cmd`) : join(binDir, tool); + if (isWindows) { + writeExecutable( + commandPath, + `@echo off\r\nnode "${CLI_TOOL_STUB_PATH}" "${tool}" %*\r\n`, + ); + } else { + writeExecutable( + commandPath, + `#!/usr/bin/env sh\nexec node "${CLI_TOOL_STUB_PATH}" "${tool}" "$@"\n`, + ); + } + + return { tool, binDir, commandPath }; +} + +export interface TestEnv { + ccwHome: string; + binDir: string; + endpoints: TestEndpoint[]; + restore: () => void; + cleanup: () => void; +} + +export function setupTestEnv(tools: CliToolName[] = ['gemini', 'qwen', 'codex']): TestEnv { + const ccwHome = mkdtempSync(join(tmpdir(), 'ccw-cli-executor-home-')); + const binDir = mkdtempSync(join(tmpdir(), 'ccw-cli-executor-bin-')); + + const endpoints = tools.map((tool) => createTestEndpoint(tool, { binDir })); + + const pathKey = getPathKey(); + const envSnapshot = snapshotEnv(['CCW_DATA_DIR', pathKey]); + + process.env.CCW_DATA_DIR = ccwHome; + const existingPath = envSnapshot[pathKey] ?? ''; + process.env[pathKey] = existingPath ? `${binDir}${delimiter}${existingPath}` : binDir; + + return { + ccwHome, + binDir, + endpoints, + restore() { + restoreEnv(envSnapshot); + }, + cleanup() { + rmSync(binDir, { recursive: true, force: true }); + rmSync(ccwHome, { recursive: true, force: true }); + }, + }; +} + +export function validateExecutionResult( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + result: any, + expectations: { success?: boolean; tool?: CliToolName } = {}, +): void { + assert.equal(typeof result, 'object'); + assert.equal(typeof result.success, 'boolean'); + assert.equal(typeof result.stdout, 'string'); + assert.equal(typeof result.stderr, 'string'); + assert.equal(typeof result.execution, 'object'); + assert.equal(typeof result.conversation, 'object'); + + if (expectations.success !== undefined) assert.equal(result.success, expectations.success); + if (expectations.tool) assert.equal(result.execution.tool, expectations.tool); +} + +export function makeEnhancedPrompt(input: { + purpose: string; + task: string; + mode: 'analysis' | 'write' | 'auto'; + context: string; + expected: string; + rules: string; + directives?: Record; +}): string { + const base = [ + `PURPOSE: ${input.purpose}`, + `TASK: ${input.task}`, + `MODE: ${input.mode}`, + `CONTEXT: ${input.context}`, + `EXPECTED: ${input.expected}`, + `RULES: ${input.rules}`, + ].join('\n'); + + if (!input.directives) return base; + return `${base}\nCCW_TEST_DIRECTIVES: ${JSON.stringify(input.directives)}`; +} + +export function assertPathWithin(rootDir: string, targetPath: string): void { + const rel = relative(rootDir, targetPath); + assert.equal(rel.startsWith('..'), false); + assert.equal(resolvePath(rootDir, rel), resolvePath(targetPath)); +} + +export async function closeCliHistoryStores(): Promise { + try { + const url = new URL('../../../dist/tools/cli-history-store.js', import.meta.url); + url.searchParams.set('t', String(Date.now())); + const historyStoreMod: any = await import(url.href); + historyStoreMod?.closeAllStores?.(); + } catch { + // ignore + } +} diff --git a/ccw/tests/integration/cli-executor/tool-stub.js b/ccw/tests/integration/cli-executor/tool-stub.js new file mode 100644 index 00000000..347a5c0e --- /dev/null +++ b/ccw/tests/integration/cli-executor/tool-stub.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, resolve as resolvePath } from 'node:path'; +import { globSync } from 'glob'; + +function parseEnhancedPrompt(prompt) { + const fields = ['PURPOSE', 'TASK', 'MODE', 'CONTEXT', 'EXPECTED', 'RULES']; + const out = {}; + for (const field of fields) { + const line = prompt + .split(/\r?\n/) + .find((l) => l.startsWith(`${field}:`)); + out[field.toLowerCase()] = line ? line.slice(field.length + 1).trim() : null; + } + return out; +} + +function parseDirectives(prompt) { + const lines = prompt.split(/\r?\n/); + const directiveLine = lines.find((l) => l.startsWith('CCW_TEST_DIRECTIVES:')); + if (!directiveLine) return null; + const json = directiveLine.slice('CCW_TEST_DIRECTIVES:'.length).trim(); + if (!json) return null; + try { + return JSON.parse(json); + } catch { + return null; + } +} + +function parseIncludeDirs(tool, args) { + if (tool === 'gemini' || tool === 'qwen') { + const idx = args.indexOf('--include-directories'); + if (idx >= 0 && typeof args[idx + 1] === 'string') { + return String(args[idx + 1]) + .split(',') + .map((d) => d.trim()) + .filter(Boolean); + } + return []; + } + if (tool === 'codex') { + const dirs = []; + for (let i = 0; i < args.length; i++) { + if (args[i] === '--add-dir' && typeof args[i + 1] === 'string') { + dirs.push(String(args[i + 1])); + i++; + } + } + return dirs; + } + return []; +} + +function extractAtPatterns(prompt) { + const patterns = []; + const re = /(^|\s)@([^\s]+)/g; + let match; + while ((match = re.exec(prompt)) !== null) { + const raw = match[2] || ''; + const cleaned = raw.replace(/[),;"']+$/g, ''); + if (cleaned) patterns.push(cleaned); + } + return patterns; +} + +function normalizeSlash(value) { + return String(value).replace(/\\/g, '/'); +} + +function isOutsideCwdPattern(pattern) { + const p = normalizeSlash(pattern); + return p.startsWith('../') || p.startsWith('..\\'); +} + +function isAllowedOutsidePattern(pattern, includeDirs) { + const p = normalizeSlash(pattern); + const include = includeDirs.map((d) => normalizeSlash(d)); + return include.some((dir) => p === dir || p.startsWith(`${dir}/`)); +} + +function resolvePatterns(prompt, tool, args) { + const includeDirs = parseIncludeDirs(tool, args); + const patterns = extractAtPatterns(prompt); + + const files = new Set(); + for (const pattern of patterns) { + if (isOutsideCwdPattern(pattern) && !isAllowedOutsidePattern(pattern, includeDirs)) { + continue; + } + const matches = globSync(pattern, { + cwd: process.cwd(), + nodir: true, + dot: true, + windowsPathsNoEscape: true, + }); + for (const m of matches) files.add(normalizeSlash(m)); + } + + return Array.from(files).sort(); +} + +function safeWriteFiles(writeFiles) { + const wrote = []; + for (const [rel, content] of Object.entries(writeFiles || {})) { + const abs = resolvePath(process.cwd(), String(rel)); + if (!abs.startsWith(resolvePath(process.cwd()))) continue; + mkdirSync(dirname(abs), { recursive: true }); + writeFileSync(abs, String(content ?? ''), 'utf8'); + wrote.push(String(rel)); + } + return wrote.sort(); +} + +async function readStdin() { + if (process.stdin.isTTY) return ''; + return new Promise((resolve) => { + let buf = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', (chunk) => { + buf += chunk; + }); + process.stdin.on('end', () => resolve(buf)); + process.stdin.on('error', () => resolve(buf)); + }); +} + +async function main() { + const tool = String(process.argv[2] || 'unknown'); + const args = process.argv.slice(3).map(String); + const prompt = await readStdin(); + + const directives = parseDirectives(prompt) || {}; + const resolvedFiles = directives.resolve_patterns ? resolvePatterns(prompt, tool, args) : []; + const wroteFiles = directives.write_files ? safeWriteFiles(directives.write_files) : []; + + const payload = { + tool, + cwd: normalizeSlash(process.cwd()), + args, + prompt, + parsed: parseEnhancedPrompt(prompt), + resolved_files: resolvedFiles, + wrote_files: wroteFiles, + }; + + const stdoutText = + typeof directives.stdout === 'string' ? directives.stdout : `${JSON.stringify(payload)}\n`; + const stderrText = typeof directives.stderr === 'string' ? directives.stderr : ''; + const exitCode = Number.isFinite(Number(directives.exit_code)) ? Number(directives.exit_code) : 0; + const sleepMs = Number.isFinite(Number(directives.sleep_ms)) ? Number(directives.sleep_ms) : 0; + + if (sleepMs > 0) { + await new Promise((r) => setTimeout(r, sleepMs)); + } + + process.stdout.write(stdoutText); + if (stderrText) process.stderr.write(stderrText); + process.exit(exitCode); +} + +main().catch((err) => { + process.stderr.write(String(err?.stack || err?.message || err)); + process.exit(1); +}); + diff --git a/cli-executor/setup.test.ts b/cli-executor/setup.test.ts new file mode 100644 index 00000000..8e0dbfbd --- /dev/null +++ b/cli-executor/setup.test.ts @@ -0,0 +1,2 @@ +import '../ccw/tests/integration/cli-executor/setup.test.ts'; +