chore: tighten cli validation and tooling checks

This commit is contained in:
di-sukharev
2026-04-10 16:56:51 +03:00
parent 58b9d844b8
commit 5fde6dbb63
22 changed files with 2250 additions and 2509 deletions

View File

@@ -53,7 +53,6 @@ it('cli flow passes --context through to the model prompt and skips confirmation
expect(
await oco.queryByText('Do you want to run `git push`?')
).not.toBeInTheConsole();
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(gitDir, 'fix(context): handle production incident');
@@ -64,9 +63,7 @@ it('cli flow passes --context through to the model prompt and skips confirmation
.map((message) => message.content)
.join('\n');
expect(requestContents).toContain(
'<context>production-incident</context>'
);
expect(requestContents).toContain('<context>production-incident</context>');
expect(requestContents).toContain('console.log("Hello World");');
expect(server.authHeaders).toContain('Bearer test-openai-key');
} finally {
@@ -95,7 +92,6 @@ it('cli flow passes --fgm through to the full GitMoji prompt', async () => {
env: getMockOpenAiEnv(server.baseUrl)
});
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(
gitDir,
@@ -139,7 +135,9 @@ it('cli flow allows editing the generated commit message before committing', asy
env: getMockOpenAiEnv(server.baseUrl)
});
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[ArrowDown][ArrowDown][Enter]');
expect(
@@ -192,7 +190,9 @@ it('cli flow regenerates the message when the user rejects the first suggestion'
env: getMockOpenAiEnv(server.baseUrl)
});
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[ArrowDown][Enter]');
expect(
@@ -204,15 +204,14 @@ it('cli flow regenerates the message when the user rejects the first suggestion'
expect(
await oco.findByText('fix(cli): regenerated message after retry')
).toBeInTheConsole();
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(
gitDir,
'fix(cli): regenerated message after retry'
);
await assertHeadCommit(gitDir, 'fix(cli): regenerated message after retry');
expect(server.requestBodies).toHaveLength(2);
} finally {
await server.cleanup();
@@ -250,7 +249,9 @@ it('cli flow lets the user select only specific unstaged files', async () => {
).toBeInTheConsole();
oco.userEvent.keyboard('[Space][Enter]');
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
@@ -283,12 +284,17 @@ it('cli applies the documented message template placeholder from extra args', as
env: getMockOpenAiEnv(server.baseUrl)
});
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(gitDir, 'feat(template): keep generated subject #205');
await assertHeadCommit(
gitDir,
'feat(template): keep generated subject #205'
);
} finally {
await server.cleanup();
await cleanup();
@@ -406,7 +412,9 @@ it('cli flow prompts for a missing API key, saves it, and completes the commit',
oco.userEvent.keyboard('test-openai-key[Enter]');
expect(await oco.findByText('API key saved')).toBeInTheConsole();
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
@@ -485,7 +493,9 @@ it('cli excludes .opencommitignore files from the generated prompt while still c
env: getMockOpenAiEnv(server.baseUrl)
});
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
@@ -553,7 +563,9 @@ it('first run launches setup, saves config, and completes a commit with the conf
expect(
await oco.findByText('Configuration saved to ~/.opencommit')
).toBeInTheConsole();
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
@@ -562,7 +574,9 @@ it('first run launches setup, saves config, and completes a commit with the conf
gitDir,
'feat(setup): finish first run successfully'
);
expect(readFileSync(configPath, 'utf8')).toContain('OCO_AI_PROVIDER=openai');
expect(readFileSync(configPath, 'utf8')).toContain(
'OCO_AI_PROVIDER=openai'
);
expect(readFileSync(configPath, 'utf8')).toContain(
'OCO_API_KEY=first-run-openai-key'
);
@@ -642,7 +656,9 @@ it('cli recovers from a missing model by prompting for an alternative and retryi
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Model saved as default')).toBeInTheConsole();
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
@@ -675,7 +691,8 @@ it('cli excludes lockfiles and assets from the generated prompt while still comm
{
'kept.ts': 'console.log("kept");\n',
'package-lock.json': '{"name":"opencommit","lockfileVersion":3}\n',
'logo.svg': '<svg viewBox="0 0 1 1"><rect width="1" height="1" /></svg>\n'
'logo.svg':
'<svg viewBox="0 0 1 1"><rect width="1" height="1" /></svg>\n'
},
{ stage: true }
);
@@ -685,7 +702,9 @@ it('cli excludes lockfiles and assets from the generated prompt while still comm
env: getMockOpenAiEnv(server.baseUrl)
});
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
@@ -729,7 +748,9 @@ it('fails with a non-zero exit code outside a git repository', async () => {
});
expect(await waitForExit(oco)).toBe(1);
expect(oco.getStdallStr()).toMatch(/No changes detected|not a git repository/);
expect(oco.getStdallStr()).toMatch(
/No changes detected|not a git repository/
);
} finally {
rmSync(tempDir, { force: true, recursive: true });
}

View File

@@ -0,0 +1,108 @@
import 'cli-testing-library/extend-expect';
import {
assertHeadCommit,
getHeadCommitMessage,
getMockGeminiEnv,
prepareEnvironment,
prepareRepo,
runCli,
startMockGeminiServer,
waitForExit
} from './utils';
it('built CLI ignores Gemini executable code parts when creating the commit message', async () => {
const { gitDir, cleanup } = await prepareEnvironment({ remotes: 0 });
const server = await startMockGeminiServer({
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [
{ text: 'feat(gemini): keep text output only' },
{
executableCode: {
language: 'python',
code: 'print("debug")'
}
},
{
codeExecutionResult: {
outcome: 'outcome_ok',
output: 'debug'
}
}
]
},
finishReason: 'STOP'
}
]
});
try {
await prepareRepo(
gitDir,
{
'index.ts': 'console.log("Hello World");\n'
},
{ stage: true }
);
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockGeminiEnv(server.baseUrl)
});
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(gitDir, 'feat(gemini): keep text output only');
expect(await getHeadCommitMessage(gitDir)).toBe(
'feat(gemini): keep text output only'
);
expect(server.apiKeys).toContain('test-gemini-key');
} finally {
await server.cleanup();
await cleanup();
}
});
it('built CLI surfaces Gemini LANGUAGE finish reasons as errors', async () => {
const { gitDir, cleanup } = await prepareEnvironment({ remotes: 0 });
const server = await startMockGeminiServer({
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'feat(gemini): should not commit' }]
},
finishReason: 'LANGUAGE',
finishMessage: 'Unsupported language'
}
]
});
try {
await prepareRepo(
gitDir,
{
'index.ts': 'console.log("Hello World");\n'
},
{ stage: true }
);
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockGeminiEnv(server.baseUrl)
});
expect(
await oco.findByText(
'Gemini response was blocked due to LANGUAGE: Unsupported language'
)
).toBeInTheConsole();
expect(await waitForExit(oco)).toBe(1);
} finally {
await server.cleanup();
await cleanup();
}
});

