mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-05 01:50:27 +08:00
test(cli-executor): add integration test infrastructure
Solution-ID: SOL-1735410003 Issue-ID: ISS-1766921318981-23 Task-ID: T1
This commit is contained in:
93
ccw/tests/integration/cli-executor/setup.test.ts
Normal file
93
ccw/tests/integration/cli-executor/setup.test.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
229
ccw/tests/integration/cli-executor/setup.ts
Normal file
229
ccw/tests/integration/cli-executor/setup.ts
Normal 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
|
||||
}
|
||||
}
|
||||
166
ccw/tests/integration/cli-executor/tool-stub.js
Normal file
166
ccw/tests/integration/cli-executor/tool-stub.js
Normal 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);
|
||||
});
|
||||
|
||||
2
cli-executor/setup.test.ts
Normal file
2
cli-executor/setup.test.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import '../ccw/tests/integration/cli-executor/setup.test.ts';
|
||||
|
||||
Reference in New Issue
Block a user