mirror of
https://github.com/di-sukharev/opencommit.git
synced 2026-04-20 03:02:51 -04:00
chore: tighten cli validation and tooling checks
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
|
||||
108
test/e2e/geminiBehavior.test.ts
Normal file
108
test/e2e/geminiBehavior.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
})
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user