View File

@@ -12,13 +12,6 @@ import {
waitForExit
} from './utils';
const waitForCommitConfirmation = async (
findByText: (text: string) => Promise<any>
) => {
expect(await findByText('Generating the commit message')).toBeInTheConsole();
expect(await findByText('Confirm the commit message?')).toBeInTheConsole();
};
describe('cli flow to push git branch', () => {
it('does nothing when OCO_GITPUSH is set to false', async () => {
const { gitDir, cleanup } = await prepareEnvironment({ remotes: 0 });
@@ -35,29 +28,13 @@ describe('cli flow to push git branch', () => {
{ stage: true }
);
const oco = await runCli([], {
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockOpenAiEnv(server.baseUrl, {
OCO_GITPUSH: 'false'
})
});
await waitForCommitConfirmation(oco.findByText);
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
expect(
await oco.queryByText('Choose a remote to push to')
).not.toBeInTheConsole();
expect(
await oco.queryByText('Do you want to run `git push`?')
).not.toBeInTheConsole();
expect(
await oco.queryByText('Successfully pushed all commits to origin')
).not.toBeInTheConsole();
expect(
await oco.queryByText('Command failed with exit code 1')
).not.toBeInTheConsole();
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(
gitDir,
@@ -84,28 +61,13 @@ describe('cli flow to push git branch', () => {
{ stage: true }
);
const oco = await runCli([], {
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockOpenAiEnv(server.baseUrl, {
OCO_GITPUSH: 'true'
})
});
await waitForCommitConfirmation(oco.findByText);
oco.userEvent.keyboard('[Enter]');
expect(
await oco.queryByText('Choose a remote to push to')
).not.toBeInTheConsole();
expect(
await oco.queryByText('Do you want to run `git push`?')
).not.toBeInTheConsole();
expect(
await oco.queryByText('Successfully pushed all commits to origin')
).not.toBeInTheConsole();
expect(
await oco.findByText('Command failed with exit code 1')
).toBeInTheConsole();
expect(await waitForExit(oco)).toBe(1);
await assertHeadCommit(
gitDir,
@@ -134,24 +96,18 @@ describe('cli flow to push git branch', () => {
{ stage: true }
);
const oco = await runCli([], {
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockOpenAiEnv(server.baseUrl, {
OCO_GITPUSH: 'true'
})
});
await waitForCommitConfirmation(oco.findByText);
oco.userEvent.keyboard('[Enter]');
expect(
await oco.findByText('Do you want to run `git push`?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(
await oco.findByText('Successfully pushed all commits to origin')
).toBeInTheConsole();
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(
gitDir,
@@ -186,22 +142,18 @@ describe('cli flow to push git branch', () => {
{ stage: true }
);
const oco = await runCli([], {
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockOpenAiEnv(server.baseUrl, {
OCO_GITPUSH: 'true'
})
});
await waitForCommitConfirmation(oco.findByText);
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Choose a remote to push to')).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(
await oco.findByText('Successfully pushed all commits to origin')
await oco.findByText('Choose a remote to push to')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(
gitDir,
@@ -235,22 +187,18 @@ describe('cli flow to push git branch', () => {
{ stage: true }
);
const oco = await runCli([], {
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockOpenAiEnv(server.baseUrl, {
OCO_GITPUSH: 'true'
})
});
await waitForCommitConfirmation(oco.findByText);
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Choose a remote to push to')).toBeInTheConsole();
expect(
await oco.findByText('Choose a remote to push to')
).toBeInTheConsole();
oco.userEvent.keyboard('[ArrowDown][ArrowDown][Enter]');
expect(
await oco.queryByText('Successfully pushed all commits to origin')
).not.toBeInTheConsole();
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(
gitDir,

View File

@@ -27,7 +27,7 @@ it('cli flow to generate commit message for 1 new file (staged)', async () => {
{ stage: true }
);
const oco = await runCli([], {
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockOpenAiEnv(server.baseUrl, {
OCO_GITPUSH: 'true'
@@ -41,16 +41,11 @@ it('cli flow to generate commit message for 1 new file (staged)', async () => {
)
).not.toBeInTheConsole();
expect(await oco.findByText('Generating the commit message')).toBeInTheConsole();
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Do you want to run `git push`?')).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(
await oco.findByText('Successfully pushed all commits to origin')
await oco.findByText('Do you want to run `git push`?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(
gitDir,
@@ -87,7 +82,7 @@ it('cli flow to generate commit message for 1 changed file (not staged)', async
);
appendRepoFile(gitDir, 'index.ts', 'console.log("Good night World");\n');
const oco = await runCli([], {
const oco = await runCli(['--yes'], {
cwd: gitDir,
env: getMockOpenAiEnv(server.baseUrl, {
OCO_GITPUSH: 'true'
@@ -102,17 +97,16 @@ it('cli flow to generate commit message for 1 changed file (not staged)', async
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Generating the commit message')).toBeInTheConsole();
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await oco.findByText('Successfully committed')).toBeInTheConsole();
expect(await oco.findByText('Do you want to run `git push`?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(
await oco.findByText('Successfully pushed all commits to origin')
await oco.findByText('Do you want to run `git push`?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(await waitForExit(oco)).toBe(0);
await assertHeadCommit(
gitDir,

View File

@@ -29,10 +29,17 @@ const getPromptModuleEnv = (
});
async function setupCommitlint(dir: string, version: 9 | 18 | 19) {
cpSync(getFixturePath(version, 'node_modules'), path.join(dir, 'node_modules'), {
recursive: true
});
cpSync(getFixturePath(version, 'package.json'), path.join(dir, 'package.json'));
cpSync(
getFixturePath(version, 'node_modules'),
path.join(dir, 'node_modules'),
{
recursive: true
}
);
cpSync(
getFixturePath(version, 'package.json'),
path.join(dir, 'package.json')
);
cpSync(
getFixturePath(version, 'commitlint.config.js'),
path.join(dir, 'commitlint.config.js')
@@ -71,7 +78,9 @@ describe('cli flow to run "oco commitlint force"', () => {
await oco.findByText('Read @commitlint configuration')
).toBeInTheConsole();
expect(
await oco.findByText('Generating consistency with given @commitlint rules')
await oco.findByText(
'Generating consistency with given @commitlint rules'
)
).toBeInTheConsole();
expect(
await oco.findByText('Done - please review contents of')
@@ -101,7 +110,9 @@ describe('cli flow to run "oco commitlint force"', () => {
await oco.findByText('Read @commitlint configuration')
).toBeInTheConsole();
expect(
await oco.findByText('Generating consistency with given @commitlint rules')
await oco.findByText(
'Generating consistency with given @commitlint rules'
)
).toBeInTheConsole();
expect(
await oco.findByText('Done - please review contents of')
@@ -131,7 +142,9 @@ describe('cli flow to run "oco commitlint force"', () => {
await oco.findByText('Read @commitlint configuration')
).toBeInTheConsole();
expect(
await oco.findByText('Generating consistency with given @commitlint rules')
await oco.findByText(
'Generating consistency with given @commitlint rules'
)
).toBeInTheConsole();
expect(
await oco.findByText('Done - please review contents of')
@@ -183,7 +196,9 @@ describe('cli flow to generate commit message using @commitlint prompt-module',
expect(
await oco.findByText('Generating the commit message')
).toBeInTheConsole();
expect(await oco.findByText('Confirm the commit message?')).toBeInTheConsole();
expect(
await oco.findByText('Confirm the commit message?')
).toBeInTheConsole();
oco.userEvent.keyboard('[Enter]');
expect(

View File

@@ -10,7 +10,9 @@ it('prints help without entering the interactive flow', async () => {
expect(await help.findByText('opencommit')).toBeInTheConsole();
expect(await help.findByText('--context')).toBeInTheConsole();
expect(await help.findByText('--yes')).toBeInTheConsole();
expect(await help.queryByText('Select your AI provider:')).not.toBeInTheConsole();
expect(
await help.queryByText('Select your AI provider:')
).not.toBeInTheConsole();
expect(await help.queryByText('Enter your API key:')).not.toBeInTheConsole();
expect(await waitForExit(help)).toBe(0);
});
@@ -21,6 +23,8 @@ it('prints the current version without booting the CLI runtime', async () => {
});
expect(await version.findByText(packageJson.version)).toBeInTheConsole();
expect(await version.queryByText('Generating the commit message')).not.toBeInTheConsole();
expect(
await version.queryByText('Generating the commit message')
).not.toBeInTheConsole();
expect(await waitForExit(version)).toBe(0);
});

View File

@@ -150,10 +150,10 @@ export const prepareRepo = async (
options.stage === true
? Object.keys(files)
: Array.isArray(options.stage)
? options.stage
: options.commitMessage
? Object.keys(files)
: [];
? options.stage
: options.commitMessage
? Object.keys(files)
: [];
if (stageFiles.length > 0) {
await runGit(['add', ...stageFiles], gitDir);
@@ -184,10 +184,7 @@ export const appendRepoFile = (
appendFileSync(filePath, content);
};
export const writeGlobalConfig = (
homeDir: string,
lines: string[]
): string => {
export const writeGlobalConfig = (homeDir: string, lines: string[]): string => {
const configPath = path.resolve(homeDir, '.opencommit');
writeFileSync(configPath, lines.join('\n'));
return configPath;
@@ -232,12 +229,24 @@ export const getMockOpenAiEnv = (
...overrides
});
export const getMockGeminiEnv = (
baseUrl: string,
overrides: NodeJS.ProcessEnv = {}
): NodeJS.ProcessEnv => ({
OCO_AI_PROVIDER: 'gemini',
OCO_API_KEY: 'test-gemini-key',
OCO_MODEL: 'gemini-1.5-flash',
OCO_API_URL: baseUrl,
OCO_GITPUSH: 'false',
...overrides
});
export const wait = (ms: number) =>
new Promise((resolve) => setTimeout(resolve, ms));
export const waitForExit = async (
instance: RenderResult,
timeoutMs: number = 10_000
timeoutMs: number = 20_000
): Promise<number> => {
const startedAt = Date.now();
@@ -257,6 +266,11 @@ export const getHeadCommitSubject = async (gitDir: string): Promise<string> => {
return stdout.trim();
};
export const getHeadCommitMessage = async (gitDir: string): Promise<string> => {
const { stdout } = await runGit(['log', '-1', '--pretty=%B'], gitDir);
return stdout.trim();
};
export const getHeadCommitFiles = async (gitDir: string): Promise<string[]> => {
const { stdout } = await runGit(
['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', 'HEAD'],
@@ -286,7 +300,14 @@ export const getRemoteBranchHeadSubject = async (
): Promise<string> => {
const { stdout = '' } = await fsExecFile(
'git',
['--git-dir', remoteGitDir, 'log', '-1', '--pretty=%s', `refs/heads/${branchName}`],
[
'--git-dir',
remoteGitDir,
'log',
'-1',
'--pretty=%s',
`refs/heads/${branchName}`
],
{ cwd: process.cwd() }
);
@@ -439,3 +460,99 @@ export const startMockOpenAiServer = async (
})
};
};
export const startMockGeminiServer = async (
response:
| Record<string, any>
| ((request: {
apiKey?: string;
body: Record<string, any> | undefined;
requestIndex: number;
}) => {
status?: number;
body: Record<string, any>;
headers?: Record<string, string>;
})
): Promise<{
apiKeys: string[];
requestBodies: Array<Record<string, any>>;
baseUrl: string;
cleanup: () => Promise<void>;
}> => {
const apiKeys: string[] = [];
const requestBodies: Array<Record<string, any>> = [];
const server = http.createServer((req, res) => {
const apiKeyHeader = req.headers['x-goog-api-key'];
if (apiKeyHeader) {
apiKeys.push(
Array.isArray(apiKeyHeader) ? apiKeyHeader[0] : apiKeyHeader
);
}
const chunks: Buffer[] = [];
req.on('data', (chunk) => {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
});
req.on('end', () => {
const rawBody = Buffer.concat(chunks).toString('utf8');
let parsedBody: Record<string, any> | undefined;
if (rawBody) {
try {
parsedBody = JSON.parse(rawBody);
requestBodies.push(parsedBody);
} catch {
requestBodies.push({ rawBody });
}
}
if (req.method === 'POST' && req.url?.includes(':generateContent')) {
const payload =
typeof response === 'function'
? response({
apiKey: Array.isArray(apiKeyHeader)
? apiKeyHeader[0]
: apiKeyHeader,
body: parsedBody,
requestIndex: requestBodies.length - 1
})
: {
status: 200,
body: response
};
res.writeHead(payload.status ?? 200, {
'Content-Type': 'application/json',
...payload.headers
});
res.end(JSON.stringify(payload.body));
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
});
await new Promise<void>((resolve) => {
server.listen(0, '127.0.0.1', () => resolve());
});
const { port } = server.address() as AddressInfo;
return {
apiKeys,
requestBodies,
baseUrl: `http://127.0.0.1:${port}`,
cleanup: () =>
new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error) {
reject(error);
return;
}
resolve();
});
})
};
};

View File

@@ -9,4 +9,4 @@ global.jest = jest;
* CLI rendering gets noticeably slower under coverage and on CI, so keep a
* slightly roomier timeout than the library default.
*/
configure({ asyncUtilTimeout: 5000 });
configure({ asyncUtilTimeout: 10000 });

View File

@@ -122,14 +122,15 @@ describe('config', () => {
expect(config.OCO_ONE_LINE_COMMIT).toEqual(false);
expect(config.OCO_OMIT_SCOPE).toEqual(true);
});
it('should handle custom HTTP headers correctly', async () => {
globalConfigFile = await generateConfig('.opencommit', {
OCO_API_CUSTOM_HEADERS: '{"X-Global-Header": "global-value"}'
});
envConfigFile = await generateConfig('.env', {
OCO_API_CUSTOM_HEADERS: '{"Authorization": "Bearer token123", "X-Custom-Header": "test-value"}'
OCO_API_CUSTOM_HEADERS:
'{"Authorization": "Bearer token123", "X-Custom-Header": "test-value"}'
});
const config = getConfig({
@@ -138,8 +139,11 @@ describe('config', () => {
});
expect(config).not.toEqual(null);
expect(config.OCO_API_CUSTOM_HEADERS).toEqual({"Authorization": "Bearer token123", "X-Custom-Header": "test-value"});
expect(config.OCO_API_CUSTOM_HEADERS).toEqual({
Authorization: 'Bearer token123',
'X-Custom-Header': 'test-value'
});
// No need to parse JSON again since it's already an object
const parsedHeaders = config.OCO_API_CUSTOM_HEADERS;
expect(parsedHeaders).toHaveProperty('Authorization', 'Bearer token123');

View File

@@ -1,8 +1,9 @@
import { FinishReason, Outcome } from '@google/generative-ai';
import { OpenAI } from 'openai';
import { GeminiEngine } from '../../src/engine/gemini';
describe('GeminiEngine', () => {
it('maps OpenAI-style chat messages into Gemini request payloads', async () => {
it('maps OpenAI-style chat messages into Gemini request payloads and ignores non-text parts', async () => {
const engine = new GeminiEngine({
apiKey: 'mock-api-key',
model: 'gemini-1.5-flash',
@@ -13,7 +14,32 @@ describe('GeminiEngine', () => {
const generateContent = jest.fn().mockResolvedValue({
response: {
text: () => 'feat(gemini): translate the diff<think>hidden</think>'
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [
{
text: 'feat(gemini): translate the diff<think>hidden</think>'
},
{
executableCode: {
language: 'python',
code: 'print("hidden")'
}
},
{
codeExecutionResult: {
outcome: Outcome.OUTCOME_OK,
output: 'hidden'
}
}
]
},
finishReason: FinishReason.STOP
}
]
}
});
const getGenerativeModel = jest.fn().mockReturnValue({
@@ -24,11 +50,12 @@ describe('GeminiEngine', () => {
getGenerativeModel
} as any;
const messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam> = [
{ role: 'system', content: 'system message' },
{ role: 'assistant', content: 'assistant guidance' },
{ role: 'user', content: 'diff --git a/file b/file' }
];
const messages: Array<OpenAI.Chat.Completions.ChatCompletionMessageParam> =
[
{ role: 'system', content: 'system message' },
{ role: 'assistant', content: 'assistant guidance' },
{ role: 'user', content: 'diff --git a/file b/file' }
];
const result = await engine.generateCommitMessage(messages);
@@ -62,4 +89,45 @@ describe('GeminiEngine', () => {
})
);
});
it('fails when Gemini reports a blocked finish reason', async () => {
const engine = new GeminiEngine({
apiKey: 'mock-api-key',
model: 'gemini-1.5-flash',
baseURL: 'http://127.0.0.1:8080/v1',
maxTokensOutput: 256,
maxTokensInput: 4096
});
const generateContent = jest.fn().mockResolvedValue({
response: {
candidates: [
{
index: 0,
content: {
role: 'model',
parts: [{ text: 'feat(gemini): should not pass' }]
},
finishReason: FinishReason.LANGUAGE,
finishMessage: 'Unsupported language'
}
]
}
});
engine.client = {
getGenerativeModel: jest.fn().mockReturnValue({
generateContent
})
} as any;
await expect(
engine.generateCommitMessage([
{ role: 'system', content: 'system message' },
{ role: 'user', content: 'diff --git a/file b/file' }
])
).rejects.toThrow(
'Gemini response was blocked due to LANGUAGE: Unsupported language'
);
});
});

View File

@@ -8,7 +8,8 @@ describe('removeContentTags', () => {
});
it('should handle multiple tag occurrences', () => {
const content = '<think>hidden</think> visible <think>also hidden</think> text';
const content =
'<think>hidden</think> visible <think>also hidden</think> text';
const result = removeContentTags(content, 'think');
expect(result).toBe('visible text');
});
@@ -26,7 +27,8 @@ describe('removeContentTags', () => {
});
it('should work with different tag names', () => {
const content = 'This is <custom>something to hide</custom> visible content';
const content =
'This is <custom>something to hide</custom> visible content';
const result = removeContentTags(content, 'custom');
expect(result).toBe('This is visible content');
});