mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-02-06 01:54:11 +08:00
591 lines
18 KiB
TypeScript
591 lines
18 KiB
TypeScript
/**
|
|
* Integration tests for cli-executor: gemini workflows.
|
|
*
|
|
* Notes:
|
|
* - Targets the runtime implementation shipped in `ccw/dist`.
|
|
* - Uses stub CLI shims (gemini.cmd) to avoid external dependencies.
|
|
*/
|
|
|
|
import { after, before, beforeEach, describe, it, mock } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { readFileSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
import {
|
|
closeCliHistoryStores,
|
|
makeEnhancedPrompt,
|
|
setupTestEnv,
|
|
setupTestProject,
|
|
validateExecutionResult,
|
|
} from './setup.ts';
|
|
|
|
const cliExecutorUrl = new URL('../../../dist/tools/cli-executor.js', import.meta.url);
|
|
cliExecutorUrl.searchParams.set('t', String(Date.now()));
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
let cliExecutor: any;
|
|
|
|
function parseFirstJsonLine(text: string): any {
|
|
const line = text.split(/\r?\n/).find((l) => l.trim().length > 0);
|
|
return JSON.parse(line || '{}');
|
|
}
|
|
|
|
function normalizeSlash(value: string): string {
|
|
return value.replace(/\\/g, '/');
|
|
}
|
|
|
|
describe('cli-executor integration: gemini workflows', () => {
|
|
before(async () => {
|
|
mock.method(console, 'log', () => {});
|
|
mock.method(console, 'error', () => {});
|
|
cliExecutor = await import(cliExecutorUrl.href);
|
|
});
|
|
|
|
beforeEach(() => {
|
|
cliExecutor?.clearToolCache?.();
|
|
});
|
|
|
|
after(async () => {
|
|
try {
|
|
mock.restoreAll();
|
|
await closeCliHistoryStores();
|
|
} catch {
|
|
// ignore
|
|
}
|
|
});
|
|
|
|
it('executes analysis mode and passes model arg to gemini', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Analyze project structure',
|
|
task: 'List key modules',
|
|
mode: 'analysis',
|
|
context: '@src/**/*.ts',
|
|
expected: 'Module list',
|
|
rules: 'analysis=READ-ONLY',
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
});
|
|
|
|
validateExecutionResult(res, { success: true, tool: 'gemini' });
|
|
const payload = parseFirstJsonLine(res.stdout);
|
|
assert.equal(payload.tool, 'gemini');
|
|
assert.ok(payload.args.includes('-m'));
|
|
assert.ok(payload.args.includes('gemini-test-model'));
|
|
assert.equal(payload.args.includes('--approval-mode'), false);
|
|
assert.equal(payload.cwd, normalizeSlash(project.projectDir));
|
|
assert.equal(payload.prompt.trim(), prompt.trim());
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('executes write mode and includes --approval-mode yolo', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const outFile = join(project.projectDir, 'out.txt');
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Write output file',
|
|
task: 'Create out.txt with hello',
|
|
mode: 'write',
|
|
context: '@src/index.ts',
|
|
expected: 'out.txt created',
|
|
rules: 'write=CREATE',
|
|
directives: { write_files: { 'out.txt': 'hello' } },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'write',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
});
|
|
|
|
validateExecutionResult(res, { success: true, tool: 'gemini' });
|
|
const payload = parseFirstJsonLine(res.stdout);
|
|
assert.ok(payload.args.includes('--approval-mode'));
|
|
assert.ok(payload.args.includes('yolo'));
|
|
assert.equal(readFileSync(outFile, 'utf8'), 'hello');
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('resolves @patterns to 10+ files when directives.resolve_patterns=true', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Collect context',
|
|
task: 'Resolve patterns',
|
|
mode: 'analysis',
|
|
context: '@src/**/*.ts @py/**/*.py',
|
|
expected: 'Resolved files list',
|
|
rules: 'analysis=READ-ONLY',
|
|
directives: { resolve_patterns: true },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
});
|
|
|
|
const payload = parseFirstJsonLine(res.stdout);
|
|
assert.ok(Array.isArray(payload.resolved_files));
|
|
assert.ok(payload.resolved_files.length >= 10);
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('includeDirs allows resolving @../shared/**/* patterns', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Collect cross-directory context',
|
|
task: 'Resolve shared files',
|
|
mode: 'analysis',
|
|
context: '@../shared/**/*',
|
|
expected: 'Resolved shared files list',
|
|
rules: 'analysis=READ-ONLY',
|
|
directives: { resolve_patterns: true },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
includeDirs: '../shared',
|
|
});
|
|
|
|
const payload = parseFirstJsonLine(res.stdout);
|
|
assert.ok(payload.args.includes('--include-directories'));
|
|
assert.ok(payload.args.includes('../shared'));
|
|
assert.ok(payload.resolved_files.some((p: string) => String(p).startsWith('../shared/')));
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('without includeDirs, @../shared/**/* is ignored by stub resolver', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Collect cross-directory context',
|
|
task: 'Resolve shared files',
|
|
mode: 'analysis',
|
|
context: '@../shared/**/*',
|
|
expected: 'Resolved shared files list',
|
|
rules: 'analysis=READ-ONLY',
|
|
directives: { resolve_patterns: true },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
});
|
|
|
|
const payload = parseFirstJsonLine(res.stdout);
|
|
assert.equal(payload.resolved_files.some((p: string) => String(p).startsWith('../shared/')), false);
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('treats non-zero exit with valid output and non-fatal stderr as success', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Non-fatal exit scenario',
|
|
task: 'Return output even with non-zero exit',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'OK',
|
|
rules: 'analysis=READ-ONLY',
|
|
directives: { exit_code: 1, stdout: 'OK\n', stderr: 'warning: something\n' },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
});
|
|
|
|
assert.equal(res.success, true);
|
|
assert.equal(res.execution.status, 'success');
|
|
assert.equal(res.stdout.trim(), 'OK');
|
|
assert.ok(res.stderr.includes('warning'));
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('treats Authentication/API key stderr as fatal error', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Fatal error scenario',
|
|
task: 'Return fatal stderr',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'Error',
|
|
rules: 'analysis=READ-ONLY',
|
|
directives: { exit_code: 1, stdout: 'partial output\n', stderr: 'Authentication failed: API key missing\n' },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
});
|
|
|
|
assert.equal(res.success, false);
|
|
assert.equal(res.execution.status, 'error');
|
|
assert.equal(res.execution.tool, 'gemini');
|
|
assert.equal(res.execution.model, 'gemini-test-model');
|
|
assert.ok(res.stderr.includes('Authentication failed'));
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('streams stdout/stderr via onOutput callback', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const outputs: Array<{ type: string; data: string }> = [];
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Streaming output',
|
|
task: 'Emit stdout and stderr',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'Events captured',
|
|
rules: 'analysis=READ-ONLY',
|
|
directives: { stdout: 'hello', stderr: 'oops', exit_code: 0 },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool(
|
|
{
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
},
|
|
(data: { type: string; data: string }) => outputs.push(data),
|
|
);
|
|
|
|
assert.equal(res.success, true);
|
|
assert.ok(outputs.some((o) => o.type === 'stdout' && o.data.includes('hello')));
|
|
assert.ok(outputs.some((o) => o.type === 'stderr' && o.data.includes('oops')));
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('stream=true disables cached stdout_full/stderr_full in history turn output', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Streaming mode',
|
|
task: 'Disable caching',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'No full output cached',
|
|
rules: 'analysis=READ-ONLY',
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
stream: true,
|
|
});
|
|
|
|
assert.equal(res.execution.output.cached, false);
|
|
assert.equal(res.execution.output.stdout_full, undefined);
|
|
assert.equal(res.execution.output.stderr_full, undefined);
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('stream=false caches stdout_full/stderr_full by default', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Caching default',
|
|
task: 'Cache full output',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'Full output cached',
|
|
rules: 'analysis=READ-ONLY',
|
|
directives: { stdout: 'cached-output', stderr: 'cached-error' },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
});
|
|
|
|
assert.equal(res.execution.output.cached, true);
|
|
assert.equal(res.execution.output.stdout_full, 'cached-output');
|
|
assert.equal(res.execution.output.stderr_full, 'cached-error');
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('includes PURPOSE/TASK/MODE/CONTEXT/EXPECTED/RULES structure in delivered prompt', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Validate enhanced prompt',
|
|
task: 'Check all required fields',
|
|
mode: 'analysis',
|
|
context: '@**/*',
|
|
expected: 'Fields present',
|
|
rules: 'analysis=READ-ONLY',
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
});
|
|
|
|
const payload = parseFirstJsonLine(res.stdout);
|
|
assert.ok(payload.parsed);
|
|
for (const field of ['purpose', 'task', 'mode', 'context', 'expected', 'rules']) {
|
|
assert.equal(typeof payload.parsed[field], 'string');
|
|
assert.ok(String(payload.parsed[field]).length > 0);
|
|
}
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('resume=true uses native gemini latest flag (-r latest)', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Resume latest',
|
|
task: 'Use native resume',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'Args include -r latest',
|
|
rules: 'analysis=READ-ONLY',
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
resume: true,
|
|
});
|
|
|
|
const payload = parseFirstJsonLine(res.stdout);
|
|
const args = payload.args.map(String);
|
|
const idx = args.indexOf('-r');
|
|
assert.ok(idx >= 0);
|
|
assert.equal(args[idx + 1], 'latest');
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('noNative=true disables native resume flags', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Resume disabled',
|
|
task: 'Force prompt concat',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'No -r flag',
|
|
rules: 'analysis=READ-ONLY',
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
resume: true,
|
|
noNative: true,
|
|
});
|
|
|
|
const payload = parseFirstJsonLine(res.stdout);
|
|
assert.equal(payload.args.includes('-r'), false);
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('resume with --id and noNative preserves previous prompt context via concatenation', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const firstPrompt = makeEnhancedPrompt({
|
|
purpose: 'First turn',
|
|
task: 'Store conversation',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'Stored',
|
|
rules: 'analysis=READ-ONLY',
|
|
});
|
|
|
|
const first = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt: firstPrompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
id: 'CONV-GEMINI-1',
|
|
});
|
|
assert.equal(first.success, true);
|
|
|
|
const secondPrompt = makeEnhancedPrompt({
|
|
purpose: 'Second turn',
|
|
task: 'Resume and include previous context',
|
|
mode: 'analysis',
|
|
context: '@src/utils/**/*.ts',
|
|
expected: 'Previous prompt included',
|
|
rules: 'analysis=READ-ONLY',
|
|
});
|
|
|
|
const second = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt: secondPrompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
resume: 'CONV-GEMINI-1',
|
|
noNative: true,
|
|
});
|
|
|
|
const payload = parseFirstJsonLine(second.stdout);
|
|
assert.ok(String(payload.prompt).includes('First turn'));
|
|
assert.ok(String(payload.prompt).includes('Second turn'));
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
|
|
it('enforces internal timeout when timeout>0 and tool sleeps longer', async () => {
|
|
const env = setupTestEnv(['gemini']);
|
|
const project = setupTestProject();
|
|
try {
|
|
const prompt = makeEnhancedPrompt({
|
|
purpose: 'Timeout',
|
|
task: 'Simulate slow tool',
|
|
mode: 'analysis',
|
|
context: '@src/index.ts',
|
|
expected: 'Timeout status',
|
|
rules: 'analysis=READ-ONLY',
|
|
directives: { sleep_ms: 2000 },
|
|
});
|
|
|
|
const res = await cliExecutor.executeCliTool({
|
|
tool: 'gemini',
|
|
prompt,
|
|
mode: 'analysis',
|
|
model: 'gemini-test-model',
|
|
cd: project.projectDir,
|
|
timeout: 100,
|
|
});
|
|
|
|
assert.equal(res.success, false);
|
|
assert.equal(res.execution.status, 'timeout');
|
|
} finally {
|
|
await closeCliHistoryStores();
|
|
env.restore();
|
|
env.cleanup();
|
|
project.cleanup();
|
|
}
|
|
});
|
|
});
|