From a60a59bc4b03316ea468db088d9e72d676fef597 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 23:03:57 +1000 Subject: [PATCH 1/4] Add support for local Ollama models (#39) - resolves #37 Resolves issue #37 - Added dual provider support with a new `diffCommit.provider` configuration option - Implemented Ollama integration with model selection and server configuration - Added new commands for Ollama setup and model switching: - `DiffCommit: Configure Ollama Model` - `DiffCommit: Change Ollama Model` - Added new configuration settings: - `diffCommit.ollamaHostname` for server connection - `diffCommit.ollamaModel` for model selection - Updated documentation with Ollama requirements and setup instructions - Enhanced error handling for Ollama-specific scenarios - Updated workflow documentation to include provider selection --- README.md | 146 ++++++-- package-lock.json | 18 +- package.json | 53 ++- src/commitMessageGenerator.ts | 256 ++++++++++--- src/configManager.ts | 9 + src/extension.ts | 38 +- src/ollamaManager.ts | 119 +++++++ test/anthropicResponseHandling.test.ts | 14 +- test/commitMessageGenerator.test.ts | 434 +++++++++++++++++++++++ test/configurationHandling.test.ts | 59 ++- test/errorHandling.test.ts | 163 ++++++++- test/extension.test.ts | 473 +++++++++++++++++++++++++ test/gitAndCommands.test.ts | 28 +- test/gitIntegration.test.ts | 18 +- test/messageHandling.test.ts | 14 + test/ollamaManager.test.ts | 261 ++++++++++++++ test/withProgressAPI.test.ts | 18 +- 17 files changed, 1988 insertions(+), 133 deletions(-) create mode 100644 src/ollamaManager.ts create mode 100644 test/commitMessageGenerator.test.ts create mode 100644 test/extension.test.ts create mode 100644 test/ollamaManager.test.ts diff --git a/README.md b/README.md index 8173cea..a6faf1b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Diff Commit -Diff Commit is a VSCode extension that helps you generate commit messages following the conventional commits specification using Anthropic's AI models like Claude 3.5 Sonnet. Commit messages are generated using the diff of staged changes and entered directly into the SCM message input or previewed in a new editor window. +Diff Commit is a VSCode extension that helps you generate commit messages following the conventional commits specification using AI models. Choose between Anthropic's cloud-based models like Claude 4 Sonnet or local Ollama models for offline usage. Commit messages are generated using the diff of staged changes and entered directly into the SCM message input or previewed in a new editor window. The generated commit messages are compatible with [googleapis/release-please](https://github.com/googleapis/release-please) and other tools that use conventional commits. @@ -19,10 +19,15 @@ The generated commit messages are compatible with [googleapis/release-please](ht - [`DiffCommit: Preview Commit Message`](#diffcommit-preview-commit-message) - [`DiffCommit: Update API Key`](#diffcommit-update-api-key) - [`DiffCommit: Delete API Key`](#diffcommit-delete-api-key) + - [`DiffCommit: Configure Ollama Model`](#diffcommit-configure-ollama-model) + - [`DiffCommit: Change Ollama Model`](#diffcommit-change-ollama-model) - [Configuration](#configuration) + - [`diffCommit.provider`](#diffcommitprovider) - [`diffCommit.allowedTypes`](#diffcommitallowedtypes) - [`diffCommit.customInstructions`](#diffcommitcustominstructions) - [`diffCommit.model`](#diffcommitmodel) + - [`diffCommit.ollamaHostname`](#diffcommitollamahostname) + - [`diffCommit.ollamaModel`](#diffcommitollamamodel) - [`diffCommit.maxTokens`](#diffcommitmaxtokens) - [`diffCommit.temperature`](#diffcommittemperature) - [Error Handling](#error-handling) @@ -33,7 +38,10 @@ The generated commit messages are compatible with [googleapis/release-please](ht ## Features -- Generate commit messages based on the diff of staged changes using Anthropic's AI models like Claude 4 Sonnet +- **Dual Provider Support**: Choose between Anthropic's cloud-based models or local Ollama models for offline usage +- Generate commit messages based on the diff of staged changes using AI models +- **Anthropic Integration**: Access to Claude 4 Sonnet and other powerful cloud models with an API key +- **Ollama Integration**: Use local models for complete offline functionality and privacy - Preview and edit generated commit messages before applying the commit - Uses markdown formatting for commit messages - Implements conventional commit format with type, scope, subject, and body @@ -42,10 +50,16 @@ The generated commit messages are compatible with [googleapis/release-please](ht ## Requirements -- VSCode 1.9.4 or higher +- VSCode 1.93.1 or higher - Git installed and configured in your workspace + +**For Anthropic Provider:** - An [Anthropic API key](https://console.anthropic.com/settings/keys) +**For Ollama Provider:** +- [Ollama](https://ollama.com/) installed and running locally +- At least one model pulled (eg `ollama pull llama3.2` or `ollama pull mistral`) + ## Installation Install the extension directly from the [Visual Studio Code Marketplace](https://marketplace.visualstudio.com/items?itemName=tpsTech.diff-commit) or: @@ -56,14 +70,17 @@ Install the extension directly from the [Visual Studio Code Marketplace](https:/ ## Typical Workflow -1. Stage the changes that you wish to commit as normal -2. Run the command: "DiffCommit: Generate Commit Message" (Cmd/Ctrl + KCmd/Ctrl + G) -3. Monitor progress in the notification area as the extension: +1. **Choose your provider** (first-time setup): + - For **Anthropic**: Set up your API key using "DiffCommit: Update stored API Key" + - For **Ollama**: Configure your local model using "DiffCommit: Configure Ollama Model" +2. Stage the changes that you wish to commit as normal +3. Run the command: "DiffCommit: Generate Commit Message" (Cmd/Ctrl + KCmd/Ctrl + G) +4. Monitor progress in the notification area as the extension: - Retrieves the git diff - - Validates your API key + - Validates your configuration (API key or Ollama connection) - Generates the commit message -4. Review the generated message in the Source Control message input -5. Click 'Commit' to commit the changes with the generated message +5. Review the generated message in the Source Control message input +6. Click 'Commit' to commit the changes with the generated message ## Commands @@ -111,10 +128,50 @@ This will delete the stored API key from your system and prevent DiffCommit from ![Demo video of Diff Commit: Delete API Key](screenshots/diff-commit-cmd-delete.gif) +### `DiffCommit: Configure Ollama Model` + +This command allows you to set up Ollama as your provider for the first time or switch from Anthropic to Ollama. It will: + +1. Prompt you to enter the Ollama server hostname (defaults to `http://localhost:11434`) +2. Test the connection to your Ollama server +3. Display available models for selection +4. Set the provider to Ollama and save your configuration + +> [!NOTE] +> Before using this command, ensure that: +> - Ollama is installed and running on your system +> - You have pulled at least one model (eg `ollama pull llama3.2`) + +### `DiffCommit: Change Ollama Model` + +This command allows you to change your currently selected Ollama model without changing the hostname. It will: + +1. Connect to your configured Ollama server +2. Display available models for selection +3. Update your model configuration + +> [!TIP] +> Use this command when you want to try a different local model or have pulled new models to your Ollama installation. + ## Configuration DiffCommit provides the following settings to customise its behavior. +### `diffCommit.provider` + +Choose the AI provider for generating commit messages. You can use either Anthropic's cloud-based models or local Ollama models. + +| **Type** | **Options** | **Default Value** | +| :------- | :---------- | :---------------- | +| enum | "anthropic" | "anthropic" | +| | "ollama" | | + +- **"anthropic"**: Use Anthropic's Claude models (requires API key and internet connection) +- **"ollama"**: Use local Ollama models (requires Ollama server to be running, works offline) + +> [!TIP] +> Use **Anthropic** for the most advanced language understanding and consistency, or **Ollama** for complete privacy and offline functionality. + ### `diffCommit.allowedTypes` A list of allowed commit types. If provided, this replaces the default options. You need to ensure all required commit types are included in the list. @@ -136,7 +193,7 @@ Add additional custom instructions to the commit generation prompt. Useful for p ### `diffCommit.model` -The Anthropic AI model to use for generating commit messages. +The Anthropic AI model to use for generating commit messages. This setting is only used when the provider is set to "anthropic". | **Type** | **Options** | **Default Value** | | :------- | :------------------------- | :------------------ | @@ -147,6 +204,31 @@ The Anthropic AI model to use for generating commit messages. | | "claude-opus-4-0" | | | | "claude-3-opus-latest" | | +### `diffCommit.ollamaHostname` + +The URL of the local Ollama server. This setting is only used when the provider is set to "ollama". + +| **Type** | **Description** | **Default Value** | +| :------- | :--------------------------------------------------- | :----------------------- | +| string | The hostname URL where your Ollama server is running | "http://localhost:11434" | + +> [!NOTE] +> Most users won't need to change this unless running Ollama on a different port or remote server. + +### `diffCommit.ollamaModel` + +The Ollama model to use for generating commit messages. This setting is only used when the provider is set to "ollama". + +| **Type** | **Description** | **Default Value** | +| :------- | :---------------------------------------------------------------------- | :---------------- | +| string | The name of the Ollama model (e.g., "llama3.2", "mistral", "codellama") | "" | + +> [!TIP] +> Use the "DiffCommit: Configure Ollama Model" command to easily set this up. Popular models for code tasks include: +> - **llama3.2**: General-purpose model with good reasoning +> - **mistral**: Fast and efficient for most tasks +> - **codellama**: Specialised for code-related tasks + ### `diffCommit.maxTokens` Maximum number of tokens to generate in the response. Higher values allow for longer commit messages but use more API tokens. @@ -172,24 +254,32 @@ Controls randomness in the response. Lower values (like 0.3) produce more focuse Diff Commit provides clear feedback for various scenarios to help troubleshoot issues. Here are the common messages you might encounter: -| | **Error** | **Description** | -| :----------------------- | :--------------------------------------------------------------- | :------------------------------------------------------------ | -| **Git Related** | Git extension not found in VSCode | VSCode's Git extension is not installed or enabled | -| | No Git repository found in the current workspace | The current workspace is not a Git repository | -| | No workspace folder found | No workspace is open in VSCode | -| | No changes detected | No staged changes found to generate a commit message for | -| **API Key Related** | API key is missing or not provided | No API key has been set | -| | Invalid API key format (should start with sk-ant-api) | The provided API key doesn't match the expected format | -| | Failed to access or update secure storage | Unable to store or retrieve the API key from secure storage | -| **Anthropic API Errors** | Bad Request: Review your prompt and try again | Invalid request to the Anthropic API (400) | -| | Unauthorized: Invalid API key, update your API key and try again | The API key is invalid or expired (401) | -| | Forbidden: Permission denied, update your API key and try again | The API key doesn't have permission for this operation (403) | -| | Rate Limited: Too many requests, try again later | You've exceeded your API rate limit (429) | -| | Server Error: Anthropic API server error | An error occurred on Anthropic's servers (500) | -| | Server Error: Anthropic Server Overloaded | The Anthropic API server is overloaded (529) | -| **Other Errors** | Failed to write commit message to Source Control | Unable to update the SCM input box with the generated message | -| | Failed to open commit message preview | Unable to open the preview editor | -| | No commit message was generated by the API | The API response didn't contain a valid commit message | +| | **Error** | **Description** | +| :------------------------ | :------------------------------------------------------------------------------- | :------------------------------------------------------------ | +| **Git Related** | Git extension not found in VSCode | VSCode's Git extension is not installed or enabled | +| | No Git repository found in the current workspace | The current workspace is not a Git repository | +| | No workspace folder found | No workspace is open in VSCode | +| | No changes detected | No staged changes found to generate a commit message for | +| **API Key Related** | API key is missing or not provided | No API key has been set for Anthropic provider | +| | Invalid API key format (should start with sk-ant-api) | The provided API key doesn't match the expected format | +| | Failed to access or update secure storage | Unable to store or retrieve the API key from secure storage | +| **Anthropic API Errors** | Bad Request: Review your prompt and try again | Invalid request to the Anthropic API (400) | +| | Unauthorized: Invalid API key, update your API key and try again | The API key is invalid or expired (401) | +| | Forbidden: Permission denied, update your API key and try again | The API key doesn't have permission for this operation (403) | +| | Rate Limited: Too many requests, try again later | You've exceeded your API rate limit (429) | +| | Server Error: Anthropic API server error | An error occurred on Anthropic's servers (500) | +| | Server Error: Anthropic Server Overloaded | The Anthropic API server is overloaded (529) | +| **Ollama Related Errors** | Unable to connect to Ollama server | Ollama server is not running or not accessible | +| | Ollama server not found | Invalid hostname or Ollama is not installed | +| | No Ollama model selected. Please configure an Ollama model first | No Ollama model has been configured for the extension | +| | Ollama model not specified | No Ollama model has been configured or selected | +| | Model '[model-name]' not found. Please check if the model is available in Ollama | The specified model is not available in Ollama | +| | No models found on the Ollama server | No models have been pulled to the Ollama installation | +| | Invalid hostname URL | The provided Ollama hostname is not a valid URL | +| | Ollama server error | An error occurred on the Ollama server (500) | +| **Other Errors** | Failed to write commit message to Source Control | Unable to update the SCM input box with the generated message | +| | Failed to open commit message preview | Unable to open the preview editor | +| | No commit message was generated by the API | The API response didn't contain a valid commit message | Each error includes specific guidance on how to resolve the issue. For persistent problems, please check your configuration and if appropriate, raise an issue on [GitHub](https://github.com/tsdevau/diff-commit/issues). diff --git a/package-lock.json b/package-lock.json index d058f02..f58428b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "0.3.9", "license": "MIT", "dependencies": { - "@anthropic-ai/sdk": "^0.39.0" + "@anthropic-ai/sdk": "^0.39.0", + "ollama": "^0.5.16" }, "devDependencies": { "@types/jest": "^29.5.14", @@ -6045,6 +6046,15 @@ "node": ">= 6" } }, + "node_modules/ollama": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/ollama/-/ollama-0.5.16.tgz", + "integrity": "sha512-OEbxxOIUZtdZgOaTPAULo051F5y+Z1vosxEYOoABPnQKeW7i4O8tJNlxCB+xioyoorVqgjkdj+TA1f1Hy2ug/w==", + "license": "MIT", + "dependencies": { + "whatwg-fetch": "^3.6.20" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -7812,6 +7822,12 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-fetch": { + "version": "3.6.20", + "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-3.6.20.tgz", + "integrity": "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==", + "license": "MIT" + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", diff --git a/package.json b/package.json index 2b529b7..d15ce23 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "diff-commit", "displayName": "Diff Commit", "version": "0.3.9", - "description": "Generate detailed Git commit messages following the conventional commit specification using Anthropic's AI models like Claude 4 Sonnet.", + "description": "Generate detailed Git commit messages following the conventional commit specification using AI models. Choose between Anthropic's cloud-based models like Claude 4 Sonnet or local Ollama models for offline usage.", "categories": [ "AI", "SCM Providers" @@ -15,6 +15,9 @@ "Anthropic", "Claude Sonnet", "Sonnet", + "Ollama", + "Local AI", + "Offline AI", "Diff Commit", "tpsTech", "tsdevau" @@ -55,11 +58,34 @@ "command": "diffCommit.deleteAPIKey", "title": "DiffCommit: Delete stored API Key", "icon": "$(trash)" + }, + { + "command": "diffCommit.configureOllamaModel", + "title": "DiffCommit: Configure Ollama Model", + "icon": "$(server-environment)" + }, + { + "command": "diffCommit.changeOllamaModel", + "title": "DiffCommit: Change Ollama Model", + "icon": "$(settings-gear)" } ], "configuration": { "title": "Diff Commit", "properties": { + "diffCommit.provider": { + "type": "string", + "default": "anthropic", + "enum": [ + "anthropic", + "ollama" + ], + "enumDescriptions": [ + "Use Anthropic's Claude models (requires API key)", + "Use local Ollama models (requires Ollama server to be running)" + ], + "description": "Choose the AI provider for generating commit messages." + }, "diffCommit.model": { "type": "string", "default": "claude-sonnet-4-0", @@ -71,7 +97,17 @@ "claude-opus-4-0", "claude-3-opus-latest" ], - "description": "The Anthropic AI model to use for generating commit messages." + "description": "The Anthropic AI model to use for generating commit messages. This value is only used when provider is set as 'anthropic'." + }, + "diffCommit.ollamaHostname": { + "type": "string", + "default": "http://localhost:11434", + "description": "The URL of the local Ollama server (eg http://localhost:11434). This value is only used when provider is set as 'ollama'." + }, + "diffCommit.ollamaModel": { + "type": "string", + "default": "", + "description": "The Ollama model to use for generating commit messages. Use the 'Configure Ollama Model' command to configure this. This value is only used when provider is set as 'ollama'." }, "diffCommit.maxTokens": { "type": "number", @@ -98,15 +134,15 @@ "type": "string" }, "default": [ + "chore", + "ci", + "docs", "feat", "fix", + "perf", "refactor", - "chore", - "docs", "style", - "test", - "perf", - "ci" + "test" ], "description": "List of allowed commit types. If provided, this replaces the default options. So you need to ensure all required commit types are included in the list." } @@ -141,7 +177,8 @@ } }, "dependencies": { - "@anthropic-ai/sdk": "^0.39.0" + "@anthropic-ai/sdk": "^0.39.0", + "ollama": "^0.5.16" }, "devDependencies": { "@types/jest": "^29.5.14", diff --git a/src/commitMessageGenerator.ts b/src/commitMessageGenerator.ts index ac82e6e..2fd804d 100644 --- a/src/commitMessageGenerator.ts +++ b/src/commitMessageGenerator.ts @@ -1,53 +1,64 @@ import Anthropic from "@anthropic-ai/sdk" +import { + Ollama, + type GenerateRequest as OllamaGenerateRequest, + type GenerateResponse as OllamaGenerateResponse, +} from "ollama" import { window } from "vscode" import type { CommitConfig } from "./configManager" export class CommitMessageGenerator { - constructor(private apiKey: string) {} + private apiKey: string | undefined + private ollamaModel: string | undefined + private ollamaHost: string | undefined + + // Constructor overload for Anthropic (API key only) + constructor(apiKey: string) + // Constructor overload for Ollama (hostname and model) + constructor(hostname: string, ollamaModel: string) + // Implementation + constructor(...args: [string] | [string, string]) { + try { + if (args.length === 1 && args[0] && typeof args[0] === "string" && args[0].startsWith("sk-")) { + // Anthropic constructor + this.apiKey = args[0] + } else if ( + args.length === 2 && + args[0] && + typeof args[0] === "string" && + args[0].startsWith("http") && + args[1] && + typeof args[1] === "string" + ) { + // Ollama constructor + this.ollamaHost = args[0] + this.ollamaModel = args[1] + } else { + throw new Error( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + } + } catch (error) { + console.error("Error initializing CommitMessageGenerator:", error) + window.showErrorMessage("Failed to initialize CommitMessageGenerator. Please check your configuration.") + throw error + } + } async generateMessage(diff: string, config: CommitConfig): Promise { + if (config.provider === "ollama") { + return this.generateOllamaMessage(diff, config) + } else { + return this.generateAnthropicMessage(diff, config) + } + } + + private async generateAnthropicMessage(diff: string, config: CommitConfig): Promise { const anthropic = new Anthropic({ apiKey: this.apiKey, }) - const systemPrompt = - "You are a seasoned software engineer with more than 25 years of experience with an extraordinary ability for assessing and interpreting git diffs and writing detailed conventional commit messages and following 'instructions' and 'customInstructions' when generating them." - - const prompt = ` - -Generate a detailed conventional commit message for the following Git diff: - -${diff} - - -- Use ONLY ${config.allowedTypes.map((val) => `'${val}'`).join(" | ")} as appropriate for the type of change. -- When assessing the commit type, consider actual impact of the commit. Refer to the "type-table" below for further guidance on the default commit types. -- Always include a scope. -- Never use '!' or 'BREAKING CHANGE' in the commit message. -- Avoid unnecessary and excessive adjectives use. (eg 'enhance', 'comprehensive', etc.) -- Output will use markdown formatting for lists etc. -- Output will ONLY contain the commit message. -- Do not explain the output. -- "customInstructions" override these instructions if they are provided and conflict. - - -\`\`\`markdown -| Commit Type | Typical Use Case | When to Use | -| ----------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| chore | Routine maintenance or updates | Use when updating configs or non-code changes. (eg updating dependencies, modifying configs, updating types, etc.) | -| ci | Continuous integration adjustments | Use when updating CI/CD config files. (eg GitHub Actions, Workflows, Pipelines, etc.) | -| docs | Documentation-only changes | Use only when updating or adding documentation, comments, or README files. (Do NOT use when adding or updating page content in web apps. eg Astro content collections.) | -| feat | New feature | Use only when adding new, user-facing feature or functionality or a fundamental change in an existing feature's functionality. | -| fix | Bug fix | Use when fixing a bug or issue in code that may or may not affect functionality. | -| perf | Performance improvement | Use when improving performance. (eg by optimising code.) | -| refactor | Code restructuring | Use when restructuring code without changing functionality or fixing bugs. (This can include significant code changes like abstracting code to its own component.) | -| style | Code formatting or styling | Use when code changes do not affect functionality. (eg linting, formatting adjustments, colour, margin, padding, etc.) | -| test | Adding or updating tests | Use when adding, updating, or removing tests. | -\`\`\` - - -${config.customInstructions ? `\n${config.customInstructions}\n` : ""} -`.trim() + const { systemPrompt, prompt } = this.buildPrompts(diff, config) let message: Anthropic.Message | undefined = undefined try { @@ -64,31 +75,152 @@ ${config.customInstructions ? `\n${config.customInstructions ], }) - let commitMessage = message.content - .filter((msg) => msg.type === "text" && "text" in msg) - .map((msg) => msg.text) - .join("\n") - .replace(/\n{3,}/g, "\n\n") // Replace 3 or more newlines with 2 newlines - .replace(/(? { + if (!this.ollamaModel) { + window.showErrorMessage("Ollama model not specified") + return undefined + } + + let response: OllamaGenerateResponse | undefined = undefined + const { systemPrompt, prompt } = this.buildPrompts(diff, config, true) + const ollama = new Ollama({ + host: this.ollamaHost, + }) + const requestBody: OllamaGenerateRequest & { stream: false } = { + model: this.ollamaModel, + system: systemPrompt, + prompt: prompt, + think: false, + stream: false, + options: { + temperature: config.temperature, + num_predict: config.maxTokens, + }, + } + + console.log(`[DiffCommit] Making request to Ollama at ${this.ollamaHost}/api/generate`) + try { + response = await ollama.generate(requestBody) + + const commitMessage = this.normaliseResponseText(response.response) + + if (!commitMessage) { + window.showWarningMessage("No commit message was generated") + return undefined + } else if (commitMessage && !response.done) { + window.showWarningMessage( + "Ollama response was marked as incomplete. Review the commit message and ensure it meets your requirements.", + ) + return commitMessage + } + + return commitMessage + } catch (error) { + this.handleOllamaError(error) + return undefined + } finally { + console.log("[DiffCommit] Ollama Stop Reason: ", response?.done_reason) + console.log("[DiffCommit] Ollama Input Tokens: ", response?.prompt_eval_count) + console.log("[DiffCommit] Ollama Output Tokens: ", response?.eval_count) } } - private handleError(error: unknown): void { + private buildPrompts( + diff: string, + config: CommitConfig, + isIdiotOllamaModel: boolean = false, + ): { systemPrompt: string; prompt: string } { + const systemPrompt = + "You are a seasoned software engineer with more than 25 years of experience with an extraordinary ability for assessing and interpreting git diffs and writing detailed conventional commit messages and following 'instructions' and 'customInstructions' when generating them." + + const idiotOllamaAdditionalInstructions = `- Follow the following 'commit-format' strictly, where anything surrounded by << and >> is a placeholder that should be replaced with the actual content. + +` + const idiotOllamaCommitFormat = ` +<>(<>): <> + +<> + + +` + const prompt = ` + +Generate a detailed conventional commit message summarising the following Git diff: + +${diff} + + +- Use ONLY ${config.allowedTypes.map((val) => `'${val}'`).join(" | ")} as appropriate for the type of change. +- When assessing the commit type, consider actual impact of the commit. Refer to the "type-table" below for further guidance on the default commit types. +- ALWAYS infer and include a scope. +- Never use '!' or 'BREAKING CHANGE' in the commit message. +- Avoid unnecessary and excessive adjectives use. (eg 'enhance', 'comprehensive', etc.) +- 'customInstructions' override any conflicting instructions if they are provided. +- Your response MUST include ONLY the commit message - NOTHING ELSE! +- Use plain text output with markdown-like list formatting for lists etc. +${isIdiotOllamaModel ? idiotOllamaAdditionalInstructions : ""} +${isIdiotOllamaModel ? idiotOllamaCommitFormat : ""} + + +\`\`\`markdown +| Commit Type | When to Use | +| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| chore | Use when updating configs or non-code changes. (eg updating dependencies, modifying configs, updating types, etc.) | +| ci | Use when updating CI/CD config files. (eg GitHub Actions, Workflows, Pipelines, etc.) | +| docs | Use only when updating or adding documentation, comments, or README files. (Do NOT use when adding or updating page content in web apps. eg Astro content collections.) | +| feat | Use only when adding new, user-facing feature or functionality or a fundamental change in an existing feature's functionality. | +| fix | Use when fixing a bug or issue in code that may or may not affect functionality. | +| perf | Use when improving performance. (eg by optimising code.) | +| refactor | Use when restructuring code without changing functionality or fixing bugs. (This can include significant code changes like abstracting code to its own component.) | +| style | Use when code changes do not affect functionality. (eg linting, formatting adjustments, colour, margin, padding, etc.) | +| test | Use when adding, updating, or removing tests. | +\`\`\` + + +${config.customInstructions ? `\n${config.customInstructions}\n` : ""} +`.trim() + + return { systemPrompt, prompt } + } + + private processAnthropicResponse(message: Anthropic.Message): string { + return this.normaliseResponseText( + message.content + .filter((msg) => msg.type === "text" && "text" in msg) + .map((msg) => msg.text) + .join("\n"), + ) + } + + private normaliseResponseText(text: string): string { + return text + .replace(/\`\`\`.*?\n?/g, "") // Remove code block markers if present + .replace(/\n{3,}/g, "\n\n") // Replace 3 or more newlines with 2 newlines + .replace(/(?\n${config.customInstructions ) } } + + private handleOllamaError(error: unknown): void { + console.error(`Ollama API Error:\n\n${error}`) + + if (error instanceof TypeError && error.message.includes("fetch")) { + window.showErrorMessage( + `Unable to connect to Ollama server at ${this.ollamaHost}. Please ensure that the Ollama server is running and accessible.`, + ) + } else if (error instanceof Error) { + if (error.message.includes("404")) { + window.showErrorMessage( + `Model '${this.ollamaModel}' not found. Please check if the model is available in Ollama.`, + ) + } else if (error.message.includes("500")) { + window.showErrorMessage("Ollama server error. Please try again later.") + } else { + window.showErrorMessage(`Failed to generate commit message with Ollama:\n\n${error.message}`) + } + } else { + window.showErrorMessage( + `Unknown error generating commit message with Ollama: ${error instanceof Error ? error.message : String(error)}`, + ) + } + } } diff --git a/src/configManager.ts b/src/configManager.ts index 7b84b71..df598e3 100644 --- a/src/configManager.ts +++ b/src/configManager.ts @@ -6,6 +6,9 @@ export interface CommitConfig { maxTokens: number model: string temperature: number + provider: "anthropic" | "ollama" + ollamaHostname: string + ollamaModel: string } export class ConfigManager { @@ -23,6 +26,9 @@ export class ConfigManager { private static readonly defaultMaxTokens = 1024 private static readonly defaultModel = "claude-sonnet-4-0" private static readonly defaultTemperature = 0.3 + private static readonly defaultProvider = "anthropic" + private static readonly defaultOllamaHostname = "http://localhost:11434" + private static readonly defaultOllamaModel = "" getConfig(): CommitConfig { const config = workspace.getConfiguration("diffCommit") @@ -33,6 +39,9 @@ export class ConfigManager { maxTokens: config.get("maxTokens") || ConfigManager.defaultMaxTokens, model: config.get("model") || ConfigManager.defaultModel, temperature: config.get("temperature") || ConfigManager.defaultTemperature, + provider: config.get<"anthropic" | "ollama">("provider") || ConfigManager.defaultProvider, + ollamaHostname: config.get("ollamaHostname") || ConfigManager.defaultOllamaHostname, + ollamaModel: config.get("ollamaModel") || ConfigManager.defaultOllamaModel, } } } diff --git a/src/extension.ts b/src/extension.ts index 02c074e..c217dbd 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,6 +3,7 @@ import { APIKeyManager } from "./apiKeyManager" import { CommitMessageGenerator } from "./commitMessageGenerator" import { ConfigManager } from "./configManager" import { GitManager } from "./gitManager" +import { OllamaManager } from "./ollamaManager" export function activate(context: ExtensionContext) { let previewDocument: TextDocument | undefined @@ -10,6 +11,7 @@ export function activate(context: ExtensionContext) { const apiKeyManager = new APIKeyManager(context) const gitManager = new GitManager() const configManager = new ConfigManager() + const ollamaManager = new OllamaManager() async function generateCommitMessage(): Promise { const workspaceRoot = workspace.workspaceFolders?.[0]?.uri?.fsPath @@ -32,16 +34,28 @@ export function activate(context: ExtensionContext) { return undefined } - progress.report({ message: "Validating API key..." }) - const apiKey = (await apiKeyManager.getAPIKey()) ?? (await apiKeyManager.setAPIKey()) - if (!apiKey) { - window.showErrorMessage("API Key is required") - return undefined - } + const config = configManager.getConfig() + let generator: CommitMessageGenerator + progress.report({ message: "Validating configuration..." }) + if (config.provider === "ollama") { + // Validate Ollama configuration + if (!config.ollamaModel) { + window.showErrorMessage("No Ollama model selected. Please configure an Ollama model first.") + return undefined + } + + generator = new CommitMessageGenerator(config.ollamaHostname, config.ollamaModel) + } else { + // Anthropic provider + const apiKey = (await apiKeyManager.getAPIKey()) ?? (await apiKeyManager.setAPIKey()) + if (!apiKey) { + window.showErrorMessage("API Key is required") + return undefined + } + generator = new CommitMessageGenerator(apiKey) + } progress.report({ message: "Generating commit message..." }) - const config = configManager.getConfig() - const generator = new CommitMessageGenerator(apiKey) return await generator.generateMessage(diff, config) }, ) @@ -51,6 +65,12 @@ export function activate(context: ExtensionContext) { const cmdUpdateAPIKey = commands.registerCommand("diffCommit.updateAPIKey", () => apiKeyManager.setAPIKey()) const cmdGetAPIKey = commands.registerCommand("diffCommit.getAPIKey", () => apiKeyManager.getAPIKey()) const cmdDeleteAPIKey = commands.registerCommand("diffCommit.deleteAPIKey", () => apiKeyManager.deleteAPIKey()) + const cmdConfigureOllamaModel = commands.registerCommand("diffCommit.configureOllamaModel", () => + ollamaManager.configureOllamaModel(), + ) + const cmdChangeOllamaModel = commands.registerCommand("diffCommit.changeOllamaModel", () => + ollamaManager.changeOllamaModel(), + ) const cmdGenerateCommitMessage = commands.registerCommand("diffCommit.generateCommitMessage", async () => { try { @@ -104,6 +124,8 @@ export function activate(context: ExtensionContext) { cmdUpdateAPIKey, cmdGetAPIKey, cmdDeleteAPIKey, + cmdConfigureOllamaModel, + cmdChangeOllamaModel, onSave, onClose, ) diff --git a/src/ollamaManager.ts b/src/ollamaManager.ts new file mode 100644 index 0000000..6b8fc7b --- /dev/null +++ b/src/ollamaManager.ts @@ -0,0 +1,119 @@ +import { Ollama } from "ollama" +import { window, workspace } from "vscode" + +export class OllamaManager { + constructor() {} + + private async ollamaModelConfig(includeHostnameSelection: boolean = true): Promise { + const config = workspace.getConfiguration("diffCommit") + let hostname = config.get("ollamaHostname") || "http://localhost:11434" + + // Only prompt for hostname if this is initial setup or explicitly requested + if (includeHostnameSelection) { + const inputHostname = await window.showInputBox({ + prompt: "Enter Ollama hostname", + value: hostname, + placeHolder: "http://localhost:11434", + }) + + if (!inputHostname) { + return false + } + + try { + const newHost = new URL(inputHostname) + hostname = newHost.toString().replace(/\/+$/, "") + } catch (error) { + window.showErrorMessage("Invalid hostname URL. Please enter a valid URL (eg http://localhost:11434).") + return this.ollamaModelConfig(includeHostnameSelection) + } + } + + try { + // Test connection and get available models + const models = await this.getAvailableModels(hostname) + + if (models.length === 0) { + window.showWarningMessage("No models found on the Ollama server. Please pull a model first.") + return false + } + + // Let user select a model + const currentModel = config.get("ollamaModel") + const placeHolder = includeHostnameSelection + ? "Select an Ollama model" + : `Select an Ollama model (current: ${currentModel || "none"})` + + const title = includeHostnameSelection ? "Choose Ollama Model" : "Change Ollama Model" + + const selectedModel = await window.showQuickPick(models, { + placeHolder, + title, + }) + + if (!selectedModel) { + return false + } + + // Save configuration + if (includeHostnameSelection) { + await config.update("provider", "ollama", true) + await config.update("ollamaHostname", hostname, true) + } + await config.update("ollamaModel", selectedModel, true) + + const action = includeHostnameSelection ? "selected" : "changed to" + + window.setStatusBarMessage(`✓ Ollama model '${selectedModel}' ${action} successfully`, 4000) + + return true + } catch (error) { + const operation = includeHostnameSelection ? "selecting" : "changing" + console.error(`Error ${operation} Ollama model:`, error) + + if (error instanceof Error) { + if (error.message.includes("ECONNREFUSED") || error.message.includes("fetch")) { + window.showErrorMessage( + `Unable to connect to Ollama server at ${hostname}. Please ensure that the Ollama server is running and accessible.`, + ) + } else if (error.message.includes("404")) { + window.showErrorMessage(`Ollama server not found at ${hostname}. Please check the hostname and try again.`) + } else { + window.showErrorMessage(`Failed to connect to Ollama: ${error.message}`) + } + } else { + window.showErrorMessage( + `Failed to connect to Ollama: ${error instanceof Error ? error.message : String(error)}`, + ) + } + return false + } + } + + async getAvailableModels(hostname: string): Promise { + try { + const ollama = new Ollama({ + host: hostname, + }) + + const response = await ollama.list() + return response.models.map((model) => model.name) + } catch (error) { + console.error("Failed to fetch Ollama models:", error) + window.showErrorMessage( + `Failed to fetch Ollama models: ${error instanceof Error ? error.message : String(error)}`, + ) + throw error + } + } + + // Convenience method for initial setup + async configureOllamaModel(): Promise { + return this.ollamaModelConfig(true) + } + + // Convenience method for changing existing model + async changeOllamaModel(): Promise { + return this.ollamaModelConfig(false) + } +} diff --git a/test/anthropicResponseHandling.test.ts b/test/anthropicResponseHandling.test.ts index 07e08fc..10f4574 100644 --- a/test/anthropicResponseHandling.test.ts +++ b/test/anthropicResponseHandling.test.ts @@ -68,13 +68,19 @@ jest.mock("vscode", () => { get: jest.fn((key: string) => { switch (key) { case "model": - return "claude-3-5-sonnet-latest" + return "claude-sonnet-4-0" case "maxTokens": return 1024 case "temperature": - return 0.4 + return 0.2 case "allowedTypes": return ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"] + case "provider": + return "anthropic" + case "ollamaHostname": + return "http://localhost:11434" + case "ollamaModel": + return "" default: return undefined } @@ -272,7 +278,9 @@ describe("Anthropic API Response Handling", () => { const generateCommitMessage = getCommand("diffCommit.generateCommitMessage") await generateCommitMessage() - expect(consoleSpy).toHaveBeenCalledWith("[DiffCommit] Usage: ", { input_tokens: 100, output_tokens: 50 }) + expect(consoleSpy).toHaveBeenCalledWith("[DiffCommit] Stop Reason: ", "end_turn") + expect(consoleSpy).toHaveBeenCalledWith("[DiffCommit] Input Tokens: ", 100) + expect(consoleSpy).toHaveBeenCalledWith("[DiffCommit] Output Tokens: ", 50) }) }) diff --git a/test/commitMessageGenerator.test.ts b/test/commitMessageGenerator.test.ts new file mode 100644 index 0000000..c16dce5 --- /dev/null +++ b/test/commitMessageGenerator.test.ts @@ -0,0 +1,434 @@ +import * as vscode from "vscode" + +jest.mock("vscode") +jest.mock("@anthropic-ai/sdk") +jest.mock("ollama") + +import { CommitMessageGenerator } from "../src/commitMessageGenerator" +import type { CommitConfig } from "../src/configManager" + +describe("CommitMessageGenerator Constructor", () => { + let mockShowErrorMessage: jest.Mock + let mockConsoleError: jest.SpyInstance + + beforeEach(() => { + jest.clearAllMocks() + + // Mock vscode.window.showErrorMessage + mockShowErrorMessage = jest.fn() + ;(vscode.window as any).showErrorMessage = mockShowErrorMessage + + // Mock console.error + mockConsoleError = jest.spyOn(console, "error").mockImplementation(() => {}) + }) + + afterEach(() => { + mockConsoleError.mockRestore() + }) + + describe("Anthropic Constructor", () => { + it("should initialise successfully with valid API key", () => { + expect(() => { + new CommitMessageGenerator("sk-ant-api-valid-key") + }).not.toThrow() + + expect(mockShowErrorMessage).not.toHaveBeenCalled() + expect(mockConsoleError).not.toHaveBeenCalled() + }) + + it("should throw error with API key that doesn't start with 'sk-'", () => { + expect(() => { + new CommitMessageGenerator("invalid-api-key") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with empty API key", () => { + expect(() => { + new CommitMessageGenerator("") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with null argument", () => { + expect(() => { + new CommitMessageGenerator(null as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with undefined argument", () => { + expect(() => { + new CommitMessageGenerator(undefined as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + }) + + describe("Ollama Constructor", () => { + it("should initialise successfully with valid hostname and model", () => { + expect(() => { + new CommitMessageGenerator("http://localhost:11434", "llama3.2") + }).not.toThrow() + + expect(mockShowErrorMessage).not.toHaveBeenCalled() + expect(mockConsoleError).not.toHaveBeenCalled() + }) + + it("should initialise successfully with https hostname", () => { + expect(() => { + new CommitMessageGenerator("https://ollama.example.com", "codellama") + }).not.toThrow() + + expect(mockShowErrorMessage).not.toHaveBeenCalled() + expect(mockConsoleError).not.toHaveBeenCalled() + }) + + it("should throw error with hostname that doesn't start with 'http'", () => { + expect(() => { + new CommitMessageGenerator("localhost:11434", "llama3.2") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with empty hostname", () => { + expect(() => { + new CommitMessageGenerator("", "llama3.2") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with empty model", () => { + expect(() => { + new CommitMessageGenerator("http://localhost:11434", "") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with null hostname", () => { + expect(() => { + new CommitMessageGenerator(null as any, "llama3.2") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with null model", () => { + expect(() => { + new CommitMessageGenerator("http://localhost:11434", null as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + }) + + describe("Invalid Constructor Arguments", () => { + it("should throw error with no arguments", () => { + expect(() => { + new (CommitMessageGenerator as any)() + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with too many arguments", () => { + expect(() => { + new (CommitMessageGenerator as any)("arg1", "arg2", "arg3") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with mixed invalid arguments", () => { + expect(() => { + new CommitMessageGenerator("sk-api-key", "extra-argument" as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error with non-string arguments", () => { + expect(() => { + new CommitMessageGenerator(123 as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + + it("should throw error when first argument is object", () => { + expect(() => { + new CommitMessageGenerator({} as any) + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith("Error initializing CommitMessageGenerator:", expect.any(Error)) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + }) + + describe("Error Handling", () => { + it("should log error and show error message when exception occurs", () => { + expect(() => { + new CommitMessageGenerator("invalid-key") + }).toThrow( + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + ) + + expect(mockConsoleError).toHaveBeenCalledWith( + "Error initializing CommitMessageGenerator:", + expect.objectContaining({ + message: + "Invalid constructor arguments. Configure either an API key for Anthropic or a hostname and model for Ollama.", + }), + ) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Failed to initialize CommitMessageGenerator. Please check your configuration.", + ) + }) + }) +}) + +describe("CommitMessageGenerator Message Generation", () => { + let mockShowErrorMessage: jest.Mock + let mockShowWarningMessage: jest.Mock + let generator: CommitMessageGenerator + + beforeEach(() => { + jest.clearAllMocks() + + // Mock vscode.window methods + mockShowErrorMessage = jest.fn() + mockShowWarningMessage = jest.fn() + ;(vscode.window as any).showErrorMessage = mockShowErrorMessage + ;(vscode.window as any).showWarningMessage = mockShowWarningMessage + }) + + describe("Ollama Message Generation", () => { + let mockOllamaGenerate: jest.Mock + + beforeEach(() => { + // Mock the Ollama class and its generate method + const { Ollama } = jest.requireMock("ollama") + mockOllamaGenerate = jest.fn() + Ollama.mockImplementation(() => ({ + generate: mockOllamaGenerate, + })) + }) + + it("should show error message and return undefined when Ollama model is not specified", async () => { + // Create generator with valid hostname but then set ollamaModel to undefined + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + // Manually set ollamaModel to undefined to simulate the error condition + ;(generator as any).ollamaModel = undefined + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", // This is ignored for Ollama + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "", // This should trigger the error + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBeUndefined() + expect(mockShowErrorMessage).toHaveBeenCalledWith("Ollama model not specified") + expect(mockShowWarningMessage).not.toHaveBeenCalled() + }) + + it("should show warning message and return undefined when Ollama returns empty response", async () => { + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + // Mock Ollama to return empty response + mockOllamaGenerate.mockResolvedValue({ + response: "", + done: true, + done_reason: "stop", + }) + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "test-model", + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBeUndefined() + expect(mockShowWarningMessage).toHaveBeenCalledWith("No commit message was generated") + expect(mockShowErrorMessage).not.toHaveBeenCalled() + }) + + it("should show warning message and return commit message when Ollama response is incomplete", async () => { + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + const incompleteMessage = "feat(api): add user authentication" + + // Mock Ollama to return incomplete response + mockOllamaGenerate.mockResolvedValue({ + response: incompleteMessage, + done: false, // This indicates incomplete response + done_reason: "length", + }) + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "test-model", + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBe(incompleteMessage) + expect(mockShowWarningMessage).toHaveBeenCalledWith( + "Ollama response was marked as incomplete. Review the commit message and ensure it meets your requirements.", + ) + expect(mockShowErrorMessage).not.toHaveBeenCalled() + }) + + it("should return commit message successfully when Ollama response is complete", async () => { + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + const completeMessage = + "feat(auth): implement user authentication system\n\n- Add login and registration endpoints\n- Implement JWT token handling" + + // Mock Ollama to return complete response + mockOllamaGenerate.mockResolvedValue({ + response: completeMessage, + done: true, + done_reason: "stop", + prompt_eval_count: 100, + eval_count: 50, + }) + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "test-model", + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBe(completeMessage) + expect(mockShowWarningMessage).not.toHaveBeenCalled() + expect(mockShowErrorMessage).not.toHaveBeenCalled() + }) + + it("should handle whitespace-only response as empty", async () => { + generator = new CommitMessageGenerator("http://localhost:11434", "test-model") + + // Mock Ollama to return whitespace-only response + mockOllamaGenerate.mockResolvedValue({ + response: " \n\n \t ", + done: true, + done_reason: "stop", + }) + + const config: CommitConfig = { + provider: "ollama", + allowedTypes: ["feat", "fix", "chore"], + maxTokens: 1024, + model: "claude-sonnet-4-0", + temperature: 0.3, + ollamaHostname: "http://localhost:11434", + ollamaModel: "test-model", + } + + const result = await generator.generateMessage("test diff", config) + + expect(result).toBeUndefined() + expect(mockShowWarningMessage).toHaveBeenCalledWith("No commit message was generated") + expect(mockShowErrorMessage).not.toHaveBeenCalled() + }) + }) +}) diff --git a/test/configurationHandling.test.ts b/test/configurationHandling.test.ts index 1508890..c6cdd46 100644 --- a/test/configurationHandling.test.ts +++ b/test/configurationHandling.test.ts @@ -67,7 +67,7 @@ describe("Configuration Handling", () => { ;(vscode.workspace.workspaceFolders as any) = [{ uri: { fsPath: "/test/workspace" } }] }) - test("should use configured values when available", async () => { + it("should use configured values when available", async () => { configGetMock.mockImplementation((key: string) => { const config: { [key: string]: any } = { model: "claude-3", @@ -75,6 +75,9 @@ describe("Configuration Handling", () => { temperature: 0.5, allowedTypes: ["feat", "fix"], customInstructions: "custom instructions", + provider: "anthropic", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", } return config[key] }) @@ -87,9 +90,12 @@ describe("Configuration Handling", () => { expect(configGetMock).toHaveBeenCalledWith("temperature") expect(configGetMock).toHaveBeenCalledWith("allowedTypes") expect(configGetMock).toHaveBeenCalledWith("customInstructions") + expect(configGetMock).toHaveBeenCalledWith("provider") + expect(configGetMock).toHaveBeenCalledWith("ollamaHostname") + expect(configGetMock).toHaveBeenCalledWith("ollamaModel") }) - test("should use default values when configuration is missing", async () => { + it("should use default values when configuration is missing", async () => { configGetMock.mockReturnValue(undefined) activate(mockContext) @@ -98,7 +104,7 @@ describe("Configuration Handling", () => { expect(configGetMock).toHaveBeenCalled() }) - test("should handle custom instructions when provided", async () => { + it("should handle custom instructions when provided", async () => { configGetMock.mockImplementation((key: string) => { if (key === "customInstructions") { return "custom instructions" @@ -112,7 +118,46 @@ describe("Configuration Handling", () => { expect(configGetMock).toHaveBeenCalledWith("customInstructions") }) - test("should handle missing workspace folder", async () => { + it("should handle Ollama provider configuration", async () => { + configGetMock.mockImplementation((key: string) => { + const config: { [key: string]: any } = { + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "llama2", + model: "claude-sonnet-4-0", // Not used for Ollama but still configured + maxTokens: 1000, + temperature: 0.3, + } + return config[key] + }) + + activate(mockContext) + await vscode.commands.executeCommand("diffCommit.generateCommitMessage") + + expect(configGetMock).toHaveBeenCalledWith("provider") + expect(configGetMock).toHaveBeenCalledWith("ollamaHostname") + expect(configGetMock).toHaveBeenCalledWith("ollamaModel") + }) + + it("should show error when Ollama model is not configured", async () => { + configGetMock.mockImplementation((key: string) => { + const config: { [key: string]: any } = { + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", // Empty model + } + return config[key] + }) + + activate(mockContext) + await vscode.commands.executeCommand("diffCommit.generateCommitMessage") + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "No Ollama model selected. Please configure an Ollama model first.", + ) + }) + + it("should handle missing workspace folder", async () => { ;(vscode.workspace.workspaceFolders as any) = undefined activate(mockContext) @@ -121,7 +166,7 @@ describe("Configuration Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No workspace folder found") }) - test("should handle missing Git extension", async () => { + it("should handle missing Git extension", async () => { ;(vscode.extensions.getExtension as jest.Mock).mockReturnValue(undefined) activate(mockContext) @@ -130,7 +175,7 @@ describe("Configuration Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Git extension not found") }) - test("should handle empty Git repositories", async () => { + it("should handle empty Git repositories", async () => { const mockGitExtension = { exports: { getAPI: (version: number) => ({ @@ -152,7 +197,7 @@ describe("Configuration Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No Git repository found") }) - test("should handle no Git changes", async () => { + it("should handle no Git changes", async () => { const mockGitRepo = { state: { HEAD: { diff --git a/test/errorHandling.test.ts b/test/errorHandling.test.ts index f260639..998170c 100644 --- a/test/errorHandling.test.ts +++ b/test/errorHandling.test.ts @@ -1,6 +1,9 @@ // Mock Anthropic API at the top level const mockAnthropicCreate = jest.fn() +// Mock Ollama API at the top level +const mockOllamaGenerate = jest.fn() + // Create mock APIError class class MockAPIError extends Error { constructor( @@ -24,11 +27,26 @@ function MockAnthropic() { } MockAnthropic.APIError = MockAPIError +// Create mock Ollama constructor +function MockOllama() { + return { + generate: mockOllamaGenerate, + } +} + jest.mock("@anthropic-ai/sdk", () => ({ __esModule: true, default: MockAnthropic, })) +jest.mock("ollama", () => ({ + Ollama: MockOllama, +})) + +import { APIKeyManager } from "../src/apiKeyManager" + +jest.mock("../src/apiKeyManager") + import Anthropic from "@anthropic-ai/sdk" import * as vscode from "vscode" import { activate } from "../src/extension" @@ -133,9 +151,12 @@ describe("Error Handling", () => { ;(vscode.workspace as any).getConfiguration = () => ({ get: (key: string) => { const defaults: { [key: string]: any } = { - model: "claude-3-5-sonnet-latest", + model: "claude-sonnet-4-0", maxTokens: 1024, - temperature: 0.4, + temperature: 0.2, + provider: "anthropic", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", } return defaults[key] }, @@ -167,19 +188,22 @@ describe("Error Handling", () => { usage: { input_tokens: 100, output_tokens: 50 }, } mockAnthropicCreate.mockResolvedValue(mockMessage) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() // Verify console.log was called with the expected arguments expect(console.log).toHaveBeenCalledWith("[DiffCommit] Stop Reason: ", mockMessage.stop_reason) - expect(console.log).toHaveBeenCalledWith("[DiffCommit] Usage: ", mockMessage.usage) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Input Tokens: ", mockMessage.usage.input_tokens) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Output Tokens: ", mockMessage.usage.output_tokens) }) describe("Anthropic API Error Handling", () => { it("handles 400 Bad Request error", async () => { const apiError = new Anthropic.APIError(400, "Bad request", "api_error", {}) mockAnthropicCreate.mockRejectedValue(apiError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() @@ -191,6 +215,7 @@ describe("Error Handling", () => { it("handles 401 Unauthorised error", async () => { const apiError = new Anthropic.APIError(401, "Invalid API key", "api_error", {}) mockAnthropicCreate.mockRejectedValue(apiError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() @@ -204,6 +229,7 @@ describe("Error Handling", () => { it("handles 403 Forbidden error", async () => { const apiError = new Anthropic.APIError(403, "Permission denied", "api_error", {}) mockAnthropicCreate.mockRejectedValue(apiError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() @@ -217,6 +243,7 @@ describe("Error Handling", () => { it("handles 429 Rate Limit error", async () => { const apiError = new Anthropic.APIError(429, "Too many requests", "api_error", {}) mockAnthropicCreate.mockRejectedValue(apiError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") activate(mockContext) await mockCommands["diffCommit.generateCommitMessage"]() @@ -248,6 +275,34 @@ describe("Error Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to generate commit message:\n\nUnknown error") expect(console.error).toHaveBeenCalledWith("Anthropic API Error (418):\n\nUnknown error") }) + + it("handles non-APIError exceptions", async () => { + const regularError = new Error("Network connection failed") + mockAnthropicCreate.mockRejectedValue(regularError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unknown error generating commit message: Network connection failed", + ) + expect(console.error).toHaveBeenCalledWith("Unknown error: Network connection failed") + }) + + it("handles non-Error objects", async () => { + const stringError = "Something went wrong" + mockAnthropicCreate.mockRejectedValue(stringError) + ;(APIKeyManager.prototype.getAPIKey as jest.Mock).mockResolvedValue("sk-test-api-key") + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unknown error generating commit message: Something went wrong", + ) + expect(console.error).toHaveBeenCalledWith("Unknown error: Something went wrong") + }) }) it("logs error when writing commit message to SCM fails", async () => { @@ -288,7 +343,8 @@ describe("Error Handling", () => { expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to write to SCM:\n\nSCM error") // Verify finally block still logs expect(console.log).toHaveBeenCalledWith("[DiffCommit] Stop Reason: ", mockMessage.stop_reason) - expect(console.log).toHaveBeenCalledWith("[DiffCommit] Usage: ", mockMessage.usage) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Input Tokens: ", mockMessage.usage.input_tokens) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Output Tokens: ", mockMessage.usage.output_tokens) }) it("logs error when opening commit message preview fails", async () => { @@ -328,6 +384,103 @@ describe("Error Handling", () => { ) // Verify finally block still logs expect(console.log).toHaveBeenCalledWith("[DiffCommit] Stop Reason: ", mockMessage.stop_reason) - expect(console.log).toHaveBeenCalledWith("[DiffCommit] Usage: ", mockMessage.usage) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Input Tokens: ", mockMessage.usage.input_tokens) + expect(console.log).toHaveBeenCalledWith("[DiffCommit] Output Tokens: ", mockMessage.usage.output_tokens) + }) + + describe("Ollama Error Handling", () => { + beforeEach(() => { + // Override the default configuration mock for Ollama tests + ;(vscode.workspace as any).getConfiguration = () => ({ + get: (key: string) => { + const config: { [key: string]: any } = { + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "llama3.2", + model: "claude-sonnet-4-0", + maxTokens: 1000, + temperature: 0.3, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + } + return config[key] + }, + }) + }) + + it("handles TypeError with fetch error (connection errors)", async () => { + const fetchError = new TypeError("fetch failed") + mockOllamaGenerate.mockRejectedValue(fetchError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unable to connect to Ollama server at http://localhost:11434. Please ensure that the Ollama server is running and accessible.", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nTypeError: fetch failed") + }) + + it("handles 404 model not found error", async () => { + const notFoundError = new Error("404 model not found") + mockOllamaGenerate.mockRejectedValue(notFoundError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Model 'llama3.2' not found. Please check if the model is available in Ollama.", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nError: 404 model not found") + }) + + it("handles 500 server error", async () => { + const serverError = new Error("500 internal server error") + mockOllamaGenerate.mockRejectedValue(serverError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Ollama server error. Please try again later.") + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nError: 500 internal server error") + }) + + it("handles generic Error with custom message", async () => { + const genericError = new Error("Something went wrong with Ollama") + mockOllamaGenerate.mockRejectedValue(genericError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to generate commit message with Ollama:\n\nSomething went wrong with Ollama", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nError: Something went wrong with Ollama") + }) + + it("handles unknown error type (not Error instance)", async () => { + const unknownError = "Some string error" + mockOllamaGenerate.mockRejectedValue(unknownError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unknown error generating commit message with Ollama: Some string error", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\nSome string error") + }) + + it("handles unknown error with object type", async () => { + const unknownError = { message: "Object error" } + mockOllamaGenerate.mockRejectedValue(unknownError) + + activate(mockContext) + await mockCommands["diffCommit.generateCommitMessage"]() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Unknown error generating commit message with Ollama: [object Object]", + ) + expect(console.error).toHaveBeenCalledWith("Ollama API Error:\n\n[object Object]") + }) }) }) diff --git a/test/extension.test.ts b/test/extension.test.ts new file mode 100644 index 0000000..9fdfcd3 --- /dev/null +++ b/test/extension.test.ts @@ -0,0 +1,473 @@ +import * as vscode from "vscode" +import { APIKeyManager } from "../src/apiKeyManager" +import { CommitMessageGenerator } from "../src/commitMessageGenerator" +import { ConfigManager } from "../src/configManager" +import { activate, deactivate } from "../src/extension" +import { GitManager } from "../src/gitManager" +import { OllamaManager } from "../src/ollamaManager" + +jest.mock("../src/apiKeyManager") +jest.mock("../src/commitMessageGenerator") +jest.mock("../src/configManager") +jest.mock("../src/gitManager") +jest.mock("../src/ollamaManager") +jest.mock("vscode") + +describe("Extension Core Functionality", () => { + let mockContext: vscode.ExtensionContext + let mockApiKeyManager: jest.Mocked + let mockGitManager: jest.Mocked + let mockConfigManager: jest.Mocked + let mockOllamaManager: jest.Mocked + let mockCommitMessageGenerator: jest.Mocked + let registeredCommands: Map + + beforeEach(() => { + jest.clearAllMocks() + registeredCommands = new Map() + + // Mock console methods + jest.spyOn(console, "error").mockImplementation() + + // Mock VS Code APIs + ;(vscode.commands.registerCommand as jest.Mock).mockImplementation((commandId: string, callback: Function) => { + registeredCommands.set(commandId, callback) + return { dispose: jest.fn() } + }) + ;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) + ;(vscode.workspace.onDidCloseTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) + ;(vscode.workspace.workspaceFolders as any) = [{ uri: { fsPath: "/test/workspace" } }] + ;(vscode.window.withProgress as jest.Mock).mockImplementation(async (options, task) => { + return await task({ report: jest.fn() }) + }) + + // Create mock context + mockContext = { + subscriptions: [], + secrets: { + get: jest.fn(), + store: jest.fn(), + delete: jest.fn(), + }, + } as unknown as vscode.ExtensionContext + + // Mock manager instances + mockApiKeyManager = { + getAPIKey: jest.fn(), + setAPIKey: jest.fn(), + deleteAPIKey: jest.fn(), + } as unknown as jest.Mocked + + mockGitManager = { + getDiff: jest.fn(), + setCommitMessage: jest.fn(), + } as unknown as jest.Mocked + + mockConfigManager = { + getConfig: jest.fn().mockReturnValue({ + provider: "anthropic", + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + ollamaHostname: "http://localhost:11434", + ollamaModel: "llama2", + }), + } as unknown as jest.Mocked + + mockOllamaManager = { + configureOllamaModel: jest.fn(), + changeOllamaModel: jest.fn(), + } as unknown as jest.Mocked + + mockCommitMessageGenerator = { + generateMessage: jest.fn(), + } as unknown as jest.Mocked + + // Mock constructor implementations + ;(APIKeyManager as jest.MockedClass).mockImplementation(() => mockApiKeyManager) + ;(GitManager as jest.MockedClass).mockImplementation(() => mockGitManager) + ;(ConfigManager as jest.MockedClass).mockImplementation(() => mockConfigManager) + ;(OllamaManager as jest.MockedClass).mockImplementation(() => mockOllamaManager) + ;(CommitMessageGenerator as jest.MockedClass).mockImplementation( + () => mockCommitMessageGenerator, + ) + }) + + describe("Extension Activation", () => { + it("should create all manager instances on activation", () => { + activate(mockContext) + + expect(APIKeyManager).toHaveBeenCalledWith(mockContext) + expect(GitManager).toHaveBeenCalled() + expect(ConfigManager).toHaveBeenCalled() + expect(OllamaManager).toHaveBeenCalled() + }) + + it("should register all expected commands", () => { + activate(mockContext) + + const expectedCommands = [ + "diffCommit.updateAPIKey", + "diffCommit.getAPIKey", + "diffCommit.deleteAPIKey", + "diffCommit.configureOllamaModel", + "diffCommit.changeOllamaModel", + "diffCommit.generateCommitMessage", + "diffCommit.previewCommitMessage", + ] + + expectedCommands.forEach((commandId) => { + expect(registeredCommands.has(commandId)).toBe(true) + }) + }) + + it("should setup workspace event listeners", () => { + activate(mockContext) + + expect(vscode.workspace.onDidSaveTextDocument).toHaveBeenCalled() + expect(vscode.workspace.onDidCloseTextDocument).toHaveBeenCalled() + }) + + it("should add all subscriptions to context", () => { + activate(mockContext) + + // 7 commands + 2 workspace event handlers = 9 subscriptions + expect(mockContext.subscriptions).toHaveLength(9) + }) + }) + + describe("generateCommitMessage function", () => { + beforeEach(() => { + activate(mockContext) + }) + + it("should show error when no workspace folder is found", async () => { + ;(vscode.workspace.workspaceFolders as any) = undefined + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No workspace folder found") + }) + + it("should show error when no changes are detected", async () => { + mockGitManager.getDiff.mockResolvedValue(undefined) + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("No changes detected") + }) + + it("should handle Ollama provider with missing model", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockConfigManager.getConfig.mockReturnValue({ + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + }) + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "No Ollama model selected. Please configure an Ollama model first.", + ) + }) + + it("should create Ollama CommitMessageGenerator for ollama provider", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockConfigManager.getConfig.mockReturnValue({ + provider: "ollama", + ollamaHostname: "http://localhost:11434", + ollamaModel: "llama2", + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + }) + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(CommitMessageGenerator).toHaveBeenCalledWith("http://localhost:11434", "llama2") + expect(mockCommitMessageGenerator.generateMessage).toHaveBeenCalled() + }) + + it("should handle Anthropic provider with missing API key", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue(undefined) + mockApiKeyManager.setAPIKey.mockResolvedValue(undefined) + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("API Key is required") + }) + + it("should create Anthropic CommitMessageGenerator for anthropic provider", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(CommitMessageGenerator).toHaveBeenCalledWith("sk-test-key") + expect(mockCommitMessageGenerator.generateMessage).toHaveBeenCalled() + expect(mockGitManager.setCommitMessage).toHaveBeenCalledWith("feat: test commit") + }) + + it("should fall back to setAPIKey when getAPIKey returns null", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue(undefined) + mockApiKeyManager.setAPIKey.mockResolvedValue("sk-new-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(mockApiKeyManager.setAPIKey).toHaveBeenCalled() + expect(CommitMessageGenerator).toHaveBeenCalledWith("sk-new-key") + }) + + it("should show progress with correct options", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(vscode.window.withProgress).toHaveBeenCalledWith( + { + location: vscode.ProgressLocation.Notification, + title: "Diff Commit", + cancellable: true, + }, + expect.any(Function), + ) + }) + + it("should handle errors gracefully", async () => { + mockGitManager.getDiff.mockRejectedValue(new Error("Git error")) + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(console.error).toHaveBeenCalledWith("Error writing commit message to SCM:\n\nError: Git error") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to write to SCM:\n\nGit error") + }) + }) + + describe("Preview Command", () => { + beforeEach(() => { + activate(mockContext) + }) + + it("should open preview document for generated commit message", async () => { + const mockDocument = { getText: jest.fn().mockReturnValue("feat: test commit") } + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue(mockDocument) + ;(vscode.window.showTextDocument as jest.Mock).mockResolvedValue(undefined) + + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + await previewCommand() + + expect(vscode.workspace.openTextDocument).toHaveBeenCalledWith({ + content: "feat: test commit", + language: "markdown", + }) + expect(vscode.window.showTextDocument).toHaveBeenCalledWith(mockDocument) + }) + + it("should handle preview errors gracefully", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockRejectedValue(new Error("Preview error")) + + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + await previewCommand() + + expect(console.error).toHaveBeenCalledWith("Error opening commit message preview:\n\nError: Preview error") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to open commit message preview:\n\nPreview error", + ) + }) + + it("should not create preview if no commit message is generated", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue(undefined) + + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + await previewCommand() + + expect(vscode.workspace.openTextDocument).not.toHaveBeenCalled() + expect(vscode.window.showTextDocument).not.toHaveBeenCalled() + }) + }) + + describe("Document Event Handlers", () => { + let saveCallback: Function + let closeCallback: Function + let mockPreviewDocument: any + + beforeEach(() => { + ;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockImplementation((callback) => { + saveCallback = callback + return { dispose: jest.fn() } + }) + ;(vscode.workspace.onDidCloseTextDocument as jest.Mock).mockImplementation((callback) => { + closeCallback = callback + return { dispose: jest.fn() } + }) + + mockPreviewDocument = { getText: jest.fn().mockReturnValue("feat: updated commit") } + + activate(mockContext) + }) + + it("should update git commit message when preview document is saved", () => { + // First create a preview document + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue(mockPreviewDocument) + + // Trigger preview command to set previewDocument + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + return previewCommand().then(() => { + // Now trigger save event + saveCallback(mockPreviewDocument) + + expect(mockGitManager.setCommitMessage).toHaveBeenCalledWith("feat: updated commit") + }) + }) + + it("should not update git commit message when other document is saved", () => { + const otherDocument = { getText: jest.fn().mockReturnValue("other content") } + + saveCallback(otherDocument) + + expect(mockGitManager.setCommitMessage).not.toHaveBeenCalled() + }) + + it("should clear preview document reference when preview document is closed", () => { + // First create a preview document + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue(mockPreviewDocument) + + // Trigger preview command to set previewDocument + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + return previewCommand().then(() => { + // Now trigger close event + closeCallback(mockPreviewDocument) + + // Subsequent save should not trigger setCommitMessage + saveCallback(mockPreviewDocument) + expect(mockGitManager.setCommitMessage).not.toHaveBeenCalled() + }) + }) + + it("should not clear preview document reference when other document is closed", () => { + const otherDocument = { getText: jest.fn().mockReturnValue("other content") } + + // First create a preview document + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockResolvedValue(mockPreviewDocument) + + // Trigger preview command to set previewDocument + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + return previewCommand().then(() => { + // Now close other document + closeCallback(otherDocument) + + // Save of preview document should still work + saveCallback(mockPreviewDocument) + expect(mockGitManager.setCommitMessage).toHaveBeenCalledWith("feat: updated commit") + }) + }) + }) + + describe("Command Delegation", () => { + beforeEach(() => { + activate(mockContext) + }) + + it("should delegate API key management commands correctly", () => { + const updateKeyCommand = registeredCommands.get("diffCommit.updateAPIKey")! + const getKeyCommand = registeredCommands.get("diffCommit.getAPIKey")! + const deleteKeyCommand = registeredCommands.get("diffCommit.deleteAPIKey")! + + updateKeyCommand() + getKeyCommand() + deleteKeyCommand() + + expect(mockApiKeyManager.setAPIKey).toHaveBeenCalled() + expect(mockApiKeyManager.getAPIKey).toHaveBeenCalled() + expect(mockApiKeyManager.deleteAPIKey).toHaveBeenCalled() + }) + + it("should delegate Ollama model commands correctly", () => { + const configureModelCommand = registeredCommands.get("diffCommit.configureOllamaModel")! + const changeModelCommand = registeredCommands.get("diffCommit.changeOllamaModel")! + + configureModelCommand() + changeModelCommand() + + expect(mockOllamaManager.configureOllamaModel).toHaveBeenCalledTimes(1) + expect(mockOllamaManager.changeOllamaModel).toHaveBeenCalledTimes(1) + }) + }) + + describe("Error Handling Edge Cases", () => { + beforeEach(() => { + activate(mockContext) + }) + + it("should handle non-Error objects in generateCommitMessage", async () => { + mockGitManager.getDiff.mockRejectedValue("String error") + + const generateCommand = registeredCommands.get("diffCommit.generateCommitMessage")! + await generateCommand() + + expect(console.error).toHaveBeenCalledWith("Error writing commit message to SCM:\n\nString error") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith("Failed to write to SCM:\n\nString error") + }) + + it("should handle non-Error objects in previewCommitMessage", async () => { + mockGitManager.getDiff.mockResolvedValue("test diff") + mockApiKeyManager.getAPIKey.mockResolvedValue("sk-test-key") + mockCommitMessageGenerator.generateMessage.mockResolvedValue("feat: test commit") + ;(vscode.workspace.openTextDocument as jest.Mock).mockRejectedValue("String error") + + const previewCommand = registeredCommands.get("diffCommit.previewCommitMessage")! + await previewCommand() + + expect(console.error).toHaveBeenCalledWith("Error opening commit message preview:\n\nString error") + expect(vscode.window.showErrorMessage).toHaveBeenCalledWith( + "Failed to open commit message preview:\n\nString error", + ) + }) + }) + + describe("Extension Deactivation", () => { + it("should handle deactivation gracefully", () => { + expect(() => deactivate()).not.toThrow() + }) + }) +}) diff --git a/test/gitAndCommands.test.ts b/test/gitAndCommands.test.ts index 29e5716..81da22f 100644 --- a/test/gitAndCommands.test.ts +++ b/test/gitAndCommands.test.ts @@ -29,13 +29,15 @@ describe("Command Registration and Lifecycle", () => { }) describe("Command Registration", () => { - test("should register all commands in correct order", () => { + it("should register all commands in correct order", () => { activate(mockContext) const expectedCommands = [ "diffCommit.updateAPIKey", "diffCommit.getAPIKey", "diffCommit.deleteAPIKey", + "diffCommit.configureOllamaModel", + "diffCommit.changeOllamaModel", "diffCommit.generateCommitMessage", "diffCommit.previewCommitMessage", ] @@ -46,11 +48,11 @@ describe("Command Registration and Lifecycle", () => { }) }) - test("should add all commands to subscriptions", () => { + it("should add all commands to subscriptions", () => { activate(mockContext) - // 7 subscriptions: 5 commands + 2 workspace event handlers - expect(mockContext.subscriptions).toHaveLength(7) + // 9 subscriptions: 7 commands + 2 workspace event handlers + expect(mockContext.subscriptions).toHaveLength(9) mockContext.subscriptions.forEach((subscription: any) => { expect(subscription).toHaveProperty("dispose") expect(typeof subscription.dispose).toBe("function") @@ -59,7 +61,7 @@ describe("Command Registration and Lifecycle", () => { }) describe("Command Lifecycle", () => { - test("should properly dispose commands on deactivation", () => { + it("should properly dispose commands on deactivation", () => { activate(mockContext) const disposeMocks = mockContext.subscriptions.map((sub: any) => sub.dispose as jest.Mock) @@ -71,7 +73,7 @@ describe("Command Registration and Lifecycle", () => { }) }) - test("should maintain command registration if one fails", () => { + it("should maintain command registration if one fails", () => { // Mock console.error const consoleErrorSpy = jest.spyOn(console, "error").mockImplementation(() => {}) @@ -90,8 +92,8 @@ describe("Command Registration and Lifecycle", () => { activate(mockContext) // Should still register remaining commands - expect(registeredCommands.size).toBe(4) - expect(mockContext.subscriptions.length).toBe(7) // All commands get registered + expect(registeredCommands.size).toBe(6) + expect(mockContext.subscriptions.length).toBe(9) // All commands get registered expect(consoleErrorSpy).toHaveBeenCalledWith(expect.any(Error)) consoleErrorSpy.mockRestore() @@ -99,7 +101,7 @@ describe("Command Registration and Lifecycle", () => { }) describe("Subscription Management", () => { - test("should not add duplicate commands to subscriptions", () => { + it("should not add duplicate commands to subscriptions", () => { // First activation activate(mockContext) const firstSubscriptionsLength = mockContext.subscriptions.length @@ -112,10 +114,10 @@ describe("Command Registration and Lifecycle", () => { // Should have same number of registrations as first activation expect(mockContext.subscriptions.length).toBe(firstSubscriptionsLength) - expect(registeredCommands.size).toBe(5) // Only command registrations + expect(registeredCommands.size).toBe(7) // Only command registrations }) - test("should handle disposal of invalid subscriptions", () => { + it("should handle disposal of invalid subscriptions", () => { activate(mockContext) // Add an invalid subscription @@ -131,9 +133,9 @@ describe("Command Registration and Lifecycle", () => { }).not.toThrow() }) - test("should handle empty subscriptions array", () => { + it("should handle empty subscriptions array", () => { expect(() => activate(mockContext)).not.toThrow() - expect(mockContext.subscriptions.length).toBe(7) // 5 commands + 2 workspace event handlers + expect(mockContext.subscriptions.length).toBe(9) // 7 commands + 2 workspace event handlers }) }) }) diff --git a/test/gitIntegration.test.ts b/test/gitIntegration.test.ts index a39f011..52e1e4b 100644 --- a/test/gitIntegration.test.ts +++ b/test/gitIntegration.test.ts @@ -48,13 +48,19 @@ jest.mock("vscode", () => { get: jest.fn((key: string) => { switch (key) { case "model": - return "claude-3-5-sonnet-20241022" + return "claude-sonnet-4-0" case "maxTokens": return 1024 case "temperature": - return 0.4 + return 0.2 case "allowedTypes": return ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"] + case "provider": + return "anthropic" + case "ollamaHostname": + return "http://localhost:11434" + case "ollamaModel": + return "" default: return undefined } @@ -209,9 +215,9 @@ describe("Git Integration", () => { expect(mockGitRepo.diff).toHaveBeenCalledWith(true) expect(mockAnthropicCreate).toHaveBeenCalledWith({ - model: "claude-3-5-sonnet-20241022", + model: "claude-sonnet-4-0", max_tokens: 1024, - temperature: 0.4, + temperature: 0.2, system: expect.any(String), messages: expect.arrayContaining([ expect.objectContaining({ @@ -231,9 +237,9 @@ describe("Git Integration", () => { expect(mockGitRepo.diff).toHaveBeenCalledWith(true) expect(mockAnthropicCreate).toHaveBeenCalledWith({ - model: "claude-3-5-sonnet-20241022", + model: "claude-sonnet-4-0", max_tokens: 1024, - temperature: 0.4, + temperature: 0.2, system: expect.any(String), messages: expect.arrayContaining([ expect.objectContaining({ diff --git a/test/messageHandling.test.ts b/test/messageHandling.test.ts index 66c56d9..011686b 100644 --- a/test/messageHandling.test.ts +++ b/test/messageHandling.test.ts @@ -81,6 +81,20 @@ describe("Message Handling", () => { ;(vscode.workspace.onDidSaveTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) ;(vscode.workspace.onDidCloseTextDocument as jest.Mock).mockReturnValue({ dispose: jest.fn() }) + // Set up default configuration + configGetMock.mockImplementation((key: string) => { + const defaults: { [key: string]: any } = { + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + allowedTypes: ["feat", "fix", "refactor", "chore", "docs", "style", "test", "perf", "ci"], + provider: "anthropic", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", + } + return defaults[key] + }) + // Mock workspace folders ;(vscode.workspace.workspaceFolders as any) = [{ uri: { fsPath: "/test/workspace" } }] }) diff --git a/test/ollamaManager.test.ts b/test/ollamaManager.test.ts new file mode 100644 index 0000000..e7789fe --- /dev/null +++ b/test/ollamaManager.test.ts @@ -0,0 +1,261 @@ +const mockOllamaList = jest.fn() + +// Create mock Ollama constructor +function MockOllama() { + return { + list: mockOllamaList, + } +} + +jest.mock("ollama", () => ({ + Ollama: MockOllama, +})) + +// Mock console +const mockConsoleError = jest.fn() +jest.mock("console", () => ({ + error: mockConsoleError, +})) + +// Mock vscode +import * as vscode from "vscode" + +jest.mock("vscode") + +import { OllamaManager } from "../src/ollamaManager" + +describe("OllamaManager", () => { + let ollamaManager: OllamaManager + let mockShowErrorMessage: jest.Mock + let mockShowWarningMessage: jest.Mock + let mockShowInputBox: jest.Mock + let mockShowQuickPick: jest.Mock + let mockSetStatusBarMessage: jest.Mock + let mockGetConfiguration: jest.Mock + let mockConfigGet: jest.Mock + let mockConfigUpdate: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + + // Mock vscode functions + mockShowErrorMessage = jest.fn() + mockShowWarningMessage = jest.fn() + mockShowInputBox = jest.fn() + mockShowQuickPick = jest.fn() + mockSetStatusBarMessage = jest.fn() + mockConfigGet = jest.fn() + mockConfigUpdate = jest.fn().mockResolvedValue(undefined) + ;(vscode.window as any).showErrorMessage = mockShowErrorMessage + ;(vscode.window as any).showWarningMessage = mockShowWarningMessage + ;(vscode.window as any).showInputBox = mockShowInputBox + ;(vscode.window as any).showQuickPick = mockShowQuickPick + ;(vscode.window as any).setStatusBarMessage = mockSetStatusBarMessage + + mockGetConfiguration = jest.fn().mockReturnValue({ + get: mockConfigGet, + update: mockConfigUpdate, + }) + ;(vscode.workspace as any).getConfiguration = mockGetConfiguration + + ollamaManager = new OllamaManager() + }) + + describe("getAvailableModels", () => { + it("should return model names successfully", async () => { + const mockModels = [{ name: "llama3.2" }, { name: "codellama" }, { name: "mistral" }] + mockOllamaList.mockResolvedValue({ models: mockModels }) + + const result = await ollamaManager.getAvailableModels("http://localhost:11434") + + expect(result).toEqual(["llama3.2", "codellama", "mistral"]) + expect(mockOllamaList).toHaveBeenCalled() + }) + + it("should handle errors and show error message", async () => { + const testError = new Error("Connection failed") + mockOllamaList.mockRejectedValue(testError) + + await expect(ollamaManager.getAvailableModels("http://localhost:11434")).rejects.toThrow("Connection failed") + + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to fetch Ollama models: Connection failed") + }) + + it("should handle non-Error objects", async () => { + const testError = "String error" + mockOllamaList.mockRejectedValue(testError) + + await expect(ollamaManager.getAvailableModels("http://localhost:11434")).rejects.toBe("String error") + + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to fetch Ollama models: String error") + }) + }) + + describe("configureOllamaModel", () => { + beforeEach(() => { + mockConfigGet.mockImplementation((key: string) => { + if (key === "ollamaHostname") return "http://localhost:11434" + if (key === "ollamaModel") return "llama3.2" + return undefined + }) + }) + + it("should configure successfully with valid hostname and models", async () => { + const mockModels = [{ name: "llama3.2" }, { name: "codellama" }] + mockOllamaList.mockResolvedValue({ models: mockModels }) + mockShowInputBox.mockResolvedValue("http://localhost:11434") + mockShowQuickPick.mockResolvedValue("llama3.2") + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(true) + expect(mockConfigUpdate).toHaveBeenCalledWith("provider", "ollama", true) + expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaHostname", "http://localhost:11434", true) + expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaModel", "llama3.2", true) + expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model 'llama3.2' selected successfully", 4000) + }) + + it("should return false when user cancels hostname input", async () => { + mockShowInputBox.mockResolvedValue(undefined) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockConfigUpdate).not.toHaveBeenCalled() + }) + + it("should handle invalid hostname URL and retry", async () => { + // First call with invalid URL, second call cancels + mockShowInputBox.mockResolvedValueOnce("invalid-url").mockResolvedValueOnce(undefined) + + const result = await ollamaManager.configureOllamaModel() + + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Invalid hostname URL. Please enter a valid URL (eg http://localhost:11434).", + ) + expect(result).toBe(false) + }) + + it("should show warning when no models found", async () => { + mockOllamaList.mockResolvedValue({ models: [] }) + mockShowInputBox.mockResolvedValue("http://localhost:11434") + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowWarningMessage).toHaveBeenCalledWith( + "No models found on the Ollama server. Please pull a model first.", + ) + }) + + it("should return false when user cancels model selection", async () => { + const mockModels = [{ name: "llama3.2" }] + mockOllamaList.mockResolvedValue({ models: mockModels }) + mockShowInputBox.mockResolvedValue("http://localhost:11434") + mockShowQuickPick.mockResolvedValue(undefined) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockConfigUpdate).not.toHaveBeenCalled() + }) + + describe("error handling", () => { + beforeEach(() => { + mockShowInputBox.mockResolvedValue("http://localhost:11434") + }) + + it("should handle ECONNREFUSED error", async () => { + const connectionError = new Error("ECONNREFUSED - connection refused") + mockOllamaList.mockRejectedValue(connectionError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Unable to connect to Ollama server at http://localhost:11434. Please ensure that the Ollama server is running and accessible.", + ) + }) + + it("should handle fetch error", async () => { + const fetchError = new Error("fetch failed") + mockOllamaList.mockRejectedValue(fetchError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Unable to connect to Ollama server at http://localhost:11434. Please ensure that the Ollama server is running and accessible.", + ) + }) + + it("should handle 404 error", async () => { + const notFoundError = new Error("404 not found") + mockOllamaList.mockRejectedValue(notFoundError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Ollama server not found at http://localhost:11434. Please check the hostname and try again.", + ) + }) + + it("should handle generic Error", async () => { + const genericError = new Error("Something went wrong") + mockOllamaList.mockRejectedValue(genericError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to connect to Ollama: Something went wrong") + }) + + it("should handle non-Error objects", async () => { + const stringError = "String error" + mockOllamaList.mockRejectedValue(stringError) + + const result = await ollamaManager.configureOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith("Failed to connect to Ollama: String error") + }) + }) + }) + + describe("changeOllamaModel", () => { + beforeEach(() => { + mockConfigGet.mockImplementation((key: string) => { + if (key === "ollamaHostname") return "http://localhost:11434" + if (key === "ollamaModel") return "llama3.2" + return undefined + }) + }) + + it("should change model successfully without changing hostname", async () => { + const mockModels = [{ name: "llama3.2" }, { name: "codellama" }] + mockOllamaList.mockResolvedValue({ models: mockModels }) + mockShowQuickPick.mockResolvedValue("codellama") + + const result = await ollamaManager.changeOllamaModel() + + expect(result).toBe(true) + expect(mockConfigUpdate).not.toHaveBeenCalledWith("provider", "ollama", true) + expect(mockConfigUpdate).not.toHaveBeenCalledWith("ollamaHostname", expect.anything(), true) + expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaModel", "codellama", true) + expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model 'codellama' changed to successfully", 4000) + }) + + it("should handle errors during model changing", async () => { + const connectionError = new Error("ECONNREFUSED - connection refused") + mockOllamaList.mockRejectedValue(connectionError) + + const result = await ollamaManager.changeOllamaModel() + + expect(result).toBe(false) + expect(mockShowErrorMessage).toHaveBeenCalledWith( + "Unable to connect to Ollama server at http://localhost:11434. Please ensure that the Ollama server is running and accessible.", + ) + }) + }) +}) diff --git a/test/withProgressAPI.test.ts b/test/withProgressAPI.test.ts index 6c732ef..6dbcc72 100644 --- a/test/withProgressAPI.test.ts +++ b/test/withProgressAPI.test.ts @@ -41,6 +41,16 @@ describe("Progress API Integration", () => { // Mock the constructor ;(GitManager as jest.MockedClass).mockImplementation(() => mockGitManager) + // Mock ConfigManager + ;(ConfigManager.prototype.getConfig as jest.Mock).mockReturnValue({ + model: "claude-sonnet-4-0", + maxTokens: 1024, + temperature: 0.2, + provider: "anthropic", + ollamaHostname: "http://localhost:11434", + ollamaModel: "", + }) + // Mock workspace folders ;(workspace.workspaceFolders as any) = [ { @@ -88,7 +98,7 @@ describe("Progress API Integration", () => { // Verify progress messages in order expect(mockProgress.report.mock.calls).toEqual([ [{ message: "Getting git diff..." }], - [{ message: "Validating API key..." }], + [{ message: "Validating configuration..." }], [{ message: "Generating commit message..." }], ]) }) @@ -120,7 +130,7 @@ describe("Progress API Integration", () => { // Should show first two progress messages expect(mockProgress.report.mock.calls).toEqual([ [{ message: "Getting git diff..." }], - [{ message: "Validating API key..." }], + [{ message: "Validating configuration..." }], ]) // Should show error message @@ -139,7 +149,7 @@ describe("Progress API Integration", () => { // Should show first two progress messages expect(mockProgress.report.mock.calls).toEqual([ [{ message: "Getting git diff..." }], - [{ message: "Validating API key..." }], + [{ message: "Validating configuration..." }], ]) // Should show error message @@ -166,7 +176,7 @@ describe("Progress API Integration", () => { // Verify all progress messages shown expect(mockProgress.report.mock.calls).toEqual([ [{ message: "Getting git diff..." }], - [{ message: "Validating API key..." }], + [{ message: "Validating configuration..." }], [{ message: "Generating commit message..." }], ]) From c36a4c85e845b359a244509ffa364a794537291b Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 23:41:28 +1000 Subject: [PATCH 2/4] fix(logging): add optional chaining to prevent undefined property access Add optional chaining operators to message.usage property access in console log statements to prevent potential "Cannot read property of undefined" errors when the usage object might not exist. --- src/commitMessageGenerator.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commitMessageGenerator.ts b/src/commitMessageGenerator.ts index 2fd804d..2bd0dd6 100644 --- a/src/commitMessageGenerator.ts +++ b/src/commitMessageGenerator.ts @@ -88,10 +88,10 @@ export class CommitMessageGenerator { return undefined } finally { console.log("[DiffCommit] Stop Reason: ", message?.stop_reason) - console.log("[DiffCommit] Input Tokens: ", message?.usage.input_tokens) - console.log("[DiffCommit] Cache Creation Tokens: ", message?.usage.cache_creation_input_tokens) - console.log("[DiffCommit] Cache Read Tokens: ", message?.usage.cache_read_input_tokens) - console.log("[DiffCommit] Output Tokens: ", message?.usage.output_tokens) + console.log("[DiffCommit] Input Tokens: ", message?.usage?.input_tokens) + console.log("[DiffCommit] Cache Creation Tokens: ", message?.usage?.cache_creation_input_tokens) + console.log("[DiffCommit] Cache Read Tokens: ", message?.usage?.cache_read_input_tokens) + console.log("[DiffCommit] Output Tokens: ", message?.usage?.output_tokens) } } From 4993f4071520f7e9e4aae8bc31ead5c5350e5c90 Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 23:41:54 +1000 Subject: [PATCH 3/4] refactor(ollamaManager): simplify status messages for model updates The commit simplifies the status messages displayed when updating Ollama models by: - Removing conditional message logic based on hostname selection - Standardising the success message to always use "updated to" - Simplifying the error message to consistently use "updating" terminology - Removing unnecessary variables that were used for message construction --- src/ollamaManager.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ollamaManager.ts b/src/ollamaManager.ts index 6b8fc7b..5d17b13 100644 --- a/src/ollamaManager.ts +++ b/src/ollamaManager.ts @@ -62,14 +62,11 @@ export class OllamaManager { } await config.update("ollamaModel", selectedModel, true) - const action = includeHostnameSelection ? "selected" : "changed to" - - window.setStatusBarMessage(`✓ Ollama model '${selectedModel}' ${action} successfully`, 4000) + window.setStatusBarMessage(`✓ Ollama model updated to '${selectedModel}' successfully`, 4000) return true } catch (error) { - const operation = includeHostnameSelection ? "selecting" : "changing" - console.error(`Error ${operation} Ollama model:`, error) + console.error(`Error updating Ollama model:`, error) if (error instanceof Error) { if (error.message.includes("ECONNREFUSED") || error.message.includes("fetch")) { From aa1628c3e0ddc4be0e8b5f0c80b59cc40a26c6bc Mon Sep 17 00:00:00 2001 From: Tim Stone Date: Thu, 5 Jun 2025 23:43:51 +1000 Subject: [PATCH 4/4] fix(tests): improve clarity of Ollama model update success messages The commit updates the expected status bar messages in the OllamaManager tests to use more consistent and clear wording. It changes "model 'x' selected successfully" and "model 'x' changed to successfully" to the more precise "model updated to 'x' successfully" format, improving readability and consistency across the test suite. --- test/ollamaManager.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/ollamaManager.test.ts b/test/ollamaManager.test.ts index e7789fe..2f08cae 100644 --- a/test/ollamaManager.test.ts +++ b/test/ollamaManager.test.ts @@ -112,7 +112,7 @@ describe("OllamaManager", () => { expect(mockConfigUpdate).toHaveBeenCalledWith("provider", "ollama", true) expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaHostname", "http://localhost:11434", true) expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaModel", "llama3.2", true) - expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model 'llama3.2' selected successfully", 4000) + expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model updated to 'llama3.2' successfully", 4000) }) it("should return false when user cancels hostname input", async () => { @@ -243,7 +243,7 @@ describe("OllamaManager", () => { expect(mockConfigUpdate).not.toHaveBeenCalledWith("provider", "ollama", true) expect(mockConfigUpdate).not.toHaveBeenCalledWith("ollamaHostname", expect.anything(), true) expect(mockConfigUpdate).toHaveBeenCalledWith("ollamaModel", "codellama", true) - expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model 'codellama' changed to successfully", 4000) + expect(mockSetStatusBarMessage).toHaveBeenCalledWith("✓ Ollama model updated to 'codellama' successfully", 4000) }) it("should handle errors during model changing", async () => {