mirror of
https://github.com/di-sukharev/opencommit.git
synced 2026-04-20 03:02:51 -04:00
Compare commits
2 Commits
v3.2.19
...
refactorin
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5fde6dbb63 | ||
|
|
58b9d844b8 |
@@ -1,33 +0,0 @@
|
||||
{
|
||||
"extends": [
|
||||
"eslint:recommended",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:prettier/recommended"
|
||||
],
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 12,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["simple-import-sort", "import", "@typescript-eslint", "prettier"],
|
||||
"settings": {
|
||||
"import/resolver": {
|
||||
"node": {
|
||||
"extensions": [".js", ".jsx", ".ts", ".tsx"]
|
||||
}
|
||||
}
|
||||
},
|
||||
"packageManager": "npm",
|
||||
"rules": {
|
||||
"prettier/prettier": "error",
|
||||
"no-console": "error",
|
||||
"import/order": "off",
|
||||
"sort-imports": "off",
|
||||
"simple-import-sort/imports": "error",
|
||||
"simple-import-sort/exports": "error",
|
||||
"import/first": "error",
|
||||
"import/newline-after-import": "error",
|
||||
"import/no-duplicates": "error",
|
||||
"@typescript-eslint/no-non-null-assertion": "off"
|
||||
}
|
||||
}
|
||||
10
.github/ISSUE_TEMPLATE/bug.yaml
vendored
10
.github/ISSUE_TEMPLATE/bug.yaml
vendored
@@ -1,7 +1,7 @@
|
||||
name: 🐞 Bug Report
|
||||
description: File a bug report
|
||||
title: "[Bug]: "
|
||||
labels: ["bug", "triage"]
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', 'triage']
|
||||
assignees:
|
||||
- octocat
|
||||
body:
|
||||
@@ -48,7 +48,7 @@ body:
|
||||
label: What happened?
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: Tell us what you see!
|
||||
value: "A bug happened!"
|
||||
value: 'A bug happened!'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
@@ -58,7 +58,7 @@ body:
|
||||
description: Also tell us, what did you expect to happen?
|
||||
placeholder: Tell us what you expected to happen!
|
||||
validations:
|
||||
required: true
|
||||
required: true
|
||||
- type: textarea
|
||||
id: current-behavior
|
||||
attributes:
|
||||
@@ -88,4 +88,4 @@ body:
|
||||
attributes:
|
||||
label: Relevant log output
|
||||
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
|
||||
render: shell
|
||||
render: shell
|
||||
|
||||
6
.github/ISSUE_TEMPLATE/featureRequest.yaml
vendored
6
.github/ISSUE_TEMPLATE/featureRequest.yaml
vendored
@@ -1,9 +1,9 @@
|
||||
---
|
||||
name: 🛠️ Feature Request
|
||||
description: Suggest an idea to help us improve Opencommit
|
||||
title: "[Feature]: "
|
||||
title: '[Feature]: '
|
||||
labels:
|
||||
- "feature_request"
|
||||
- 'feature_request'
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
@@ -45,4 +45,4 @@ body:
|
||||
description: |
|
||||
Add any other context about the problem here.
|
||||
validations:
|
||||
required: false
|
||||
required: false
|
||||
|
||||
63
.github/workflows/codeql.yml
vendored
63
.github/workflows/codeql.yml
vendored
@@ -9,14 +9,14 @@
|
||||
# the `language` matrix defined below to confirm you have the correct set of
|
||||
# supported CodeQL languages.
|
||||
#
|
||||
name: "CodeQL"
|
||||
name: 'CodeQL'
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "master" ]
|
||||
branches: ['master']
|
||||
pull_request:
|
||||
# The branches below must be a subset of the branches above
|
||||
branches: [ "master" ]
|
||||
branches: ['master']
|
||||
schedule:
|
||||
- cron: '21 16 * * 0'
|
||||
|
||||
@@ -32,45 +32,44 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
language: [ 'javascript' ]
|
||||
language: ['javascript']
|
||||
# CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]
|
||||
# Use only 'java' to analyze code written in Java, Kotlin or both
|
||||
# Use only 'javascript' to analyze code written in JavaScript, TypeScript or both
|
||||
# Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||
# By default, queries listed here will override any specified in a config file.
|
||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
# Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs
|
||||
# queries: security-extended,security-and-quality
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
|
||||
# Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java).
|
||||
# If this step fails, then you should remove it and run the build manually (see below)
|
||||
- name: Autobuild
|
||||
uses: github/codeql-action/autobuild@v3
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
|
||||
# ℹ️ Command-line programs to run using the OS shell.
|
||||
# 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
|
||||
# If the Autobuild fails above, remove it and uncomment the following three lines.
|
||||
# modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
# - run: |
|
||||
# echo "Run, Build Application using script"
|
||||
# ./location_of_script_within_repo/buildscript.sh
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@v3
|
||||
with:
|
||||
category: '/language:${{matrix.language}}'
|
||||
|
||||
78
.github/workflows/test.yml
vendored
78
.github/workflows/test.yml
vendored
@@ -11,52 +11,38 @@ jobs:
|
||||
linux-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
- name: Setup git
|
||||
run: |
|
||||
git config --global user.email "test@example.com"
|
||||
git config --global user.name "Test User"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run Unit Tests
|
||||
run: npm run test:unit
|
||||
- name: Run Core E2E Tests
|
||||
run: npm run test:e2e:core
|
||||
- name: Run Prompt Module E2E Tests
|
||||
run: npm run test:e2e:prompt-module
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
- name: Setup git
|
||||
run: |
|
||||
git config --global user.email "test@example.com"
|
||||
git config --global user.name "Test User"
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run Lint
|
||||
run: npm run lint
|
||||
- name: Run Format Check
|
||||
run: npm run format:check
|
||||
- name: Run Unit Tests
|
||||
run: npm run test:unit
|
||||
- name: Run Core E2E Tests
|
||||
run: npm run test:e2e:core
|
||||
- name: Run Prompt Module E2E Tests
|
||||
run: npm run test:e2e:prompt-module
|
||||
macos-smoke:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run Smoke E2E Tests
|
||||
run: npm run test:e2e:smoke
|
||||
prettier:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run Prettier
|
||||
run: npm run format:check
|
||||
- name: Prettier Output
|
||||
if: failure()
|
||||
run: |
|
||||
echo "Prettier check failed. Please run 'npm run format' to fix formatting issues."
|
||||
exit 1
|
||||
- uses: actions/checkout@v4
|
||||
- name: Use Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x'
|
||||
cache: 'npm'
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Run Smoke E2E Tests
|
||||
run: npm run test:e2e:smoke
|
||||
|
||||
15
biome.json
15
biome.json
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.10/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
|
||||
|
||||
"vcs": {
|
||||
"enabled": true,
|
||||
@@ -13,7 +13,7 @@
|
||||
},
|
||||
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineEnding": "lf"
|
||||
@@ -33,7 +33,12 @@
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"suspicious": {
|
||||
"noConsole": "error"
|
||||
"noConsole": "off",
|
||||
"noImplicitAnyLet": "off",
|
||||
"useIterableCallbackReturn": "off"
|
||||
},
|
||||
"correctness": {
|
||||
"noSwitchDeclarations": "off"
|
||||
},
|
||||
"style": {
|
||||
"noNonNullAssertion": "off"
|
||||
@@ -42,10 +47,10 @@
|
||||
},
|
||||
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"enabled": false,
|
||||
"actions": {
|
||||
"source": {
|
||||
"organizeImports": "on"
|
||||
"organizeImports": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,7 @@ const config: Config = {
|
||||
testRegex: ['.*\\.test\\.ts$'],
|
||||
// Tell Jest to ignore the specific duplicate package.json files
|
||||
// that are causing Haste module naming collisions
|
||||
modulePathIgnorePatterns: [
|
||||
'<rootDir>/test/e2e/prompt-module/data/'
|
||||
],
|
||||
modulePathIgnorePatterns: ['<rootDir>/test/e2e/prompt-module/data/'],
|
||||
transformIgnorePatterns: [
|
||||
'node_modules/(?!(cli-testing-library|@clack|cleye|chalk)/.*)'
|
||||
],
|
||||
|
||||
2503
out/cli.cjs
2503
out/cli.cjs
File diff suppressed because it is too large
Load Diff
1424
package-lock.json
generated
1424
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -49,9 +49,10 @@
|
||||
"deploy": "npm publish --tag latest",
|
||||
"deploy:build": "npm run build:push && git push --tags && npm run deploy",
|
||||
"deploy:patch": "npm version patch && npm run deploy:build",
|
||||
"lint": "eslint src --ext ts && tsc --noEmit",
|
||||
"format": "prettier --write src",
|
||||
"format:check": "prettier --check src",
|
||||
"lint": "biome check . --diagnostic-level=error && tsc --noEmit",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"format": "prettier --write \"src/**/*.{ts,js,json,md}\" \"test/**/*.{ts,js,json,md}\" \".github/**/*.{yml,yaml}\" \"*.{js,json,ts,md,yml,yaml}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,js,json,md}\" \"test/**/*.{ts,js,json,md}\" \".github/**/*.{yml,yaml}\" \"*.{js,json,ts,md,yml,yaml}\"",
|
||||
"test": "node --no-warnings --experimental-vm-modules $( [ -f ./node_modules/.bin/jest ] && echo ./node_modules/.bin/jest || which jest ) test/unit",
|
||||
"test:all": "npm run test:unit:docker && npm run test:e2e:docker",
|
||||
"test:docker-build": "docker build -t oco-test -f test/Dockerfile .",
|
||||
@@ -61,7 +62,7 @@
|
||||
"test:e2e:smoke": "npm run build && npm run test:e2e:smoke:run",
|
||||
"test:e2e:smoke:run": "OCO_TEST_SKIP_VERSION_CHECK=true jest test/e2e/smoke.test.ts",
|
||||
"test:e2e:core": "npm run build && npm run test:e2e:core:run",
|
||||
"test:e2e:core:run": "OCO_TEST_SKIP_VERSION_CHECK=true jest test/e2e/cliBehavior.test.ts test/e2e/gitPush.test.ts test/e2e/oneFile.test.ts test/e2e/noChanges.test.ts",
|
||||
"test:e2e:core:run": "OCO_TEST_SKIP_VERSION_CHECK=true jest test/e2e/cliBehavior.test.ts test/e2e/geminiBehavior.test.ts test/e2e/gitPush.test.ts test/e2e/oneFile.test.ts test/e2e/noChanges.test.ts",
|
||||
"test:e2e:setup": "npm run test:e2e:prompt-module:setup",
|
||||
"test:e2e:prompt-module:setup": "sh test/e2e/setup.sh",
|
||||
"test:e2e:prompt-module": "npm run build && npm run test:e2e:prompt-module:run",
|
||||
@@ -70,17 +71,15 @@
|
||||
"mlx:start": "OCO_AI_PROVIDER='mlx' node ./out/cli.cjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.11",
|
||||
"@commitlint/types": "^17.4.4",
|
||||
"@types/ini": "^1.3.31",
|
||||
"@types/inquirer": "^9.0.3",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^16.18.14",
|
||||
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
||||
"@typescript-eslint/parser": "^8.29.0",
|
||||
"cli-testing-library": "^2.0.2",
|
||||
"dotenv": "^16.0.3",
|
||||
"esbuild": "^0.25.5",
|
||||
"eslint": "^9.24.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^2.8.4",
|
||||
"rimraf": "^6.0.1",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import {
|
||||
Content,
|
||||
FinishReason,
|
||||
GenerateContentResponse,
|
||||
GoogleGenerativeAI,
|
||||
HarmBlockThreshold,
|
||||
HarmCategory,
|
||||
@@ -12,6 +14,59 @@ import { AiEngine, AiEngineConfig } from './Engine';
|
||||
|
||||
interface GeminiConfig extends AiEngineConfig {}
|
||||
|
||||
const GEMINI_BLOCKING_FINISH_REASONS = new Set<FinishReason>([
|
||||
FinishReason.RECITATION,
|
||||
FinishReason.SAFETY,
|
||||
FinishReason.LANGUAGE
|
||||
]);
|
||||
|
||||
const formatGeminiBlockMessage = (
|
||||
response: GenerateContentResponse
|
||||
): string => {
|
||||
const promptFeedback = response.promptFeedback;
|
||||
if (promptFeedback?.blockReason) {
|
||||
return promptFeedback.blockReasonMessage
|
||||
? `Gemini response was blocked due to ${promptFeedback.blockReason}: ${promptFeedback.blockReasonMessage}`
|
||||
: `Gemini response was blocked due to ${promptFeedback.blockReason}`;
|
||||
}
|
||||
|
||||
const firstCandidate = response.candidates?.[0];
|
||||
if (firstCandidate?.finishReason) {
|
||||
return firstCandidate.finishMessage
|
||||
? `Gemini response was blocked due to ${firstCandidate.finishReason}: ${firstCandidate.finishMessage}`
|
||||
: `Gemini response was blocked due to ${firstCandidate.finishReason}`;
|
||||
}
|
||||
|
||||
return 'Gemini response did not contain usable text';
|
||||
};
|
||||
|
||||
const extractGeminiText = (response: GenerateContentResponse): string => {
|
||||
const firstCandidate = response.candidates?.[0];
|
||||
|
||||
if (
|
||||
firstCandidate?.finishReason &&
|
||||
GEMINI_BLOCKING_FINISH_REASONS.has(firstCandidate.finishReason)
|
||||
) {
|
||||
throw new Error(formatGeminiBlockMessage(response));
|
||||
}
|
||||
|
||||
const text = firstCandidate?.content?.parts
|
||||
?.flatMap((part) =>
|
||||
'text' in part && typeof part.text === 'string' ? [part.text] : []
|
||||
)
|
||||
.join('');
|
||||
|
||||
if (typeof text === 'string' && text.length > 0) {
|
||||
return text;
|
||||
}
|
||||
|
||||
if (response.promptFeedback?.blockReason) {
|
||||
throw new Error(formatGeminiBlockMessage(response));
|
||||
}
|
||||
|
||||
return '';
|
||||
};
|
||||
|
||||
export class GeminiEngine implements AiEngine {
|
||||
config: GeminiConfig;
|
||||
client: GoogleGenerativeAI;
|
||||
@@ -77,7 +132,7 @@ export class GeminiEngine implements AiEngine {
|
||||
}
|
||||
});
|
||||
|
||||
const content = result.response.text();
|
||||
const content = extractGeminiText(result.response);
|
||||
return removeContentTags(content, 'think');
|
||||
} catch (error) {
|
||||
throw normalizeEngineError(error, 'gemini', this.config.model);
|
||||
|
||||
@@ -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