test(cli-executor): add gemini workflow integration tests

Solution-ID: SOL-1735410003

Issue-ID: ISS-1766921318981-23

Task-ID: T2
This commit is contained in:
catlog22
2025-12-29 17:29:48 +08:00
parent 3537c0fc74
commit 823e1dc487
3 changed files with 592 additions and 1 deletions

View File

@@ -0,0 +1,590 @@
/**
* 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();
}
});
});

View File

@@ -220,7 +220,6 @@ export function assertPathWithin(rootDir: string, targetPath: string): void {
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 {

2
gemini-workflow.test.ts Normal file
View File

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