test(cli-executor): add integration test infrastructure

Solution-ID: SOL-1735410003

Issue-ID: ISS-1766921318981-23

Task-ID: T1
This commit is contained in:
catlog22
2025-12-29 17:15:15 +08:00
parent 3df1eac2fc
commit f3e23f0a57
4 changed files with 490 additions and 0 deletions

View File

@@ -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 });
});
});

View File

@@ -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<string, string | undefined>;
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, unknown>;
}): 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<void> {
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
}
}

View File

@@ -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);
});

View File

@@ -0,0 +1,2 @@
import '../ccw/tests/integration/cli-executor/setup.test.ts';