Compare commits

..

3 Commits

Author SHA1 Message Date
AndyMik90 0301212b9d fix(ci): fix YAML syntax error in release.yml multiline string 2026-01-02 15:16:52 +01:00
AndyMik90 272b8792cd ci: force workflow re-index for tag trigger 2026-01-02 15:12:30 +01:00
Andy fd5341f14c Fix/2.7.2 hotfixes (#577)
* feat(terminal): respect preferred terminal setting for Windows PTY shell

Adds Windows shell selection in the embedded PTY terminal based on
the user's preferredTerminal setting from onboarding/settings.

On Windows, the terminal preference (PowerShell, Windows Terminal, CMD)
now maps to the appropriate shell executable when spawning PTY processes.
This ensures the embedded terminal matches user expectations when they
select their preferred terminal during setup.

- Adds WINDOWS_SHELL_PATHS mapping for powershell, windowsterminal, cmd
- Implements getWindowsShell() to find first available shell executable
- Falls back to COMSPEC/cmd.exe for 'system' or unknown terminals
- Reads preferredTerminal from user settings on each spawn

* fix(ci): cache pip wheels to speed up Intel Mac builds

The real_ladybug package has no pre-built wheel for macOS x86_64 (Intel),
requiring Rust compilation from source on every build. This caused builds
to take 5-10+ minutes.

Changes:
- Remove --no-cache-dir from pip install so wheels get cached
- Add pip wheel cache to GitHub Actions cache for all platforms
- Include requirements.txt hash in cache keys for proper invalidation
- Fix restore-keys to avoid falling back to incompatible old caches

After this fix, subsequent Intel Mac builds will use the cached compiled
wheel instead of rebuilding from source each time.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* # 🔥 hotfix(electron): restore app functionality on Windows broken by GPU cache errors (#569)

## 📋 Critical Issue

| Severity | Impact | Affected Users |
|----------|--------|----------------|
| 🔴 **CRITICAL** | 🚫 **Non-functional** | 🪟 **Windows users** |

On Windows systems, the Electron app failed to create GPU shader and program caches due to filesystem permission errors (**Error 0x5: Access Denied**). This prevented users from initiating the autonomous coding phase, rendering the application **non-functional** for its primary purpose.

---

## 🔍 Root Cause Analysis

### The Problem
Chromium's GPU process attempts to create persistent shader caches in the following locations:

%LOCALAPPDATA%\auto-claude-ui\GPUCache\
%LOCALAPPDATA%\auto-claude-ui\ShaderCache\

### Why It Fails
| Factor | Description |
|--------|-------------|
| 🦠 **Antivirus** | Real-time scanning blocks cache directory creation |
| 🛡️ **Windows Defender** | Protection policies deny write access |
| ☁️ **Sync Software** | OneDrive/Dropbox interferes with AppData folders |
| 🔐 **Permissions** | Insufficient rights in default Electron cache paths |

### Error Console Output
 ERROR:net\disk_cache\cache_util_win.cc:25] Unable to move the cache: Zugriff verweigert (0x5)
 ERROR:gpu\ipc\host\gpu_disk_cache.cc:724] Gpu Cache Creation failed: -2
 ERROR:net\disk_cache\disk_cache.cc:236] Unable to create cache

---

##  Solution Implemented

### 1️⃣ GPU Shader Disk Cache Disabled
app.commandLine.appendSwitch('disable-gpu-shader-disk-cache');
-  Prevents Chromium from writing shader caches to disk
-  GPU acceleration remains fully functional
- 🎯 Zero performance impact on typical usage

### 2️⃣ GPU Program Disk Cache Disabled
app.commandLine.appendSwitch('disable-gpu-program-cache');
- 🚫 Prevents compiled GPU program caching issues
- 🔒 Eliminates permission-related failures

### 3️⃣ Startup Cache Clearing
session.defaultSession.clearCache()
  .then(() => console.log('[main] Cleared cache on startup'))
  .catch((err) => console.warn('[main] Failed to clear cache:', err));
- 🧹 Clears stale session cache on initialization
- 🔧 Prevents errors from corrupted cache artifacts
- ⚠️ Includes error handling for robustness

---

## 📝 Technical Changes

### Files Modified
| File | Changes |
|------|---------|
| apps/frontend/src/main/index.ts | +13 lines (cache fixes) |

### Platform Gating
 **Windows Only** (process.platform === 'win32')
 macOS & Linux behavior unchanged

---

## 🎯 Impact Assessment

| Aspect | Status | Details |
|--------|--------|---------|
| 🎮 **GPU Acceleration** |  **PRESERVED** | Hardware rendering fully functional |
| 🤖 **Agent Functionality** |  **RESTORED** | Coding phase now works on Windows |
| 🖥️ **Console Errors** |  **ELIMINATED** | Clean startup on all Windows systems |
|  **Performance** |  **NO IMPACT** | Typical usage unaffected |
| 🔙 **Compatibility** |  **MAINTAINED** | No breaking changes |

---

## 🧪 Testing

### Test Environments
| Platform | Antivirus | Result |
|----------|-----------|--------|
| Windows 10 | Windows Defender |  Pass |
| Windows 11 | Real-time scanning |  Pass |

### Test Scenarios
 Application starts without cache errors
 Agent initialization completes successfully
 Coding phase executes without GPU failures
 GPU acceleration functional (hardware rendering active)

---

## 📦 Meta Information

| Field | Value |
|-------|-------|
| 📍 **Component** | apps/frontend/src/main/index.ts |
| 🪟 **Platform** | Windows (win32) - platform-gated |
| 🔥 **Type** | Hotfix (critical functionality restoration) |

---

## 🔄 Backwards Compatibility

| Check | Status |
|-------|--------|
| Breaking Changes |  None |
| User Data Migration |  Not required |
| Settings Impact |  Unaffected |
| Workflow Changes |  None required |

---

*This hotfix restores critical functionality for Windows users while maintaining
full compatibility with macOS and Linux platforms. GPU acceleration remains
fully functional — only disk-based caching is disabled.*

Co-authored-by: sniggl <snigg1337@gmail.com>

* ci(release): add CHANGELOG.md validation and fix release workflow

The release workflow was failing with "GitHub Releases requires a tag"
when triggered via workflow_dispatch because no tag existed.

Changes:
- prepare-release.yml: Validates CHANGELOG.md has entry for version
  BEFORE creating tag (fails early with clear error message)
- release.yml: Uses CHANGELOG.md content instead of release-drafter
  for release notes; fixes workflow_dispatch to be dry-run only
- bump-version.js: Warns if CHANGELOG.md missing entry for new version
- RELEASE.md: Updated documentation for new changelog-first workflow

This ensures releases are only created when CHANGELOG.md is properly
updated, preventing incomplete releases and giving better release notes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix(merge): handle Windows CRLF line endings in regex fallback

The merge conflict layer was failing on Windows when tree-sitter was
unavailable. The regex-based fallback used split("\n") which doesn't
handle CRLF line endings, and findall() returned tuples for JS/TS
patterns breaking function detection.

Changes:
- Normalize line endings (CRLF → LF) before parsing in regex_analyzer.py
- Use splitlines() instead of split("\n") in file_merger.py
- Fix tuple extraction from findall() for JS/TS function patterns
- Normalize line endings before tree-sitter parsing for consistent
  byte positions

All 111 merge tests pass. These changes are cross-platform safe and
maintain compatibility with macOS and Linux.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* 2.7.2 release

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: sniggl <sniggl1337@gmail.com>
Co-authored-by: sniggl <snigg1337@gmail.com>
2026-01-02 14:13:17 +01:00
91 changed files with 2497 additions and 28847 deletions
+3 -3
View File
@@ -1,4 +1,5 @@
name: Release
# Triggers on version tags (v*) to build and publish releases
on:
push:
@@ -548,9 +549,8 @@ jobs:
if [ "$CHANGELOG_CONTENT" = "NOT_FOUND" ] || [ -z "$CHANGELOG_CONTENT" ]; then
echo "::warning::Version $VERSION not found in CHANGELOG.md, using minimal release notes"
CHANGELOG_CONTENT="Release v$VERSION
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md) for details."
REPO="${{ github.repository }}"
CHANGELOG_CONTENT="Release v$VERSION"$'\n\n'"See [CHANGELOG.md](https://github.com/${REPO}/blob/main/CHANGELOG.md) for details."
fi
echo "✅ Extracted changelog content"
-1
View File
@@ -163,4 +163,3 @@ _bmad-output/
.claude/
/docs
OPUS_ANALYSIS_AND_IDEAS.md
/.github/agents
+5 -5
View File
@@ -24,11 +24,11 @@
<!-- STABLE_DOWNLOADS -->
| Platform | Download |
|----------|----------|
| **Windows** | [Auto-Claude-2.7.2-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.2-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.2-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.2-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.2-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.2/Auto-Claude-2.7.2-linux-amd64.deb) |
| **Windows** | [Auto-Claude-2.7.1-win32-x64.exe](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-win32-x64.exe) |
| **macOS (Apple Silicon)** | [Auto-Claude-2.7.1-darwin-arm64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-arm64.dmg) |
| **macOS (Intel)** | [Auto-Claude-2.7.1-darwin-x64.dmg](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-darwin-x64.dmg) |
| **Linux** | [Auto-Claude-2.7.1-linux-x86_64.AppImage](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-x86_64.AppImage) |
| **Linux (Debian)** | [Auto-Claude-2.7.1-linux-amd64.deb](https://github.com/AndyMik90/Auto-Claude/releases/download/v2.7.1/Auto-Claude-2.7.1-linux-amd64.deb) |
<!-- STABLE_DOWNLOADS_END -->
### Beta Release
+2 -2
View File
@@ -28,8 +28,8 @@ from ui import (
muted,
)
# Configuration - uses shorthand that resolves via API Profile if configured
DEFAULT_MODEL = "opus"
# Configuration
DEFAULT_MODEL = "claude-opus-4-5-20251101"
def setup_environment() -> Path:
-7
View File
@@ -23,15 +23,8 @@ AUTH_TOKEN_ENV_VARS = [
# Environment variables to pass through to SDK subprocess
# NOTE: ANTHROPIC_API_KEY is intentionally excluded to prevent silent API billing
SDK_ENV_VARS = [
# API endpoint configuration
"ANTHROPIC_BASE_URL",
"ANTHROPIC_AUTH_TOKEN",
# Model overrides (from API Profile custom model mappings)
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
# SDK behavior configuration
"NO_PROXY",
"DISABLE_TELEMETRY",
"DISABLE_COST_WARNINGS",
+1 -1
View File
@@ -25,7 +25,7 @@ class IdeationConfigManager:
include_roadmap_context: bool = True,
include_kanban_context: bool = True,
max_ideas_per_type: int = 5,
model: str = "opus",
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
append: bool = False,
+4 -4
View File
@@ -17,7 +17,7 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from client import create_client
from phase_config import get_thinking_budget, resolve_model_id
from phase_config import get_thinking_budget
from ui import print_status
# Ideation types
@@ -56,7 +56,7 @@ class IdeationGenerator:
self,
project_dir: Path,
output_dir: Path,
model: str = "opus",
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
max_ideas_per_type: int = 5,
):
@@ -94,7 +94,7 @@ class IdeationGenerator:
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
self.model,
max_thinking_tokens=self.thinking_budget,
)
@@ -187,7 +187,7 @@ Write the fixed JSON to the file now.
client = create_client(
self.project_dir,
self.output_dir,
resolve_model_id(self.model),
self.model,
max_thinking_tokens=self.thinking_budget,
)
+1 -1
View File
@@ -41,7 +41,7 @@ class IdeationOrchestrator:
include_roadmap_context: bool = True,
include_kanban_context: bool = True,
max_ideas_per_type: int = 5,
model: str = "opus",
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
append: bool = False,
+1 -1
View File
@@ -31,6 +31,6 @@ class IdeationConfig:
include_roadmap_context: bool = True
include_kanban_context: bool = True
max_ideas_per_type: int = 5
model: str = "opus"
model: str = "claude-opus-4-5-20251101"
refresh: bool = False
append: bool = False # If True, preserve existing ideas when merging
+1 -2
View File
@@ -118,7 +118,6 @@ def _create_linear_client() -> ClaudeSDKClient:
get_sdk_env_vars,
require_auth_token,
)
from phase_config import resolve_model_id
require_auth_token() # Raises ValueError if no token found
ensure_claude_code_oauth_token()
@@ -131,7 +130,7 @@ def _create_linear_client() -> ClaudeSDKClient:
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id("haiku"), # Resolves via API Profile if configured
model="claude-haiku-4-5", # Fast & cheap model for simple API calls
system_prompt="You are a Linear API assistant. Execute the requested Linear operation precisely.",
allowed_tools=LINEAR_TOOLS,
mcp_servers={
+2 -20
View File
@@ -7,7 +7,6 @@ Reads configuration from task_metadata.json and provides resolved model IDs.
"""
import json
import os
from pathlib import Path
from typing import Literal, TypedDict
@@ -95,34 +94,17 @@ def resolve_model_id(model: str) -> str:
Resolve a model shorthand (haiku, sonnet, opus) to a full model ID.
If the model is already a full ID, return it unchanged.
Priority:
1. Environment variable override (from API Profile)
2. Hardcoded MODEL_ID_MAP
3. Pass through unchanged (assume full model ID)
Args:
model: Model shorthand or full ID
Returns:
Full Claude model ID
"""
# Check for environment variable override (from API Profile custom model mappings)
# Check if it's a shorthand
if model in MODEL_ID_MAP:
env_var_map = {
"haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL",
"sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL",
"opus": "ANTHROPIC_DEFAULT_OPUS_MODEL",
}
env_var = env_var_map.get(model)
if env_var:
env_value = os.environ.get(env_var)
if env_value:
return env_value
# Fall back to hardcoded mapping
return MODEL_ID_MAP[model]
# Already a full model ID or unknown shorthand
# Already a full model ID
return model
@@ -173,16 +173,12 @@ LANGUAGE_COMMANDS: dict[str, set[str]] = {
"zig",
},
"dart": {
# Core Dart CLI (modern unified tool)
"dart",
"pub",
# Flutter CLI (included in Dart language for SDK detection)
"flutter",
# Legacy commands (deprecated but may exist in older projects)
"dart2js",
"dartanalyzer",
"dartdoc",
"dartfmt",
"pub",
},
}
@@ -33,9 +33,6 @@ PACKAGE_MANAGER_COMMANDS: dict[str, set[str]] = {
"brew": {"brew"},
"apt": {"apt", "apt-get", "dpkg"},
"nix": {"nix", "nix-shell", "nix-build", "nix-env"},
# Dart/Flutter package managers
"pub": {"pub", "dart"},
"melos": {"melos", "dart", "flutter"},
}
@@ -23,8 +23,6 @@ VERSION_MANAGER_COMMANDS: dict[str, set[str]] = {
"rustup": {"rustup"},
"sdkman": {"sdk"},
"jabba": {"jabba"},
# Dart/Flutter version managers
"fvm": {"fvm", "flutter"},
}
-9
View File
@@ -164,12 +164,6 @@ class StackDetector:
if self.parser.file_exists("build.gradle", "build.gradle.kts"):
self.stack.package_managers.append("gradle")
# Dart/Flutter package managers
if self.parser.file_exists("pubspec.yaml", "pubspec.lock"):
self.stack.package_managers.append("pub")
if self.parser.file_exists("melos.yaml"):
self.stack.package_managers.append("melos")
def detect_databases(self) -> None:
"""Detect databases from config files and dependencies."""
# Check for database config files
@@ -364,6 +358,3 @@ class StackDetector:
self.stack.version_managers.append("rbenv")
if self.parser.file_exists("rust-toolchain.toml", "rust-toolchain"):
self.stack.version_managers.append("rustup")
# Flutter Version Manager
if self.parser.file_exists(".fvm", ".fvmrc", "fvm_config.json"):
self.stack.version_managers.append("fvm")
@@ -8,7 +8,6 @@ from typing import Any
try:
from claude_agent_sdk import ClaudeAgentOptions, ClaudeSDKClient
from phase_config import resolve_model_id
CLAUDE_SDK_AVAILABLE = True
except ImportError:
@@ -18,7 +17,7 @@ except ImportError:
class ClaudeAnalysisClient:
"""Wrapper for Claude SDK client with analysis-specific configuration."""
DEFAULT_MODEL = "sonnet" # Shorthand - resolved via API Profile if configured
DEFAULT_MODEL = "claude-sonnet-4-5-20250929"
ALLOWED_TOOLS = ["Read", "Glob", "Grep"]
MAX_TURNS = 50
@@ -111,7 +110,7 @@ class ClaudeAnalysisClient:
return ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id(self.DEFAULT_MODEL), # Resolve via API Profile
model=self.DEFAULT_MODEL,
system_prompt=system_prompt,
allowed_tools=self.ALLOWED_TOOLS,
max_turns=self.MAX_TURNS,
+2 -2
View File
@@ -94,8 +94,8 @@ def main():
parser.add_argument(
"--model",
type=str,
default="opus",
help="Model to use (haiku, sonnet, opus, or full model ID)",
default="claude-opus-4-5-20251101",
help="Model to use (default: claude-opus-4-5-20251101)",
)
parser.add_argument(
"--thinking-level",
+4 -5
View File
@@ -39,7 +39,6 @@ from debug import (
debug_section,
debug_success,
)
from phase_config import resolve_model_id
def load_project_context(project_dir: str) -> str:
@@ -133,7 +132,7 @@ async def run_with_sdk(
project_dir: str,
message: str,
history: list,
model: str = "sonnet", # Shorthand - resolved via API Profile if configured
model: str = "claude-sonnet-4-5-20250929",
thinking_level: str = "medium",
) -> None:
"""Run the chat using Claude SDK with streaming."""
@@ -181,7 +180,7 @@ Current question: {message}"""
# Create Claude SDK client with appropriate settings for insights
client = ClaudeSDKClient(
options=ClaudeAgentOptions(
model=resolve_model_id(model), # Resolve via API Profile if configured
model=model, # Use configured model
system_prompt=system_prompt,
allowed_tools=[
"Read",
@@ -337,8 +336,8 @@ def main():
)
parser.add_argument(
"--model",
default="sonnet",
help="Model to use (haiku, sonnet, opus, or full model ID)",
default="claude-sonnet-4-5-20250929",
help="Claude model ID (default: claude-sonnet-4-5-20250929)",
)
parser.add_argument(
"--thinking-level",
+1 -1
View File
@@ -23,6 +23,6 @@ class RoadmapConfig:
project_dir: Path
output_dir: Path
model: str = "opus"
model: str = "claude-opus-4-5-20251101"
refresh: bool = False # Force regeneration even if roadmap exists
enable_competitor_analysis: bool = False # Enable competitor analysis phase
+1 -1
View File
@@ -27,7 +27,7 @@ class RoadmapOrchestrator:
self,
project_dir: Path,
output_dir: Path | None = None,
model: str = "opus",
model: str = "claude-opus-4-5-20251101",
thinking_level: str = "medium",
refresh: bool = False,
enable_competitor_analysis: bool = False,
+2 -2
View File
@@ -55,8 +55,8 @@ def main():
parser.add_argument(
"--model",
type=str,
default="opus",
help="Model to use (haiku, sonnet, opus, or full model ID)",
default="claude-opus-4-5-20251101",
help="Model to use (default: claude-opus-4-5-20251101)",
)
parser.add_argument(
"--thinking-level",
+1 -1
View File
@@ -16,7 +16,7 @@ from core.simple_client import create_simple_client
async def summarize_phase_output(
phase_name: str,
phase_output: str,
model: str = "sonnet", # Shorthand - resolved via API Profile if configured
model: str = "claude-sonnet-4-5-20250929",
target_words: int = 500,
) -> str:
"""
+2 -3
View File
@@ -57,7 +57,7 @@ class SpecOrchestrator:
spec_name: str | None = None,
spec_dir: Path
| None = None, # Use existing spec directory (for UI integration)
model: str = "sonnet", # Shorthand - resolved via API Profile if configured
model: str = "claude-sonnet-4-5-20250929",
thinking_level: str = "medium", # Thinking level for extended thinking
complexity_override: str | None = None, # Force a specific complexity
use_ai_assessment: bool = True, # Use AI for complexity assessment (vs heuristics)
@@ -173,11 +173,10 @@ class SpecOrchestrator:
return
# Summarize the output
# Use sonnet shorthand - will resolve via API Profile if configured
summary = await summarize_phase_output(
phase_name,
phase_output,
model="sonnet",
model="claude-sonnet-4-5-20250929", # Use Sonnet for efficiency
target_words=500,
)
+2321 -1326
View File
File diff suppressed because it is too large Load Diff
-3
View File
@@ -48,7 +48,6 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.71.2",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -84,7 +83,6 @@
"i18next": "^25.7.3",
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
"proper-lockfile": "^4.1.2",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-i18next": "^16.5.0",
@@ -104,7 +102,6 @@
"@eslint/js": "^9.39.1",
"@playwright/test": "^1.52.0",
"@tailwindcss/postcss": "^4.1.17",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.1.0",
"@types/node": "^25.0.0",
"@types/react": "^19.2.7",
@@ -30,13 +30,9 @@ const mockProcess = Object.assign(new EventEmitter(), {
})
});
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: vi.fn(() => mockProcess)
};
});
vi.mock('child_process', () => ({
spawn: vi.fn(() => mockProcess)
}));
// Mock claude-profile-manager to bypass auth checks in tests
vi.mock('../../main/claude-profile-manager', () => ({
@@ -111,7 +107,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test task description');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test task description');
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
@@ -136,7 +132,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001');
manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001');
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
@@ -158,7 +154,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startQAProcess('task-1', TEST_PROJECT_PATH, 'spec-001');
manager.startQAProcess('task-1', TEST_PROJECT_PATH, 'spec-001');
expect(spawn).toHaveBeenCalledWith(
EXPECTED_PYTHON_COMMAND,
@@ -182,7 +178,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001', {
manager.startTaskExecution('task-1', TEST_PROJECT_PATH, 'spec-001', {
parallel: true,
workers: 4
});
@@ -208,7 +204,7 @@ describe('Subprocess Spawn Integration', () => {
const logHandler = vi.fn();
manager.on('log', logHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate stdout data (must include newline for buffered output processing)
mockStdout.emit('data', Buffer.from('Test log output\n'));
@@ -224,7 +220,7 @@ describe('Subprocess Spawn Integration', () => {
const logHandler = vi.fn();
manager.on('log', logHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate stderr data (must include newline for buffered output processing)
mockStderr.emit('data', Buffer.from('Progress: 50%\n'));
@@ -240,7 +236,7 @@ describe('Subprocess Spawn Integration', () => {
const exitHandler = vi.fn();
manager.on('exit', exitHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate process exit
mockProcess.emit('exit', 0);
@@ -257,7 +253,7 @@ describe('Subprocess Spawn Integration', () => {
const errorHandler = vi.fn();
manager.on('error', errorHandler);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
// Simulate process error
mockProcess.emit('error', new Error('Spawn failed'));
@@ -270,7 +266,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
expect(manager.isRunning('task-1')).toBe(true);
@@ -297,10 +293,10 @@ describe('Subprocess Spawn Integration', () => {
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
expect(manager.getRunningTasks()).toHaveLength(0);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
expect(manager.getRunningTasks()).toContain('task-1');
await manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
expect(manager.getRunningTasks()).toHaveLength(2);
});
@@ -311,7 +307,7 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure('/custom/python3', AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test');
expect(spawn).toHaveBeenCalledWith(
'/custom/python3',
@@ -325,8 +321,8 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
await manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
manager.startTaskExecution('task-2', TEST_PROJECT_PATH, 'spec-001');
await manager.killAll();
@@ -338,10 +334,10 @@ describe('Subprocess Spawn Integration', () => {
const manager = new AgentManager();
manager.configure(undefined, AUTO_CLAUDE_SOURCE);
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 1');
// Start another process for same task
await manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 2');
manager.startSpecCreation('task-1', TEST_PROJECT_PATH, 'Test 2');
// Should have killed the first one
expect(mockProcess.kill).toHaveBeenCalled();
+1 -8
View File
@@ -88,14 +88,7 @@ if (typeof window !== 'undefined') {
success: true,
data: { openProjectIds: [], activeProjectId: null, tabOrder: [] }
}),
saveTabState: vi.fn().mockResolvedValue({ success: true }),
// Profile-related API methods (API Profile feature)
getAPIProfiles: vi.fn(),
saveAPIProfile: vi.fn(),
updateAPIProfile: vi.fn(),
deleteAPIProfile: vi.fn(),
setActiveAPIProfile: vi.fn(),
testConnection: vi.fn()
saveTabState: vi.fn().mockResolvedValue({ success: true })
};
}
@@ -139,8 +139,7 @@ function cleanupTestDirs(): void {
}
}
// Increase timeout for all tests in this file due to dynamic imports and setup overhead
describe('IPC Handlers', { timeout: 15000 }, () => {
describe('IPC Handlers', () => {
let ipcMain: EventEmitter & {
handlers: Map<string, Function>;
invokeHandler: (channel: string, event: unknown, ...args: unknown[]) => Promise<unknown>;
@@ -87,14 +87,14 @@ export class AgentManager extends EventEmitter {
/**
* Start spec creation process
*/
async startSpecCreation(
startSpecCreation(
taskId: string,
projectPath: string,
taskDescription: string,
specDir?: string,
metadata?: SpecCreationMetadata,
baseBranch?: string
): Promise<void> {
): void {
// Pre-flight auth check: Verify active profile has valid authentication
const profileManager = getClaudeProfileManager();
if (!profileManager.hasValidAuth()) {
@@ -156,18 +156,18 @@ export class AgentManager extends EventEmitter {
this.storeTaskContext(taskId, projectPath, '', {}, true, taskDescription, specDir, metadata, baseBranch);
// Note: This is spec-creation but it chains to task-execution via run.py
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
}
/**
* Start task execution (run.py)
*/
async startTaskExecution(
startTaskExecution(
taskId: string,
projectPath: string,
specId: string,
options: TaskExecutionOptions = {}
): Promise<void> {
): void {
// Pre-flight auth check: Verify active profile has valid authentication
const profileManager = getClaudeProfileManager();
if (!profileManager.hasValidAuth()) {
@@ -213,17 +213,17 @@ export class AgentManager extends EventEmitter {
// Store context for potential restart
this.storeTaskContext(taskId, projectPath, specId, options, false);
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'task-execution');
}
/**
* Start QA process
*/
async startQAProcess(
startQAProcess(
taskId: string,
projectPath: string,
specId: string
): Promise<void> {
): void {
const autoBuildSource = this.processManager.getAutoBuildSourcePath();
if (!autoBuildSource) {
@@ -243,7 +243,7 @@ export class AgentManager extends EventEmitter {
const args = [runPath, '--spec', specId, '--project-dir', projectPath, '--qa'];
await this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
this.processManager.spawnProcess(taskId, autoBuildSource, args, combinedEnv, 'qa-process');
}
/**
@@ -1,494 +0,0 @@
/**
* Integration tests for AgentProcessManager
* Tests API profile environment variable injection into spawnProcess
*
* Story 2.3: Env Var Injection - AC1, AC2, AC3, AC4
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { EventEmitter } from 'events';
// Create a mock process object that will be returned by spawn
function createMockProcess() {
return {
stdout: { on: vi.fn() },
stderr: { on: vi.fn() },
on: vi.fn((event: string, callback: any) => {
if (event === 'exit') {
// Simulate immediate exit with code 0
setTimeout(() => callback(0), 10);
}
}),
kill: vi.fn()
};
}
// Mock child_process - must be BEFORE imports of modules that use it
const spawnCalls: Array<{ command: string; args: string[]; options: { env: Record<string, string>; cwd?: string; [key: string]: unknown } }> = [];
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
const mockSpawn = vi.fn((command: string, args: string[], options: { env: Record<string, string>; cwd?: string; [key: string]: unknown }) => {
// Record the call for test assertions
spawnCalls.push({ command, args, options });
return createMockProcess();
});
return {
...actual,
spawn: mockSpawn,
execSync: vi.fn((command: string) => {
if (command.includes('git')) {
return '/fake/path';
}
return '';
})
};
});
// Mock project-initializer to avoid child_process.execSync issues
vi.mock('../project-initializer', () => ({
getAutoBuildPath: vi.fn(() => '/fake/auto-build'),
isInitialized: vi.fn(() => true),
initializeProject: vi.fn(),
getProjectStorePath: vi.fn(() => '/fake/store/path')
}));
// Mock project-store BEFORE agent-process imports it
vi.mock('../project-store', () => ({
projectStore: {
getProject: vi.fn(),
listProjects: vi.fn(),
createProject: vi.fn(),
updateProject: vi.fn(),
deleteProject: vi.fn(),
getProjectSettings: vi.fn(),
updateProjectSettings: vi.fn()
}
}));
// Mock claude-profile-manager
vi.mock('../claude-profile-manager', () => ({
getClaudeProfileManager: vi.fn(() => ({
getProfilePath: vi.fn(() => '/fake/profile/path'),
ensureProfileDir: vi.fn(),
readProfile: vi.fn(),
writeProfile: vi.fn(),
deleteProfile: vi.fn()
}))
}));
// Mock dependencies
vi.mock('../services/profile', () => ({
getAPIProfileEnv: vi.fn()
}));
vi.mock('../rate-limit-detector', () => ({
getProfileEnv: vi.fn(() => ({})),
detectRateLimit: vi.fn(() => ({ isRateLimited: false })),
createSDKRateLimitInfo: vi.fn(),
detectAuthFailure: vi.fn(() => ({ isAuthFailure: false }))
}));
vi.mock('../python-detector', () => ({
findPythonCommand: vi.fn(() => 'python'),
parsePythonCommand: vi.fn(() => ['python', []])
}));
vi.mock('electron', () => ({
app: {
getAppPath: vi.fn(() => '/fake/app/path')
}
}));
// Import AFTER all mocks are set up
import { AgentProcessManager } from './agent-process';
import { AgentState } from './agent-state';
import { AgentEvents } from './agent-events';
import * as profileService from '../services/profile';
import * as rateLimitDetector from '../rate-limit-detector';
describe('AgentProcessManager - API Profile Env Injection (Story 2.3)', () => {
let processManager: AgentProcessManager;
let state: AgentState;
let events: AgentEvents;
let emitter: EventEmitter;
beforeEach(() => {
// Reset all mocks and spawn calls
vi.clearAllMocks();
spawnCalls.length = 0;
// Clear environment variables that could interfere with tests
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.CLAUDE_CODE_OAUTH_TOKEN;
// Initialize components
state = new AgentState();
events = new AgentEvents();
emitter = new EventEmitter();
processManager = new AgentProcessManager(state, events, emitter);
});
afterEach(() => {
processManager.killAllProcesses();
});
describe('AC1: API Profile Env Var Injection', () => {
it('should inject ANTHROPIC_BASE_URL when active profile has baseUrl', async () => {
const mockApiProfileEnv = {
ANTHROPIC_BASE_URL: 'https://custom.api.com',
ANTHROPIC_AUTH_TOKEN: 'sk-test-key'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
expect(spawnCalls).toHaveLength(1);
expect(spawnCalls[0].command).toBe('python');
expect(spawnCalls[0].args).toContain('run.py');
expect(spawnCalls[0].options.env).toMatchObject({
ANTHROPIC_BASE_URL: 'https://custom.api.com',
ANTHROPIC_AUTH_TOKEN: 'sk-test-key'
});
});
it('should inject ANTHROPIC_AUTH_TOKEN when active profile has apiKey', async () => {
const mockApiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-custom-key-12345678'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
expect(spawnCalls).toHaveLength(1);
expect(spawnCalls[0].options.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-custom-key-12345678');
});
it('should inject model env vars when active profile has models configured', async () => {
const mockApiProfileEnv = {
ANTHROPIC_MODEL: 'claude-3-5-sonnet-20241022',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-3-5-haiku-20241022',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-3-5-sonnet-20241022',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-3-5-opus-20241022'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
expect(spawnCalls).toHaveLength(1);
expect(spawnCalls[0].options.env).toMatchObject({
ANTHROPIC_MODEL: 'claude-3-5-sonnet-20241022',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-3-5-haiku-20241022',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-3-5-sonnet-20241022',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-3-5-opus-20241022'
});
});
it('should give API profile env vars highest precedence over extraEnv', async () => {
const extraEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-extra-token',
ANTHROPIC_BASE_URL: 'https://extra.com'
};
const mockApiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-profile-token',
ANTHROPIC_BASE_URL: 'https://profile.com'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], extraEnv, 'task-execution');
expect(spawnCalls).toHaveLength(1);
// API profile should override extraEnv
expect(spawnCalls[0].options.env.ANTHROPIC_AUTH_TOKEN).toBe('sk-profile-token');
expect(spawnCalls[0].options.env.ANTHROPIC_BASE_URL).toBe('https://profile.com');
});
});
describe('AC2: OAuth Mode (No Active Profile)', () => {
let originalEnv: NodeJS.ProcessEnv;
beforeEach(() => {
// Save original environment before each test
originalEnv = { ...process.env };
});
afterEach(() => {
// Restore original environment after each test
process.env = originalEnv;
});
it('should NOT set ANTHROPIC_AUTH_TOKEN when no active profile (OAuth mode)', async () => {
// Return empty object = OAuth mode
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
// Set OAuth token via getProfileEnv (existing flow)
vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-123'
});
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
expect(spawnCalls).toHaveLength(1);
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
expect(envArg.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token-123');
// OAuth mode clears ANTHROPIC_AUTH_TOKEN with empty string (not undefined)
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('');
});
it('should return empty object from getAPIProfileEnv when activeProfileId is null', async () => {
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
const result = await profileService.getAPIProfileEnv();
expect(result).toEqual({});
});
it('should clear stale ANTHROPIC_AUTH_TOKEN from process.env when switching to OAuth mode', async () => {
// Simulate process.env having stale ANTHROPIC_* vars from previous session
process.env = {
...originalEnv,
ANTHROPIC_AUTH_TOKEN: 'stale-token-from-env',
ANTHROPIC_BASE_URL: 'https://stale.example.com'
};
// OAuth mode - no active API profile
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
// Set OAuth token
vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-456'
});
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// OAuth token should be present
expect(envArg.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token-456');
// Stale ANTHROPIC_* vars should be cleared (empty string overrides process.env)
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('');
expect(envArg.ANTHROPIC_BASE_URL).toBe('');
});
it('should clear stale ANTHROPIC_BASE_URL when switching to OAuth mode', async () => {
process.env = {
...originalEnv,
ANTHROPIC_BASE_URL: 'https://old-custom-endpoint.com'
};
// OAuth mode
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue({
CLAUDE_CODE_OAUTH_TOKEN: 'oauth-token-789'
});
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// Should clear the base URL (so Python uses default api.anthropic.com)
expect(envArg.ANTHROPIC_BASE_URL).toBe('');
expect(envArg.CLAUDE_CODE_OAUTH_TOKEN).toBe('oauth-token-789');
});
it('should NOT clear ANTHROPIC_* vars when API Profile is active', async () => {
process.env = {
...originalEnv,
ANTHROPIC_AUTH_TOKEN: 'old-token-in-env'
};
// API Profile mode - active profile
const mockApiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-profile-active',
ANTHROPIC_BASE_URL: 'https://active-profile.com'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// Should use API profile vars, NOT clear them
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('sk-profile-active');
expect(envArg.ANTHROPIC_BASE_URL).toBe('https://active-profile.com');
});
});
describe('AC4: No API Key Logging', () => {
it('should never log full API keys in spawn env vars', async () => {
const mockApiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-sensitive-api-key-12345678',
ANTHROPIC_BASE_URL: 'https://api.example.com'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
// Mock ALL console methods to capture any debug/error output
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {});
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
// Get the env object passed to spawn
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// Verify the full API key is in the env (for Python subprocess)
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('sk-sensitive-api-key-12345678');
// Collect ALL console output from all methods
const allLogCalls = [
...consoleLogSpy.mock.calls,
...consoleErrorSpy.mock.calls,
...consoleWarnSpy.mock.calls,
...consoleDebugSpy.mock.calls
].flatMap(call => call.map(String));
const logString = JSON.stringify(allLogCalls);
// The full API key should NOT appear in any logs (AC4 compliance)
expect(logString).not.toContain('sk-sensitive-api-key-12345678');
// Restore all spies
consoleLogSpy.mockRestore();
consoleErrorSpy.mockRestore();
consoleWarnSpy.mockRestore();
consoleDebugSpy.mockRestore();
});
it('should not log API key even in error scenarios', async () => {
const mockApiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-secret-key-for-error-test',
ANTHROPIC_BASE_URL: 'https://api.example.com'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(mockApiProfileEnv);
// Mock console methods
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
// Collect all error and log output
const allOutput = [
...consoleErrorSpy.mock.calls,
...consoleLogSpy.mock.calls
].flatMap(call => call.map(arg => typeof arg === 'object' ? JSON.stringify(arg) : String(arg)));
const outputString = allOutput.join(' ');
// Verify API key is never exposed in logs
expect(outputString).not.toContain('sk-secret-key-for-error-test');
consoleErrorSpy.mockRestore();
consoleLogSpy.mockRestore();
});
});
describe('AC3: Profile Switching Between Builds', () => {
it('should allow different profiles for different spawn calls', async () => {
// First spawn with Profile A
const profileAEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-profile-a',
ANTHROPIC_BASE_URL: 'https://api-a.com'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValueOnce(profileAEnv);
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
const firstEnv = spawnCalls[0].options.env as Record<string, unknown>;
expect(firstEnv.ANTHROPIC_AUTH_TOKEN).toBe('sk-profile-a');
// Second spawn with Profile B (user switched active profile)
const profileBEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-profile-b',
ANTHROPIC_BASE_URL: 'https://api-b.com'
};
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValueOnce(profileBEnv);
await processManager.spawnProcess('task-2', '/fake/cwd', ['run.py'], {}, 'task-execution');
const secondEnv = spawnCalls[1].options.env as Record<string, unknown>;
expect(secondEnv.ANTHROPIC_AUTH_TOKEN).toBe('sk-profile-b');
// Verify first spawn's env is NOT affected by second spawn
expect(firstEnv.ANTHROPIC_AUTH_TOKEN).toBe('sk-profile-a');
});
});
describe('Integration: Combined env precedence', () => {
it('should merge env vars in correct precedence order', async () => {
const extraEnv = {
CUSTOM_VAR: 'from-extra'
};
const profileEnv = {
CLAUDE_CONFIG_DIR: '/custom/config'
};
const apiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-api-profile',
ANTHROPIC_BASE_URL: 'https://api-profile.com'
};
vi.mocked(rateLimitDetector.getProfileEnv).mockReturnValue(profileEnv);
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue(apiProfileEnv);
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], extraEnv, 'task-execution');
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// Verify all sources are included
expect(envArg.CUSTOM_VAR).toBe('from-extra'); // From extraEnv
expect(envArg.CLAUDE_CONFIG_DIR).toBe('/custom/config'); // From profileEnv
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('sk-api-profile'); // From apiProfileEnv (highest for ANTHROPIC_*)
// Verify standard Python env vars
expect(envArg.PYTHONUNBUFFERED).toBe('1');
expect(envArg.PYTHONIOENCODING).toBe('utf-8');
expect(envArg.PYTHONUTF8).toBe('1');
});
it('should call getOAuthModeClearVars and apply clearing when in OAuth mode', async () => {
// OAuth mode - empty API profile
vi.mocked(profileService.getAPIProfileEnv).mockResolvedValue({});
await processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution');
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// Verify clearing vars are applied (empty strings for ANTHROPIC_* vars)
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('');
expect(envArg.ANTHROPIC_BASE_URL).toBe('');
expect(envArg.ANTHROPIC_MODEL).toBe('');
expect(envArg.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('');
expect(envArg.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('');
expect(envArg.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('');
});
it('should handle getAPIProfileEnv errors gracefully', async () => {
// Simulate service error
vi.mocked(profileService.getAPIProfileEnv).mockRejectedValue(new Error('Service unavailable'));
// Should not throw - should fall back to OAuth mode
await expect(
processManager.spawnProcess('task-1', '/fake/cwd', ['run.py'], {}, 'task-execution')
).resolves.not.toThrow();
const envArg = spawnCalls[0].options.env as Record<string, unknown>;
// Should have clearing vars (falls back to OAuth mode on error)
expect(envArg.ANTHROPIC_AUTH_TOKEN).toBe('');
expect(envArg.ANTHROPIC_BASE_URL).toBe('');
});
});
});
+4 -23
View File
@@ -7,7 +7,6 @@ import { AgentState } from './agent-state';
import { AgentEvents } from './agent-events';
import { ProcessType, ExecutionProgressData } from './types';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv, detectAuthFailure } from '../rate-limit-detector';
import { getAPIProfileEnv } from '../services/profile';
import { projectStore } from '../project-store';
import { getClaudeProfileManager } from '../claude-profile-manager';
import { parsePythonCommand, validatePythonPath } from '../python-detector';
@@ -15,7 +14,6 @@ import { pythonEnvManager, getConfiguredPythonPath } from '../python-env-manager
import { buildMemoryEnvVars } from '../memory-env-builder';
import { readSettingsFile } from '../settings-utils';
import type { AppSettings } from '../../shared/types/settings';
import { getOAuthModeClearVars } from './env-utils';
/**
* Process spawning and lifecycle management
@@ -337,16 +335,13 @@ export class AgentProcessManager {
}
}
/**
* Spawn a Python process for task execution
*/
async spawnProcess(
spawnProcess(
taskId: string,
cwd: string,
args: string[],
extraEnv: Record<string, string> = {},
processType: ProcessType = 'task-execution'
): Promise<void> {
): void {
const isSpecRunner = processType === 'spec-creation';
this.killProcess(taskId);
@@ -356,27 +351,13 @@ export class AgentProcessManager {
// Get Python environment (PYTHONPATH for bundled packages, etc.)
const pythonEnv = pythonEnvManager.getPythonEnv();
// Get active API profile environment variables
let apiProfileEnv: Record<string, string> = {};
try {
apiProfileEnv = await getAPIProfileEnv();
} catch (error) {
console.error('[Agent Process] Failed to get API profile env:', error);
// Continue with empty profile env (falls back to OAuth mode)
}
// Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode)
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
// Parse Python commandto handle space-separated commands like "py -3"
// Parse Python command to handle space-separated commands like "py -3"
const [pythonCommand, pythonBaseArgs] = parsePythonCommand(this.getPythonPath());
const childProcess = spawn(pythonCommand, [...pythonBaseArgs, ...args], {
cwd,
env: {
...env, // Already includes process.env, extraEnv, profileEnv, PYTHONUNBUFFERED, PYTHONUTF8
...pythonEnv, // Include Python environment (PYTHONPATH for bundled packages)
...oauthModeClearVars, // Clear stale ANTHROPIC_* vars when in OAuth mode
...apiProfileEnv // Include active API profile config (highest priority for ANTHROPIC_* vars)
...pythonEnv // Include Python environment (PYTHONPATH for bundled packages)
}
});
+24 -42
View File
@@ -9,8 +9,6 @@ import { RoadmapConfig } from './types';
import type { IdeationConfig, Idea } from '../../shared/types';
import { MODEL_ID_MAP } from '../../shared/constants';
import { detectRateLimit, createSDKRateLimitInfo, getProfileEnv } from '../rate-limit-detector';
import { getAPIProfileEnv } from '../services/profile';
import { getOAuthModeClearVars } from './env-utils';
import { debugLog, debugError } from '../../shared/utils/debug-logger';
import { parsePythonCommand } from '../python-detector';
import { pythonEnvManager } from '../python-env-manager';
@@ -46,14 +44,14 @@ export class AgentQueueManager {
* This allows refreshing competitor data independently of the general roadmap refresh.
* Use when user explicitly wants new competitor research.
*/
async startRoadmapGeneration(
startRoadmapGeneration(
projectId: string,
projectPath: string,
refresh: boolean = false,
enableCompetitorAnalysis: boolean = false,
refreshCompetitorAnalysis: boolean = false,
config?: RoadmapConfig
): Promise<void> {
): void {
debugLog('[Agent Queue] Starting roadmap generation:', {
projectId,
projectPath,
@@ -107,18 +105,18 @@ export class AgentQueueManager {
debugLog('[Agent Queue] Spawning roadmap process with args:', args);
// Use projectId as taskId for roadmap operations
await this.spawnRoadmapProcess(projectId, projectPath, args);
this.spawnRoadmapProcess(projectId, projectPath, args);
}
/**
* Start ideation generation process
*/
async startIdeationGeneration(
startIdeationGeneration(
projectId: string,
projectPath: string,
config: IdeationConfig,
refresh: boolean = false
): Promise<void> {
): void {
debugLog('[Agent Queue] Starting ideation generation:', {
projectId,
projectPath,
@@ -183,17 +181,17 @@ export class AgentQueueManager {
debugLog('[Agent Queue] Spawning ideation process with args:', args);
// Use projectId as taskId for ideation operations
await this.spawnIdeationProcess(projectId, projectPath, args);
this.spawnIdeationProcess(projectId, projectPath, args);
}
/**
* Spawn a Python process for ideation generation
*/
private async spawnIdeationProcess(
private spawnIdeationProcess(
projectId: string,
projectPath: string,
args: string[]
): Promise<void> {
): void {
debugLog('[Agent Queue] Spawning ideation process:', { projectId, projectPath });
// Kill existing process for this project if any
@@ -216,12 +214,6 @@ export class AgentQueueManager {
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
const profileEnv = getProfileEnv();
// Get active API profile environment variables
const apiProfileEnv = await getAPIProfileEnv();
// Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode)
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
// Get Python path from process manager (uses venv if configured)
const pythonPath = this.processManager.getPythonPath();
@@ -242,30 +234,28 @@ export class AgentQueueManager {
// 1. process.env (system)
// 2. pythonEnv (bundled packages environment)
// 3. combinedEnv (auto-claude/.env for CLI usage)
// 4. oauthModeClearVars (clear stale ANTHROPIC_* vars when in OAuth mode)
// 5. profileEnv (Electron app OAuth token)
// 6. apiProfileEnv (Active API profile config - highest priority for ANTHROPIC_* vars)
// 7. Our specific overrides
// 4. profileEnv (Electron app OAuth token - highest priority)
// 5. Our specific overrides
const finalEnv = {
...process.env,
...pythonEnv,
...combinedEnv,
...oauthModeClearVars,
...profileEnv,
...apiProfileEnv,
PYTHONPATH: combinedPythonPath,
PYTHONUNBUFFERED: '1',
PYTHONUTF8: '1'
};
// Debug: Show OAuth token source (token values intentionally omitted for security - AC4)
// Debug: Show OAuth token source
const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN']
? 'Electron app profile'
: (combinedEnv['CLAUDE_CODE_OAUTH_TOKEN'] ? 'auto-claude/.env' : 'not found');
const hasToken = !!(finalEnv as Record<string, string | undefined>)['CLAUDE_CODE_OAUTH_TOKEN'];
const oauthToken = (finalEnv as Record<string, string | undefined>)['CLAUDE_CODE_OAUTH_TOKEN'];
const hasToken = !!oauthToken;
debugLog('[Agent Queue] OAuth token status:', {
source: tokenSource,
hasToken
hasToken,
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
});
// Parse Python command to handle space-separated commands like "py -3"
@@ -510,11 +500,11 @@ export class AgentQueueManager {
/**
* Spawn a Python process for roadmap generation
*/
private async spawnRoadmapProcess(
private spawnRoadmapProcess(
projectId: string,
projectPath: string,
args: string[]
): Promise<void> {
): void {
debugLog('[Agent Queue] Spawning roadmap process:', { projectId, projectPath });
// Kill existing process for this project if any
@@ -537,12 +527,6 @@ export class AgentQueueManager {
// Get active Claude profile environment (CLAUDE_CODE_OAUTH_TOKEN if not default)
const profileEnv = getProfileEnv();
// Get active API profile environment variables
const apiProfileEnv = await getAPIProfileEnv();
// Get OAuth mode clearing vars (clears stale ANTHROPIC_* vars when in OAuth mode)
const oauthModeClearVars = getOAuthModeClearVars(apiProfileEnv);
// Get Python path from process manager (uses venv if configured)
const pythonPath = this.processManager.getPythonPath();
@@ -563,30 +547,28 @@ export class AgentQueueManager {
// 1. process.env (system)
// 2. pythonEnv (bundled packages environment)
// 3. combinedEnv (auto-claude/.env for CLI usage)
// 4. oauthModeClearVars (clear stale ANTHROPIC_* vars when in OAuth mode)
// 5. profileEnv (Electron app OAuth token)
// 6. apiProfileEnv (Active API profile config - highest priority for ANTHROPIC_* vars)
// 7. Our specific overrides
// 4. profileEnv (Electron app OAuth token - highest priority)
// 5. Our specific overrides
const finalEnv = {
...process.env,
...pythonEnv,
...combinedEnv,
...oauthModeClearVars,
...profileEnv,
...apiProfileEnv,
PYTHONPATH: combinedPythonPath,
PYTHONUNBUFFERED: '1',
PYTHONUTF8: '1'
};
// Debug: Show OAuth token source (token values intentionally omitted for security - AC4)
// Debug: Show OAuth token source
const tokenSource = profileEnv['CLAUDE_CODE_OAUTH_TOKEN']
? 'Electron app profile'
: (combinedEnv['CLAUDE_CODE_OAUTH_TOKEN'] ? 'auto-claude/.env' : 'not found');
const hasToken = !!(finalEnv as Record<string, string | undefined>)['CLAUDE_CODE_OAUTH_TOKEN'];
const oauthToken = (finalEnv as Record<string, string | undefined>)['CLAUDE_CODE_OAUTH_TOKEN'];
const hasToken = !!oauthToken;
debugLog('[Agent Queue] OAuth token status:', {
source: tokenSource,
hasToken
hasToken,
tokenPreview: hasToken ? oauthToken?.substring(0, 20) + '...' : 'none'
});
// Parse Python command to handle space-separated commands like "py -3"
@@ -1,129 +0,0 @@
/**
* Unit tests for env-utils
* Tests OAuth mode environment variable clearing functionality
*/
import { describe, it, expect } from 'vitest';
import { getOAuthModeClearVars } from './env-utils';
describe('getOAuthModeClearVars', () => {
describe('OAuth mode (no active API profile)', () => {
it('should return clearing vars when apiProfileEnv is empty', () => {
const result = getOAuthModeClearVars({});
expect(result).toEqual({
ANTHROPIC_AUTH_TOKEN: '',
ANTHROPIC_BASE_URL: '',
ANTHROPIC_MODEL: '',
ANTHROPIC_DEFAULT_HAIKU_MODEL: '',
ANTHROPIC_DEFAULT_SONNET_MODEL: '',
ANTHROPIC_DEFAULT_OPUS_MODEL: ''
});
});
it('should clear all ANTHROPIC_* environment variables', () => {
const result = getOAuthModeClearVars({});
// Verify all known ANTHROPIC_* vars are cleared
expect(result.ANTHROPIC_AUTH_TOKEN).toBe('');
expect(result.ANTHROPIC_BASE_URL).toBe('');
expect(result.ANTHROPIC_MODEL).toBe('');
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('');
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('');
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('');
});
});
describe('API Profile mode (active profile)', () => {
it('should return empty object when apiProfileEnv has values', () => {
const apiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-active-profile',
ANTHROPIC_BASE_URL: 'https://custom.api.com'
};
const result = getOAuthModeClearVars(apiProfileEnv);
expect(result).toEqual({});
});
it('should NOT clear vars when API profile is active', () => {
const apiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-test',
ANTHROPIC_BASE_URL: 'https://test.com',
ANTHROPIC_MODEL: 'claude-3-opus'
};
const result = getOAuthModeClearVars(apiProfileEnv);
// Should not return any clearing vars
expect(Object.keys(result)).toHaveLength(0);
});
it('should detect non-empty profile even with single property', () => {
const apiProfileEnv = {
ANTHROPIC_AUTH_TOKEN: 'sk-minimal'
};
const result = getOAuthModeClearVars(apiProfileEnv);
expect(result).toEqual({});
});
});
describe('Edge cases', () => {
it('should handle undefined gracefully (treat as empty)', () => {
// TypeScript should prevent this, but runtime safety
const result = getOAuthModeClearVars(undefined as any);
// Should treat undefined as empty object -> OAuth mode
expect(result).toBeDefined();
});
it('should handle null gracefully (treat as empty)', () => {
// Runtime safety for null values
const result = getOAuthModeClearVars(null as any);
// Should treat null as OAuth mode and return clearing vars
expect(result).toEqual({
ANTHROPIC_AUTH_TOKEN: '',
ANTHROPIC_BASE_URL: '',
ANTHROPIC_MODEL: '',
ANTHROPIC_DEFAULT_HAIKU_MODEL: '',
ANTHROPIC_DEFAULT_SONNET_MODEL: '',
ANTHROPIC_DEFAULT_OPUS_MODEL: ''
});
});
it('should return consistent object shape for OAuth mode', () => {
const result1 = getOAuthModeClearVars({});
const result2 = getOAuthModeClearVars({});
expect(result1).toEqual(result2);
// Use specific expected keys instead of magic number
const expectedKeys = [
'ANTHROPIC_AUTH_TOKEN',
'ANTHROPIC_BASE_URL',
'ANTHROPIC_MODEL',
'ANTHROPIC_DEFAULT_HAIKU_MODEL',
'ANTHROPIC_DEFAULT_SONNET_MODEL',
'ANTHROPIC_DEFAULT_OPUS_MODEL'
];
expect(Object.keys(result1).sort()).toEqual(expectedKeys.sort());
});
it('should NOT clear if apiProfileEnv has non-ANTHROPIC keys only', () => {
// Edge case: service returns metadata but no ANTHROPIC_* vars
const result = getOAuthModeClearVars({ SOME_OTHER_VAR: 'value' });
// Should treat as OAuth mode since no ANTHROPIC_* keys present
expect(result).toEqual({
ANTHROPIC_AUTH_TOKEN: '',
ANTHROPIC_BASE_URL: '',
ANTHROPIC_MODEL: '',
ANTHROPIC_DEFAULT_HAIKU_MODEL: '',
ANTHROPIC_DEFAULT_SONNET_MODEL: '',
ANTHROPIC_DEFAULT_OPUS_MODEL: ''
});
});
});
});
-39
View File
@@ -1,39 +0,0 @@
/**
* Utility functions for managing environment variables in agent spawning
*/
/**
* Get environment variables to clear ANTHROPIC_* vars when in OAuth mode
*
* When switching from API Profile mode to OAuth mode, residual ANTHROPIC_*
* environment variables from process.env can cause authentication failures.
* This function returns an object with empty strings for these vars when
* no API profile is active, ensuring OAuth tokens are used correctly.
*
* **Why empty strings?** Setting environment variables to empty strings (rather than
* undefined) ensures they override any stale values from process.env. Python's SDK
* treats empty strings as falsy in conditional checks like `if token:`, so empty
* strings effectively disable these authentication parameters without leaving
* undefined values that might be ignored during object spreading.
*
* @param apiProfileEnv - Environment variables from getAPIProfileEnv()
* @returns Object with empty ANTHROPIC_* vars if in OAuth mode, empty object otherwise
*/
export function getOAuthModeClearVars(apiProfileEnv: Record<string, string>): Record<string, string> {
// If API profile is active (has ANTHROPIC_* vars), don't clear anything
if (apiProfileEnv && Object.keys(apiProfileEnv).some(key => key.startsWith('ANTHROPIC_'))) {
return {};
}
// In OAuth mode (no API profile), clear all ANTHROPIC_* vars
// Setting to empty string ensures they override any values from process.env
// Python's `if token:` checks treat empty strings as falsy
return {
ANTHROPIC_AUTH_TOKEN: '',
ANTHROPIC_BASE_URL: '',
ANTHROPIC_MODEL: '',
ANTHROPIC_DEFAULT_HAIKU_MODEL: '',
ANTHROPIC_DEFAULT_SONNET_MODEL: '',
ANTHROPIC_DEFAULT_OPUS_MODEL: ''
};
}
@@ -10,15 +10,11 @@ const mockSpawn = vi.fn();
const mockExecSync = vi.fn();
const mockExecFileSync = vi.fn();
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: (...args: unknown[]) => mockSpawn(...args),
execSync: (...args: unknown[]) => mockExecSync(...args),
execFileSync: (...args: unknown[]) => mockExecFileSync(...args)
};
});
vi.mock('child_process', () => ({
spawn: (...args: unknown[]) => mockSpawn(...args),
execSync: (...args: unknown[]) => mockExecSync(...args),
execFileSync: (...args: unknown[]) => mockExecFileSync(...args)
}));
// Mock shell.openExternal
const mockOpenExternal = vi.fn();
@@ -86,13 +82,6 @@ vi.mock('../../../env-utils', () => ({
isCommandAvailable: vi.fn((cmd: string) => mockFindExecutable(cmd) !== null)
}));
// Mock cli-tool-manager to avoid child_process import issues
vi.mock('../../../cli-tool-manager', () => ({
getToolPath: vi.fn(() => '/usr/local/bin/gh'),
detectCLITools: vi.fn(),
getAllToolStatus: vi.fn()
}));
// Create mock process for spawn
function createMockProcess(): EventEmitter & {
stdout: EventEmitter | null;
@@ -4,15 +4,11 @@ import { runPythonSubprocess } from './subprocess-runner';
import * as childProcess from 'child_process';
import EventEmitter from 'events';
// Mock child_process with importOriginal to preserve all exports
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('child_process')>();
return {
...actual,
spawn: vi.fn(),
exec: vi.fn(),
};
});
// Mock child_process.spawn
vi.mock('child_process', () => ({
spawn: vi.fn(),
exec: vi.fn(),
}));
// Mock parsePythonCommand
vi.mock('../../../python-detector', () => ({
+1 -6
View File
@@ -32,7 +32,6 @@ import { registerAppUpdateHandlers } from './app-update-handlers';
import { registerDebugHandlers } from './debug-handlers';
import { registerClaudeCodeHandlers } from './claude-code-handlers';
import { registerMcpHandlers } from './mcp-handlers';
import { registerProfileHandlers } from './profile-handlers';
import { notificationService } from '../notification-service';
/**
@@ -115,9 +114,6 @@ export function setupIpcHandlers(
// MCP server health check handlers
registerMcpHandlers();
// API Profile handlers (custom Anthropic-compatible endpoints)
registerProfileHandlers();
console.warn('[IPC] All handler modules registered successfully');
}
@@ -143,6 +139,5 @@ export {
registerAppUpdateHandlers,
registerDebugHandlers,
registerClaudeCodeHandlers,
registerMcpHandlers,
registerProfileHandlers
registerMcpHandlers
};
@@ -1,341 +0,0 @@
/**
* Tests for profile IPC handlers
*
* Tests profiles:set-active handler with support for:
* - Setting valid profile as active
* - Switching to OAuth (null profileId)
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { APIProfile, ProfilesFile } from '@shared/types/profile';
// Hoist mocked functions to avoid circular dependency in atomicModifyProfiles
const { mockedLoadProfilesFile, mockedSaveProfilesFile } = vi.hoisted(() => ({
mockedLoadProfilesFile: vi.fn(),
mockedSaveProfilesFile: vi.fn()
}));
// Mock electron before importing
vi.mock('electron', () => ({
ipcMain: {
handle: vi.fn(),
on: vi.fn()
}
}));
// Mock profile service
vi.mock('../services/profile', () => ({
loadProfilesFile: mockedLoadProfilesFile,
saveProfilesFile: mockedSaveProfilesFile,
validateFilePermissions: vi.fn(),
getProfilesFilePath: vi.fn(() => '/test/profiles.json'),
createProfile: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: vi.fn(),
testConnection: vi.fn(),
discoverModels: vi.fn(),
atomicModifyProfiles: vi.fn(async (modifier: (file: unknown) => unknown) => {
const file = await mockedLoadProfilesFile();
const modified = modifier(file);
await mockedSaveProfilesFile(modified as never);
return modified;
})
}));
import { registerProfileHandlers } from './profile-handlers';
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../shared/constants';
import {
loadProfilesFile,
saveProfilesFile,
validateFilePermissions,
testConnection
} from '../services/profile';
import type { TestConnectionResult } from '@shared/types/profile';
// Get the handler function for testing
function getSetActiveHandler() {
const calls = (ipcMain.handle as unknown as ReturnType<typeof vi.fn>).mock.calls;
const setActiveCall = calls.find(
(call) => call[0] === IPC_CHANNELS.PROFILES_SET_ACTIVE
);
return setActiveCall?.[1];
}
// Get the testConnection handler function for testing
function getTestConnectionHandler() {
const calls = (ipcMain.handle as unknown as ReturnType<typeof vi.fn>).mock.calls;
const testConnectionCall = calls.find(
(call) => call[0] === IPC_CHANNELS.PROFILES_TEST_CONNECTION
);
return testConnectionCall?.[1];
}
describe('profile-handlers - setActiveProfile', () => {
beforeEach(() => {
vi.clearAllMocks();
registerProfileHandlers();
});
const mockProfiles: APIProfile[] = [
{
id: 'profile-1',
name: 'Test Profile 1',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key-1',
createdAt: Date.now(),
updatedAt: Date.now()
},
{
id: 'profile-2',
name: 'Test Profile 2',
baseUrl: 'https://custom.api.com',
apiKey: 'sk-custom-key-2',
createdAt: Date.now(),
updatedAt: Date.now()
}
];
describe('setting valid profile as active', () => {
it('should set active profile with valid profileId', async () => {
const mockFile: ProfilesFile = {
profiles: mockProfiles,
activeProfileId: null,
version: 1
};
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
vi.mocked(saveProfilesFile).mockResolvedValue(undefined);
vi.mocked(validateFilePermissions).mockResolvedValue(true);
const handler = getSetActiveHandler();
const result = await handler({}, 'profile-1');
expect(result).toEqual({ success: true });
expect(saveProfilesFile).toHaveBeenCalledWith(
expect.objectContaining({
activeProfileId: 'profile-1'
})
);
});
it('should return error for non-existent profile', async () => {
const mockFile: ProfilesFile = {
profiles: mockProfiles,
activeProfileId: null,
version: 1
};
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const handler = getSetActiveHandler();
const result = await handler({}, 'non-existent-id');
expect(result).toEqual({
success: false,
error: 'Profile not found'
});
});
});
describe('switching to OAuth (null profileId)', () => {
it('should accept null profileId to switch to OAuth', async () => {
const mockFile: ProfilesFile = {
profiles: mockProfiles,
activeProfileId: 'profile-1',
version: 1
};
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
vi.mocked(saveProfilesFile).mockResolvedValue(undefined);
vi.mocked(validateFilePermissions).mockResolvedValue(true);
const handler = getSetActiveHandler();
const result = await handler({}, null);
// Should succeed and clear activeProfileId
expect(result).toEqual({ success: true });
expect(saveProfilesFile).toHaveBeenCalledWith(
expect.objectContaining({
activeProfileId: null
})
);
});
it('should handle null when no profile was active', async () => {
const mockFile: ProfilesFile = {
profiles: mockProfiles,
activeProfileId: null,
version: 1
};
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
vi.mocked(saveProfilesFile).mockResolvedValue(undefined);
vi.mocked(validateFilePermissions).mockResolvedValue(true);
const handler = getSetActiveHandler();
const result = await handler({}, null);
// Should succeed (idempotent operation)
expect(result).toEqual({ success: true });
expect(saveProfilesFile).toHaveBeenCalled();
});
});
describe('error handling', () => {
it('should handle loadProfilesFile errors', async () => {
vi.mocked(loadProfilesFile).mockRejectedValue(
new Error('Failed to load profiles')
);
const handler = getSetActiveHandler();
const result = await handler({}, 'profile-1');
expect(result).toEqual({
success: false,
error: 'Failed to load profiles'
});
});
it('should handle saveProfilesFile errors', async () => {
const mockFile: ProfilesFile = {
profiles: mockProfiles,
activeProfileId: null,
version: 1
};
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
vi.mocked(saveProfilesFile).mockRejectedValue(
new Error('Failed to save')
);
const handler = getSetActiveHandler();
const result = await handler({}, 'profile-1');
expect(result).toEqual({
success: false,
error: 'Failed to save'
});
});
});
});
describe('profile-handlers - testConnection', () => {
beforeEach(() => {
vi.clearAllMocks();
registerProfileHandlers();
});
describe('successful connection tests', () => {
it('should return success result for valid connection', async () => {
const mockResult: TestConnectionResult = {
success: true,
message: 'Connection successful'
};
vi.mocked(testConnection).mockResolvedValue(mockResult);
const handler = getTestConnectionHandler();
const result = await handler({}, 'https://api.anthropic.com', 'sk-test-key-12chars');
expect(result).toEqual({
success: true,
data: mockResult
});
expect(testConnection).toHaveBeenCalledWith(
'https://api.anthropic.com',
'sk-test-key-12chars',
expect.any(AbortSignal)
);
});
});
describe('input validation', () => {
it('should return error for empty baseUrl', async () => {
const handler = getTestConnectionHandler();
const result = await handler({}, '', 'sk-test-key-12chars');
expect(result).toEqual({
success: false,
error: 'Base URL is required'
});
expect(testConnection).not.toHaveBeenCalled();
});
it('should return error for whitespace-only baseUrl', async () => {
const handler = getTestConnectionHandler();
const result = await handler({}, ' ', 'sk-test-key-12chars');
expect(result).toEqual({
success: false,
error: 'Base URL is required'
});
expect(testConnection).not.toHaveBeenCalled();
});
it('should return error for empty apiKey', async () => {
const handler = getTestConnectionHandler();
const result = await handler({}, 'https://api.anthropic.com', '');
expect(result).toEqual({
success: false,
error: 'API key is required'
});
expect(testConnection).not.toHaveBeenCalled();
});
it('should return error for whitespace-only apiKey', async () => {
const handler = getTestConnectionHandler();
const result = await handler({}, 'https://api.anthropic.com', ' ');
expect(result).toEqual({
success: false,
error: 'API key is required'
});
expect(testConnection).not.toHaveBeenCalled();
});
});
describe('error handling', () => {
it('should return IPCResult with TestConnectionResult data for service errors', async () => {
const mockResult: TestConnectionResult = {
success: false,
errorType: 'auth',
message: 'Authentication failed. Please check your API key.'
};
vi.mocked(testConnection).mockResolvedValue(mockResult);
const handler = getTestConnectionHandler();
const result = await handler({}, 'https://api.anthropic.com', 'invalid-key');
expect(result).toEqual({
success: true,
data: mockResult
});
});
it('should return error for unexpected exceptions', async () => {
vi.mocked(testConnection).mockRejectedValue(new Error('Unexpected error'));
const handler = getTestConnectionHandler();
const result = await handler({}, 'https://api.anthropic.com', 'sk-test-key-12chars');
expect(result).toEqual({
success: false,
error: 'Unexpected error'
});
});
it('should return error for non-Error exceptions', async () => {
vi.mocked(testConnection).mockRejectedValue('String error');
const handler = getTestConnectionHandler();
const result = await handler({}, 'https://api.anthropic.com', 'sk-test-key-12chars');
expect(result).toEqual({
success: false,
error: 'Failed to test connection'
});
});
});
});
@@ -1,358 +0,0 @@
/**
* Profile IPC Handlers
*
* IPC handlers for API profile management:
* - profiles:get - Get all profiles
* - profiles:save - Save/create a profile
* - profiles:update - Update an existing profile
* - profiles:delete - Delete a profile
* - profiles:setActive - Set active profile
* - profiles:test-connection - Test API profile connection
*/
import { ipcMain } from 'electron';
import { IPC_CHANNELS } from '../../shared/constants';
import type { IPCResult } from '../../shared/types';
import type { APIProfile, ProfileFormData, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from '@shared/types/profile';
import {
loadProfilesFile,
saveProfilesFile,
validateFilePermissions,
getProfilesFilePath,
atomicModifyProfiles,
createProfile,
updateProfile,
deleteProfile,
testConnection,
discoverModels
} from '../services/profile';
// Track active test connection requests for cancellation
const activeTestConnections = new Map<number, AbortController>();
// Track active discover models requests for cancellation
const activeDiscoverModelsRequests = new Map<number, AbortController>();
/**
* Register all profile-related IPC handlers
*/
export function registerProfileHandlers(): void {
/**
* Get all profiles
*/
ipcMain.handle(
IPC_CHANNELS.PROFILES_GET,
async (): Promise<IPCResult<ProfilesFile>> => {
try {
const profiles = await loadProfilesFile();
return { success: true, data: profiles };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to load profiles'
};
}
}
);
/**
* Save/create a profile
*/
ipcMain.handle(
IPC_CHANNELS.PROFILES_SAVE,
async (
_,
profileData: ProfileFormData
): Promise<IPCResult<APIProfile>> => {
try {
// Use createProfile from service layer (handles validation)
const newProfile = await createProfile(profileData);
// Set file permissions to user-readable only
await validateFilePermissions(getProfilesFilePath()).catch((err) => {
console.warn('[profile-handlers] Failed to set secure file permissions:', err);
});
return { success: true, data: newProfile };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to save profile'
};
}
}
);
/**
* Update an existing profile
*/
ipcMain.handle(
IPC_CHANNELS.PROFILES_UPDATE,
async (_, profileData: APIProfile): Promise<IPCResult<APIProfile>> => {
try {
// Use updateProfile from service layer (handles validation)
const updatedProfile = await updateProfile({
id: profileData.id,
name: profileData.name,
baseUrl: profileData.baseUrl,
apiKey: profileData.apiKey,
models: profileData.models
});
// Set file permissions to user-readable only
await validateFilePermissions(getProfilesFilePath()).catch((err) => {
console.warn('[profile-handlers] Failed to set secure file permissions:', err);
});
return { success: true, data: updatedProfile };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to update profile'
};
}
}
);
/**
* Delete a profile
*/
ipcMain.handle(
IPC_CHANNELS.PROFILES_DELETE,
async (_, profileId: string): Promise<IPCResult> => {
try {
// Use deleteProfile from service layer (handles validation)
await deleteProfile(profileId);
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to delete profile'
};
}
}
);
/**
* Set active profile
* - If profileId is provided, set that profile as active
* - If profileId is null, clear active profile (switch to OAuth)
* Uses atomic operation to prevent race conditions
*/
ipcMain.handle(
IPC_CHANNELS.PROFILES_SET_ACTIVE,
async (_, profileId: string | null): Promise<IPCResult> => {
try {
await atomicModifyProfiles((file) => {
// If switching to OAuth (null), clear active profile
if (profileId === null) {
file.activeProfileId = null;
return file;
}
// Check if profile exists
const profileExists = file.profiles.some((p) => p.id === profileId);
if (!profileExists) {
throw new Error('Profile not found');
}
// Set active profile
file.activeProfileId = profileId;
return file;
});
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to set active profile'
};
}
}
);
/**
* Test API profile connection
* - Tests credentials by making a minimal API request
* - Returns detailed error information for different failure types
* - Includes configurable timeout (defaults to 15 seconds)
* - Supports cancellation via PROFILES_TEST_CONNECTION_CANCEL
*/
ipcMain.handle(
IPC_CHANNELS.PROFILES_TEST_CONNECTION,
async (_event, baseUrl: string, apiKey: string, requestId: number): Promise<IPCResult<TestConnectionResult>> => {
// Create AbortController for timeout and cancellation
const controller = new AbortController();
const timeoutMs = 15000; // 15 seconds
// Track this request for cancellation
activeTestConnections.set(requestId, controller);
// Set timeout to abort the request
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
// Validate inputs (null/empty checks)
if (!baseUrl || baseUrl.trim() === '') {
clearTimeout(timeoutId);
activeTestConnections.delete(requestId);
return {
success: false,
error: 'Base URL is required'
};
}
if (!apiKey || apiKey.trim() === '') {
clearTimeout(timeoutId);
activeTestConnections.delete(requestId);
return {
success: false,
error: 'API key is required'
};
}
// Call testConnection from service layer with abort signal
const result = await testConnection(baseUrl, apiKey, controller.signal);
// Clear timeout on success
clearTimeout(timeoutId);
activeTestConnections.delete(requestId);
return { success: true, data: result };
} catch (error) {
// Clear timeout on error
clearTimeout(timeoutId);
activeTestConnections.delete(requestId);
// Handle abort errors (timeout or explicit cancellation)
if (error instanceof Error && error.name === 'AbortError') {
return {
success: false,
error: 'Connection timeout. The request took too long to complete.'
};
}
return {
success: false,
error: error instanceof Error ? error.message : 'Failed to test connection'
};
}
}
);
/**
* Cancel an active test connection request
*/
ipcMain.on(
IPC_CHANNELS.PROFILES_TEST_CONNECTION_CANCEL,
(_event, requestId: number) => {
const controller = activeTestConnections.get(requestId);
if (controller) {
controller.abort();
activeTestConnections.delete(requestId);
}
}
);
/**
* Discover available models from API endpoint
* - Fetches list of models from /v1/models endpoint
* - Returns model IDs and display names for dropdown selection
* - Supports cancellation via PROFILES_DISCOVER_MODELS_CANCEL
*/
ipcMain.handle(
IPC_CHANNELS.PROFILES_DISCOVER_MODELS,
async (_event, baseUrl: string, apiKey: string, requestId: number): Promise<IPCResult<DiscoverModelsResult>> => {
console.log('[discoverModels] Called with:', { baseUrl, requestId });
// Create AbortController for timeout and cancellation
const controller = new AbortController();
const timeoutMs = 15000; // 15 seconds
// Track this request for cancellation
activeDiscoverModelsRequests.set(requestId, controller);
// Set timeout to abort the request
const timeoutId = setTimeout(() => {
controller.abort();
}, timeoutMs);
try {
// Validate inputs (null/empty checks)
if (!baseUrl || baseUrl.trim() === '') {
clearTimeout(timeoutId);
activeDiscoverModelsRequests.delete(requestId);
return {
success: false,
error: 'Base URL is required'
};
}
if (!apiKey || apiKey.trim() === '') {
clearTimeout(timeoutId);
activeDiscoverModelsRequests.delete(requestId);
return {
success: false,
error: 'API key is required'
};
}
// Call discoverModels from service layer with abort signal
const result = await discoverModels(baseUrl, apiKey, controller.signal);
// Clear timeout on success
clearTimeout(timeoutId);
activeDiscoverModelsRequests.delete(requestId);
return { success: true, data: result };
} catch (error) {
// Clear timeout on error
clearTimeout(timeoutId);
activeDiscoverModelsRequests.delete(requestId);
// Handle abort errors (timeout or explicit cancellation)
if (error instanceof Error && error.name === 'AbortError') {
return {
success: false,
error: 'Connection timeout. The request took too long to complete.'
};
}
// Extract error type if available
const errorType = (error as any).errorType;
const errorMessage = error instanceof Error ? error.message : 'Failed to discover models';
// Log for debugging
console.error('[discoverModels] Error:', {
name: error instanceof Error ? error.name : 'unknown',
message: errorMessage,
errorType,
originalError: error
});
// Include error type in error message for UI to handle appropriately
return {
success: false,
error: errorMessage
};
}
}
);
/**
* Cancel an active discover models request
*/
ipcMain.on(
IPC_CHANNELS.PROFILES_DISCOVER_MODELS_CANCEL,
(_event, requestId: number) => {
const controller = activeDiscoverModelsRequests.get(requestId);
if (controller) {
controller.abort();
activeDiscoverModelsRequests.delete(requestId);
}
}
);
}
File diff suppressed because it is too large Load Diff
@@ -1,510 +0,0 @@
/**
* Profile Service - Validation and profile creation
*
* Provides validation functions for URL, API key, and profile name uniqueness.
* Handles creating new profiles with validation.
*/
import { loadProfilesFile, saveProfilesFile, generateProfileId } from '../utils/profile-manager';
import type { APIProfile, TestConnectionResult } from '../../shared/types/profile';
/**
* Validate base URL format
* Accepts HTTP(S) URLs with valid endpoints
*/
export function validateBaseUrl(baseUrl: string): boolean {
if (!baseUrl || baseUrl.trim() === '') {
return false;
}
try {
const url = new URL(baseUrl);
// Only allow http and https protocols
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
/**
* Validate API key format
* Accepts various API key formats (Anthropic, OpenAI, custom)
*/
export function validateApiKey(apiKey: string): boolean {
if (!apiKey || apiKey.trim() === '') {
return false;
}
const trimmed = apiKey.trim();
// Too short to be a real API key
if (trimmed.length < 12) {
return false;
}
// Accept common API key formats
// Anthropic: sk-ant-...
// OpenAI: sk-proj-... or sk-...
// Custom: any reasonable length key with alphanumeric chars
const hasValidChars = /^[a-zA-Z0-9\-_+.]+$/.test(trimmed);
return hasValidChars;
}
/**
* Validate that profile name is unique (case-insensitive, trimmed)
*/
export async function validateProfileNameUnique(name: string): Promise<boolean> {
const trimmed = name.trim().toLowerCase();
const file = await loadProfilesFile();
// Check if any profile has the same name (case-insensitive)
const exists = file.profiles.some(
(p) => p.name.trim().toLowerCase() === trimmed
);
return !exists;
}
/**
* Input type for creating a profile (without id, createdAt, updatedAt)
*/
export type CreateProfileInput = Omit<APIProfile, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Input type for updating a profile (with id, without createdAt, updatedAt)
*/
export type UpdateProfileInput = Pick<APIProfile, 'id'> & CreateProfileInput;
/**
* Delete a profile with validation
* Throws errors for validation failures
*/
export async function deleteProfile(id: string): Promise<void> {
const file = await loadProfilesFile();
// Find the profile
const profileIndex = file.profiles.findIndex((p) => p.id === id);
if (profileIndex === -1) {
throw new Error('Profile not found');
}
const profile = file.profiles[profileIndex];
// Active Profile Check: Cannot delete active profile (AC3)
if (file.activeProfileId === id) {
throw new Error('Cannot delete active profile. Please switch to another profile or OAuth first.');
}
// Remove profile
file.profiles.splice(profileIndex, 1);
// Last Profile Fallback: If no profiles remain, set activeProfileId to null (AC4)
if (file.profiles.length === 0) {
file.activeProfileId = null;
}
// Save to disk
await saveProfilesFile(file);
}
/**
* Create a new profile with validation
* Throws errors for validation failures
*/
export async function createProfile(input: CreateProfileInput): Promise<APIProfile> {
// Validate base URL
if (!validateBaseUrl(input.baseUrl)) {
throw new Error('Invalid base URL');
}
// Validate API key
if (!validateApiKey(input.apiKey)) {
throw new Error('Invalid API key');
}
// Validate profile name uniqueness
const isUnique = await validateProfileNameUnique(input.name);
if (!isUnique) {
throw new Error('A profile with this name already exists');
}
// Load existing profiles
const file = await loadProfilesFile();
// Create new profile
const now = Date.now();
const newProfile: APIProfile = {
id: generateProfileId(),
name: input.name.trim(),
baseUrl: input.baseUrl.trim(),
apiKey: input.apiKey.trim(),
models: input.models,
createdAt: now,
updatedAt: now
};
// Add to profiles list
file.profiles.push(newProfile);
// Set as active if it's the first profile
if (file.profiles.length === 1) {
file.activeProfileId = newProfile.id;
}
// Save to disk
await saveProfilesFile(file);
return newProfile;
}
/**
* Update an existing profile with validation
* Throws errors for validation failures
*/
export async function updateProfile(input: UpdateProfileInput): Promise<APIProfile> {
// Validate base URL
if (!validateBaseUrl(input.baseUrl)) {
throw new Error('Invalid base URL');
}
// Validate API key
if (!validateApiKey(input.apiKey)) {
throw new Error('Invalid API key');
}
// Load existing profiles
const file = await loadProfilesFile();
// Find the profile
const profileIndex = file.profiles.findIndex((p) => p.id === input.id);
if (profileIndex === -1) {
throw new Error('Profile not found');
}
const existingProfile = file.profiles[profileIndex];
// Validate profile name uniqueness (exclude current profile from check)
if (input.name.trim().toLowerCase() !== existingProfile.name.trim().toLowerCase()) {
const trimmed = input.name.trim().toLowerCase();
const nameExists = file.profiles.some(
(p) => p.id !== input.id && p.name.trim().toLowerCase() === trimmed
);
if (nameExists) {
throw new Error('A profile with this name already exists');
}
}
// Update profile (including name)
const updatedProfile: APIProfile = {
...existingProfile,
name: input.name.trim(),
baseUrl: input.baseUrl.trim(),
apiKey: input.apiKey.trim(),
models: input.models,
updatedAt: Date.now()
};
// Replace in profiles list
file.profiles[profileIndex] = updatedProfile;
// Save to disk
await saveProfilesFile(file);
return updatedProfile;
}
/**
* Get environment variables for the active API profile
*
* Maps the active API profile to SDK environment variables for injection
* into Python subprocess. Returns empty object when no profile is active
* (OAuth mode), allowing CLAUDE_CODE_OAUTH_TOKEN to be used instead.
*
* Environment Variable Mapping:
* - profile.baseUrl → ANTHROPIC_BASE_URL
* - profile.apiKey → ANTHROPIC_AUTH_TOKEN
* - profile.models.default → ANTHROPIC_MODEL
* - profile.models.haiku → ANTHROPIC_DEFAULT_HAIKU_MODEL
* - profile.models.sonnet → ANTHROPIC_DEFAULT_SONNET_MODEL
* - profile.models.opus → ANTHROPIC_DEFAULT_OPUS_MODEL
*
* Empty string values are filtered out (not set as env vars).
*
* @returns Promise<Record<string, string>> Environment variables for active profile
*/
export async function getAPIProfileEnv(): Promise<Record<string, string>> {
// Load profiles.json
const file = await loadProfilesFile();
// If no active profile (null/empty), return empty object (OAuth mode)
if (!file.activeProfileId || file.activeProfileId === '') {
return {};
}
// Find active profile by activeProfileId
const profile = file.profiles.find((p) => p.id === file.activeProfileId);
// If profile not found, return empty object (shouldn't happen with valid data)
if (!profile) {
return {};
}
// Map profile fields to SDK env vars
const envVars: Record<string, string> = {
ANTHROPIC_BASE_URL: profile.baseUrl || '',
ANTHROPIC_AUTH_TOKEN: profile.apiKey || '',
ANTHROPIC_MODEL: profile.models?.default || '',
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models?.haiku || '',
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models?.sonnet || '',
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models?.opus || '',
};
// Filter out empty/whitespace string values (only set env vars that have values)
// This handles empty strings, null, undefined, and whitespace-only values
const filteredEnvVars: Record<string, string> = {};
for (const [key, value] of Object.entries(envVars)) {
const trimmedValue = value?.trim();
if (trimmedValue && trimmedValue !== '') {
filteredEnvVars[key] = trimmedValue;
}
}
return filteredEnvVars;
}
/**
* Test API profile connection
*
* Validates credentials by making a minimal API request to the /v1/models endpoint.
* Returns detailed error information for different failure types.
*
* @param baseUrl - API base URL (will be normalized)
* @param apiKey - API key for authentication
* @param signal - Optional AbortSignal for cancelling the request
* @returns Promise<TestConnectionResult> Result of connection test
*/
export async function testConnection(
baseUrl: string,
apiKey: string,
signal?: AbortSignal
): Promise<TestConnectionResult> {
// Validate API key first (key format doesn't depend on URL normalization)
if (!validateApiKey(apiKey)) {
return {
success: false,
errorType: 'auth',
message: 'Authentication failed. Please check your API key.'
};
}
// Normalize baseUrl BEFORE validation (allows auto-prepending https://)
let normalizedUrl = baseUrl.trim();
// Store original URL for error suggestions
const originalUrl = normalizedUrl;
// If empty, return error
if (!normalizedUrl) {
return {
success: false,
errorType: 'endpoint',
message: 'Invalid endpoint. Please check the Base URL.'
};
}
// Ensure https:// prefix (auto-prepend if NO protocol exists)
// Check if URL already has a protocol (contains ://)
if (!normalizedUrl.includes('://')) {
normalizedUrl = `https://${normalizedUrl}`;
}
// Remove trailing slash
normalizedUrl = normalizedUrl.replace(/\/+$/, '');
// Helper function to generate URL suggestions
const getUrlSuggestions = (url: string): string[] => {
const suggestions: string[] = [];
// Check if URL lacks https://
if (!url.includes('://')) {
suggestions.push('Ensure URL starts with https://');
}
// Check for trailing slash
if (url.endsWith('/')) {
suggestions.push('Remove trailing slashes from URL');
}
// Check for suspicious domain patterns (common typos)
const domainMatch = url.match(/:\/\/([^/]+)/);
if (domainMatch) {
const domain = domainMatch[1];
// Check for common typos like anthropiic, ap, etc.
if (domain.includes('anthropiic') || domain.includes('anthhropic') ||
domain.includes('anhtropic') || domain.length < 10) {
suggestions.push('Check for typos in domain name');
}
}
return suggestions;
};
// Validate the normalized baseUrl
if (!validateBaseUrl(normalizedUrl)) {
// Generate suggestions based on original URL
const suggestions = getUrlSuggestions(originalUrl);
const message = suggestions.length > 0
? `Invalid endpoint. Please check the Base URL.${suggestions.map(s => ' ' + s).join('')}`
: 'Invalid endpoint. Please check the Base URL.';
return {
success: false,
errorType: 'endpoint',
message
};
}
// Set timeout to 10 seconds (NFR-P3 compliance)
const timeoutController = new AbortController();
const timeoutId = setTimeout(() => timeoutController.abort(), 10000);
// Create a combined controller that aborts when either timeout or external signal aborts
const combinedController = new AbortController();
// Cleanup function for event listeners
const cleanup = () => {
clearTimeout(timeoutId);
};
// Listen to timeout abort
const onTimeoutAbort = () => {
cleanup();
combinedController.abort();
};
timeoutController.signal.addEventListener('abort', onTimeoutAbort);
// Listen to external signal abort (if provided)
let onExternalAbort: (() => void) | undefined;
if (signal) {
// If external signal already aborted, abort immediately
if (signal.aborted) {
cleanup();
timeoutController.signal.removeEventListener('abort', onTimeoutAbort);
return {
success: false,
errorType: 'timeout',
message: 'Connection timeout. The endpoint did not respond.'
};
}
// Listen to external signal abort
onExternalAbort = () => {
cleanup();
timeoutController.signal.removeEventListener('abort', onTimeoutAbort);
combinedController.abort();
};
signal.addEventListener('abort', onExternalAbort);
}
const combinedSignal = combinedController.signal;
try {
// Make minimal API request
const response = await fetch(`${normalizedUrl}/v1/models`, {
method: 'GET',
headers: {
'x-api-key': apiKey,
'anthropic-version': '2023-06-01'
},
signal: combinedSignal
});
// Clear timeout on successful response
cleanup();
if (onTimeoutAbort) {
timeoutController.signal.removeEventListener('abort', onTimeoutAbort);
}
if (signal && onExternalAbort) {
signal.removeEventListener('abort', onExternalAbort);
}
// Parse response and determine error type
if (response.status === 200 || response.status === 201) {
return {
success: true,
message: 'Connection successful'
};
}
if (response.status === 401 || response.status === 403) {
return {
success: false,
errorType: 'auth',
message: 'Authentication failed. Please check your API key.'
};
}
if (response.status === 404) {
// Generate URL suggestions for 404 errors
const suggestions = getUrlSuggestions(baseUrl.trim());
const message = suggestions.length > 0
? `Invalid endpoint. Please check the Base URL.${suggestions.map(s => ' ' + s).join('')}`
: 'Invalid endpoint. Please check the Base URL.';
return {
success: false,
errorType: 'endpoint',
message
};
}
// Other HTTP errors
return {
success: false,
errorType: 'unknown',
message: 'Connection test failed. Please try again.'
};
} catch (error) {
// Cleanup event listeners and timeout
cleanup();
if (onTimeoutAbort) {
timeoutController.signal.removeEventListener('abort', onTimeoutAbort);
}
if (signal && onExternalAbort) {
signal.removeEventListener('abort', onExternalAbort);
}
// Determine error type from error object
if (error instanceof Error) {
// AbortError → timeout
if (error.name === 'AbortError') {
return {
success: false,
errorType: 'timeout',
message: 'Connection timeout. The endpoint did not respond.'
};
}
// TypeError with ECONNREFUSED/ENOTFOUND → network error
if (error instanceof TypeError) {
const errorCode = (error as any).code;
if (errorCode === 'ECONNREFUSED' || errorCode === 'ENOTFOUND') {
return {
success: false,
errorType: 'network',
message: 'Network error. Please check your internet connection.'
};
}
}
}
// Other errors
return {
success: false,
errorType: 'unknown',
message: 'Connection test failed. Please try again.'
};
}
}
@@ -1,43 +0,0 @@
/**
* Profile Service - Barrel Export
*
* Re-exports all profile-related functionality for convenient importing.
* Main process code should import from this index file.
*/
// Profile Manager utilities
export {
loadProfilesFile,
saveProfilesFile,
generateProfileId,
validateFilePermissions,
getProfilesFilePath,
withProfilesLock,
atomicModifyProfiles
} from './profile-manager';
// Profile Service
export {
validateBaseUrl,
validateApiKey,
validateProfileNameUnique,
createProfile,
updateProfile,
deleteProfile,
getAPIProfileEnv,
testConnection,
discoverModels
} from './profile-service';
export type { CreateProfileInput, UpdateProfileInput } from './profile-service';
// Re-export types from shared for convenience
export type {
APIProfile,
ProfilesFile,
ProfileFormData,
TestConnectionResult,
ModelInfo,
DiscoverModelsResult,
DiscoverModelsError
} from '@shared/types/profile';
@@ -1,208 +0,0 @@
/**
* Tests for profile-manager.ts
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import {
loadProfilesFile,
saveProfilesFile,
generateProfileId,
validateFilePermissions
} from './profile-manager';
import type { ProfilesFile } from '@shared/types/profile';
// Use vi.hoisted to define mock functions that need to be accessible in vi.mock
const { fsMocks } = vi.hoisted(() => ({
fsMocks: {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
chmod: vi.fn(),
access: vi.fn(),
unlink: vi.fn(),
rename: vi.fn()
}
}));
// Mock Electron app.getPath
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
if (name === 'userData') {
return '/mock/userdata';
}
return '/mock/path';
})
}
}));
// Mock proper-lockfile
vi.mock('proper-lockfile', () => ({
default: {
lock: vi.fn().mockResolvedValue(vi.fn().mockResolvedValue(undefined))
}
}));
// Mock fs module
vi.mock('fs', () => ({
default: {
promises: fsMocks
},
promises: fsMocks,
existsSync: vi.fn(),
constants: {
O_RDONLY: 0,
S_IRUSR: 0o400
}
}));
describe('profile-manager', () => {
beforeEach(() => {
vi.clearAllMocks();
// Setup default mocks to resolve
fsMocks.mkdir.mockResolvedValue(undefined);
fsMocks.writeFile.mockResolvedValue(undefined);
fsMocks.chmod.mockResolvedValue(undefined);
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('loadProfilesFile', () => {
it('should return default profiles file when file does not exist', async () => {
fsMocks.readFile.mockRejectedValue(new Error('ENOENT'));
const result = await loadProfilesFile();
expect(result).toEqual({
profiles: [],
activeProfileId: null,
version: 1
});
});
it('should return default profiles file when file is corrupted JSON', async () => {
fsMocks.readFile.mockResolvedValue(Buffer.from('invalid json{'));
const result = await loadProfilesFile();
expect(result).toEqual({
profiles: [],
activeProfileId: null,
version: 1
});
});
it('should load valid profiles file', async () => {
const mockData: ProfilesFile = {
profiles: [
{
id: 'test-id-1',
name: 'Test Profile',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: 'test-id-1',
version: 1
};
fsMocks.readFile.mockResolvedValue(
Buffer.from(JSON.stringify(mockData))
);
const result = await loadProfilesFile();
expect(result).toEqual(mockData);
});
it('should use auto-claude directory for profiles.json path', async () => {
fsMocks.readFile.mockRejectedValue(new Error('ENOENT'));
await loadProfilesFile();
// Verify the file path includes auto-claude
const readFileCalls = fsMocks.readFile.mock.calls;
const filePath = readFileCalls[0]?.[0];
expect(filePath).toContain('auto-claude');
expect(filePath).toContain('profiles.json');
});
});
describe('saveProfilesFile', () => {
it('should write profiles file to disk', async () => {
const mockData: ProfilesFile = {
profiles: [],
activeProfileId: null,
version: 1
};
await saveProfilesFile(mockData);
expect(fsMocks.writeFile).toHaveBeenCalled();
const writeFileCall = fsMocks.writeFile.mock.calls[0];
const filePath = writeFileCall?.[0];
const content = writeFileCall?.[1];
expect(filePath).toContain('auto-claude');
expect(filePath).toContain('profiles.json');
expect(content).toBe(JSON.stringify(mockData, null, 2));
});
it('should throw error when write fails', async () => {
const mockData: ProfilesFile = {
profiles: [],
activeProfileId: null,
version: 1
};
fsMocks.writeFile.mockRejectedValue(new Error('Write failed'));
await expect(saveProfilesFile(mockData)).rejects.toThrow('Write failed');
});
});
describe('generateProfileId', () => {
it('should generate unique UUID v4 format IDs', () => {
const id1 = generateProfileId();
const id2 = generateProfileId();
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(id2).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
// IDs should be unique
expect(id1).not.toBe(id2);
});
it('should generate different IDs on consecutive calls', () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) {
ids.add(generateProfileId());
}
expect(ids.size).toBe(100);
});
});
describe('validateFilePermissions', () => {
it('should validate user-readable only file permissions', async () => {
// Mock successful chmod
fsMocks.chmod.mockResolvedValue(undefined);
const result = await validateFilePermissions('/mock/path/to/file.json');
expect(result).toBe(true);
});
it('should return false if chmod fails', async () => {
fsMocks.chmod.mockRejectedValue(new Error('Permission denied'));
const result = await validateFilePermissions('/mock/path/to/file.json');
expect(result).toBe(false);
});
});
});
@@ -1,262 +0,0 @@
/**
* Profile Manager - File I/O for API profiles
*
* Handles loading and saving profiles.json from the auto-claude directory.
* Provides graceful handling for missing or corrupted files.
* Uses file locking to prevent race conditions in concurrent operations.
*/
import { promises as fs } from 'fs';
import path from 'path';
import { app } from 'electron';
// @ts-expect-error - no types available for proper-lockfile
import * as lockfile from 'proper-lockfile';
import type { APIProfile, ProfilesFile } from '@shared/types/profile';
/**
* Get the path to profiles.json in the auto-claude directory
*/
export function getProfilesFilePath(): string {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'auto-claude', 'profiles.json');
}
/**
* Check if a value is a valid profile object with required fields
*/
function isValidProfile(value: unknown): value is APIProfile {
if (typeof value !== 'object' || value === null) {
return false;
}
const profile = value as Record<string, unknown>;
return (
typeof profile.id === 'string' &&
typeof profile.name === 'string' &&
typeof profile.baseUrl === 'string' &&
typeof profile.apiKey === 'string' &&
typeof profile.createdAt === 'number' &&
typeof profile.updatedAt === 'number'
);
}
/**
* Validate the structure of parsed profiles data
*/
function isValidProfilesFile(data: unknown): data is ProfilesFile {
if (typeof data !== 'object' || data === null) {
return false;
}
const obj = data as Record<string, unknown>;
// Check profiles is an array
if (!Array.isArray(obj.profiles)) {
return false;
}
// Check each profile has required fields
for (const profile of obj.profiles) {
if (!isValidProfile(profile)) {
return false;
}
}
// Check activeProfileId is string or null
if (obj.activeProfileId !== null && typeof obj.activeProfileId !== 'string') {
return false;
}
// Check version is a number
if (typeof obj.version !== 'number') {
return false;
}
return true;
}
/**
* Default profiles file structure for fallback
*/
function getDefaultProfilesFile(): ProfilesFile {
return {
profiles: [],
activeProfileId: null,
version: 1
};
}
/**
* Load profiles.json from disk
* Returns default empty profiles file if file doesn't exist or is corrupted
*/
export async function loadProfilesFile(): Promise<ProfilesFile> {
const filePath = getProfilesFilePath();
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content);
// Validate parsed data structure
if (isValidProfilesFile(data)) {
return data;
}
// Validation failed - return default
return getDefaultProfilesFile();
} catch {
// File doesn't exist or read/parse error - return default
return getDefaultProfilesFile();
}
}
/**
* Save profiles.json to disk
* Creates the auto-claude directory if it doesn't exist
* Ensures secure file permissions (user read/write only)
*/
export async function saveProfilesFile(data: ProfilesFile): Promise<void> {
const filePath = getProfilesFilePath();
const dir = path.dirname(filePath);
// Ensure directory exists
// mkdir with recursive: true resolves successfully if dir already exists
await fs.mkdir(dir, { recursive: true });
// Write file with formatted JSON
const content = JSON.stringify(data, null, 2);
await fs.writeFile(filePath, content, 'utf-8');
// Set secure file permissions (user read/write only - 0600)
const permissionsValid = await validateFilePermissions(filePath);
if (!permissionsValid) {
throw new Error('Failed to set secure file permissions on profiles file');
}
}
/**
* Generate a unique UUID v4 for a new profile
*/
export function generateProfileId(): string {
// Use crypto.randomUUID() if available (Node.js 16+ and modern browsers)
// Fall back to hand-rolled implementation for older environments
if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
return crypto.randomUUID();
}
// Fallback: hand-rolled UUID v4 implementation
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Validate and set file permissions to user-readable only
* Returns true if successful, false otherwise
*/
export async function validateFilePermissions(filePath: string): Promise<boolean> {
try {
// Set file permissions to user-readable only (0600)
await fs.chmod(filePath, 0o600);
return true;
} catch {
return false;
}
}
/**
* Execute a function with exclusive file lock to prevent race conditions
* This ensures atomic read-modify-write operations on the profiles file
*
* @param fn Function to execute while holding the lock
* @returns Result of the function execution
*/
export async function withProfilesLock<T>(fn: () => Promise<T>): Promise<T> {
const filePath = getProfilesFilePath();
const dir = path.dirname(filePath);
// Ensure directory and file exist before trying to lock
await fs.mkdir(dir, { recursive: true });
// Create file if it doesn't exist (needed for lockfile to work)
try {
await fs.access(filePath);
} catch {
// File doesn't exist, create it atomically with exclusive flag
const defaultData = getDefaultProfilesFile();
try {
await fs.writeFile(filePath, JSON.stringify(defaultData, null, 2), { encoding: 'utf-8', flag: 'wx' });
} catch (err: unknown) {
// If file was created by another process (race condition), that's fine
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
throw err;
}
// EEXIST means another process won the race, proceed normally
}
}
// Acquire lock with reasonable timeout
let release: (() => Promise<void>) | undefined;
try {
release = await lockfile.lock(filePath, {
retries: {
retries: 10,
minTimeout: 50,
maxTimeout: 500
}
});
// Execute the function while holding the lock
return await fn();
} finally {
// Always release the lock
if (release) {
await release();
}
}
}
/**
* Atomically modify the profiles file
* Loads, modifies, and saves the file within an exclusive lock
*
* @param modifier Function that modifies the ProfilesFile
* @returns The modified ProfilesFile
*/
export async function atomicModifyProfiles(
modifier: (file: ProfilesFile) => ProfilesFile | Promise<ProfilesFile>
): Promise<ProfilesFile> {
return await withProfilesLock(async () => {
// Load current state
const file = await loadProfilesFile();
// Apply modification
const modifiedFile = await modifier(file);
// Save atomically (write to temp file and rename)
const filePath = getProfilesFilePath();
const tempPath = `${filePath}.tmp`;
try {
// Write to temp file
const content = JSON.stringify(modifiedFile, null, 2);
await fs.writeFile(tempPath, content, 'utf-8');
// Set permissions on temp file
await fs.chmod(tempPath, 0o600);
// Atomically replace original file
await fs.rename(tempPath, filePath);
return modifiedFile;
} catch (error) {
// Clean up temp file on error
try {
await fs.unlink(tempPath);
} catch {
// Ignore cleanup errors
}
throw error;
}
});
}
@@ -1,792 +0,0 @@
/**
* Tests for profile-service.ts
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import {
validateBaseUrl,
validateApiKey,
validateProfileNameUnique,
createProfile,
updateProfile,
getAPIProfileEnv,
testConnection,
discoverModels
} from './profile-service';
import type { APIProfile, ProfilesFile, TestConnectionResult } from '@shared/types/profile';
// Mock Anthropic SDK - use vi.hoisted to properly hoist the mock variable
const { mockModelsList, mockMessagesCreate } = vi.hoisted(() => ({
mockModelsList: vi.fn(),
mockMessagesCreate: vi.fn()
}));
vi.mock('@anthropic-ai/sdk', () => {
// Create mock error classes
class APIError extends Error {
status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'APIError';
this.status = status;
}
}
class AuthenticationError extends APIError {
constructor(message: string) {
super(message, 401);
this.name = 'AuthenticationError';
}
}
class NotFoundError extends APIError {
constructor(message: string) {
super(message, 404);
this.name = 'NotFoundError';
}
}
class APIConnectionError extends Error {
constructor(message: string) {
super(message);
this.name = 'APIConnectionError';
}
}
class APIConnectionTimeoutError extends Error {
constructor(message: string) {
super(message);
this.name = 'APIConnectionTimeoutError';
}
}
class BadRequestError extends APIError {
constructor(message: string) {
super(message, 400);
this.name = 'BadRequestError';
}
}
return {
default: class Anthropic {
models = {
list: mockModelsList
};
messages = {
create: mockMessagesCreate
};
},
APIError,
AuthenticationError,
NotFoundError,
APIConnectionError,
APIConnectionTimeoutError,
BadRequestError
};
});
// Mock profile-manager
vi.mock('./profile-manager', () => ({
loadProfilesFile: vi.fn(),
saveProfilesFile: vi.fn(),
generateProfileId: vi.fn(() => 'mock-uuid-1234'),
validateFilePermissions: vi.fn().mockResolvedValue(true),
getProfilesFilePath: vi.fn(() => '/mock/profiles.json'),
atomicModifyProfiles: vi.fn(async (modifier: (file: ProfilesFile) => ProfilesFile) => {
// Get the current mock file from loadProfilesFile
const { loadProfilesFile, saveProfilesFile } = await import('./profile-manager');
const file = await loadProfilesFile();
const modified = modifier(file);
await saveProfilesFile(modified);
return modified;
})
}));
describe('profile-service', () => {
describe('validateBaseUrl', () => {
it('should accept valid HTTPS URLs', () => {
expect(validateBaseUrl('https://api.anthropic.com')).toBe(true);
expect(validateBaseUrl('https://custom-api.example.com')).toBe(true);
expect(validateBaseUrl('https://api.example.com/v1')).toBe(true);
});
it('should accept valid HTTP URLs', () => {
expect(validateBaseUrl('http://localhost:8080')).toBe(true);
expect(validateBaseUrl('http://127.0.0.1:8000')).toBe(true);
});
it('should reject invalid URLs', () => {
expect(validateBaseUrl('not-a-url')).toBe(false);
expect(validateBaseUrl('ftp://example.com')).toBe(false);
expect(validateBaseUrl('')).toBe(false);
expect(validateBaseUrl('https://')).toBe(false);
});
it('should reject URLs without valid format', () => {
expect(validateBaseUrl('anthropic.com')).toBe(false);
expect(validateBaseUrl('://api.anthropic.com')).toBe(false);
});
});
describe('validateApiKey', () => {
it('should accept Anthropic API key format (sk-ant-...)', () => {
expect(validateApiKey('sk-ant-api03-12345')).toBe(true);
expect(validateApiKey('sk-ant-test-key')).toBe(true);
});
it('should accept OpenAI API key format (sk-...)', () => {
expect(validateApiKey('sk-proj-12345')).toBe(true);
expect(validateApiKey('sk-test-key-12345')).toBe(true);
});
it('should accept custom API keys with reasonable length', () => {
expect(validateApiKey('custom-key-12345678')).toBe(true);
expect(validateApiKey('x-api-key-abcdefghij')).toBe(true);
});
it('should reject empty or too short keys', () => {
expect(validateApiKey('')).toBe(false);
expect(validateApiKey('sk-')).toBe(false);
expect(validateApiKey('abc')).toBe(false);
});
it('should reject keys with only whitespace', () => {
expect(validateApiKey(' ')).toBe(false);
expect(validateApiKey('\t\n')).toBe(false);
});
});
describe('validateProfileNameUnique', () => {
it('should return true when name is unique', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: '1',
name: 'Existing Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const result = await validateProfileNameUnique('New Profile');
expect(result).toBe(true);
});
it('should return false when name already exists', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: '1',
name: 'Existing Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const result = await validateProfileNameUnique('Existing Profile');
expect(result).toBe(false);
});
it('should be case-insensitive for duplicate detection', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: '1',
name: 'My Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const result1 = await validateProfileNameUnique('my profile');
const result2 = await validateProfileNameUnique('MY PROFILE');
expect(result1).toBe(false);
expect(result2).toBe(false);
});
it('should trim whitespace before checking', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: '1',
name: 'My Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const result = await validateProfileNameUnique(' My Profile ');
expect(result).toBe(false);
});
});
describe('createProfile', () => {
it('should create profile with valid data and save', async () => {
const mockFile: ProfilesFile = {
profiles: [],
activeProfileId: null,
version: 1
};
const { loadProfilesFile, saveProfilesFile, generateProfileId } =
await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
vi.mocked(saveProfilesFile).mockResolvedValue(undefined);
vi.mocked(generateProfileId).mockReturnValue('generated-id-123');
const input = {
name: 'Test Profile',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
models: {
default: 'claude-3-5-sonnet-20241022'
}
};
const result = await createProfile(input);
expect(result).toMatchObject({
id: 'generated-id-123',
name: 'Test Profile',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
models: {
default: 'claude-3-5-sonnet-20241022'
}
});
expect(result.createdAt).toBeGreaterThan(0);
expect(result.updatedAt).toBeGreaterThan(0);
expect(saveProfilesFile).toHaveBeenCalled();
});
it('should throw error for invalid base URL', async () => {
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue({
profiles: [],
activeProfileId: null,
version: 1
});
const input = {
name: 'Test Profile',
baseUrl: 'not-a-url',
apiKey: 'sk-ant-test-key'
};
await expect(createProfile(input)).rejects.toThrow('Invalid base URL');
});
it('should throw error for invalid API key', async () => {
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue({
profiles: [],
activeProfileId: null,
version: 1
});
const input = {
name: 'Test Profile',
baseUrl: 'https://api.anthropic.com',
apiKey: 'too-short'
};
await expect(createProfile(input)).rejects.toThrow('Invalid API key');
});
it('should throw error for duplicate profile name', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: '1',
name: 'Existing Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const input = {
name: 'Existing Profile',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key'
};
await expect(createProfile(input)).rejects.toThrow(
'A profile with this name already exists'
);
});
});
describe('updateProfile', () => {
it('should update profile name and other fields', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: 'existing-id',
name: 'Old Name',
baseUrl: 'https://old-api.example.com',
apiKey: 'sk-old-key-12345678',
createdAt: 1000000,
updatedAt: 1000000
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile, saveProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
vi.mocked(saveProfilesFile).mockResolvedValue(undefined);
const input = {
id: 'existing-id',
name: 'New Name',
baseUrl: 'https://new-api.example.com',
apiKey: 'sk-new-api-key-123',
models: { default: 'claude-3-5-sonnet-20241022' }
};
const result = await updateProfile(input);
expect(result.name).toBe('New Name');
expect(result.baseUrl).toBe('https://new-api.example.com');
expect(result.apiKey).toBe('sk-new-api-key-123');
expect(result.models).toEqual({ default: 'claude-3-5-sonnet-20241022' });
expect(result.updatedAt).toBeGreaterThan(1000000);
expect(result.createdAt).toBe(1000000);
});
it('should allow updating profile with same name (case-insensitive)', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: 'existing-id',
name: 'My Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-old-api-key-123',
createdAt: 1000000,
updatedAt: 1000000
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile, saveProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
vi.mocked(saveProfilesFile).mockResolvedValue(undefined);
const input = {
id: 'existing-id',
name: 'my profile',
baseUrl: 'https://new-api.example.com',
apiKey: 'sk-new-api-key-456'
};
const result = await updateProfile(input);
expect(result.name).toBe('my profile');
expect(saveProfilesFile).toHaveBeenCalled();
});
it('should throw error when name conflicts with another profile', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: 'profile-1',
name: 'Profile One',
baseUrl: 'https://api1.example.com',
apiKey: 'sk-key-one-12345678',
createdAt: 1000000,
updatedAt: 1000000
},
{
id: 'profile-2',
name: 'Profile Two',
baseUrl: 'https://api2.example.com',
apiKey: 'sk-key-two-12345678',
createdAt: 1000000,
updatedAt: 1000000
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const input = {
id: 'profile-1',
name: 'Profile Two',
baseUrl: 'https://api1.example.com',
apiKey: 'sk-key-one-12345678'
};
await expect(updateProfile(input)).rejects.toThrow(
'A profile with this name already exists'
);
});
it('should throw error for invalid base URL', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: 'existing-id',
name: 'Test Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test-api-key-123',
createdAt: 1000000,
updatedAt: 1000000
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const input = {
id: 'existing-id',
name: 'Test Profile',
baseUrl: 'not-a-url',
apiKey: 'sk-test-api-key-123'
};
await expect(updateProfile(input)).rejects.toThrow('Invalid base URL');
});
it('should throw error for invalid API key', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: 'existing-id',
name: 'Test Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test-api-key-123',
createdAt: 1000000,
updatedAt: 1000000
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const input = {
id: 'existing-id',
name: 'Test Profile',
baseUrl: 'https://api.example.com',
apiKey: 'too-short'
};
await expect(updateProfile(input)).rejects.toThrow('Invalid API key');
});
it('should throw error when profile not found', async () => {
const mockFile: ProfilesFile = {
profiles: [],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const input = {
id: 'non-existent-id',
name: 'Test Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test-api-key-123'
};
await expect(updateProfile(input)).rejects.toThrow('Profile not found');
});
});
describe('getAPIProfileEnv', () => {
it('should return empty object when no active profile (OAuth mode)', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: 'profile-1',
name: 'Test Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-test-key-12345678',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: null,
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const result = await getAPIProfileEnv();
expect(result).toEqual({});
});
it('should return correct env vars for active profile with all fields', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: 'profile-1',
name: 'Test Profile',
baseUrl: 'https://api.custom.com',
apiKey: 'sk-test-key-12345678',
models: {
default: 'claude-3-5-sonnet-20241022',
haiku: 'claude-3-5-haiku-20241022',
sonnet: 'claude-3-5-sonnet-20241022',
opus: 'claude-3-5-opus-20241022'
},
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: 'profile-1',
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const result = await getAPIProfileEnv();
expect(result).toEqual({
ANTHROPIC_BASE_URL: 'https://api.custom.com',
ANTHROPIC_AUTH_TOKEN: 'sk-test-key-12345678',
ANTHROPIC_MODEL: 'claude-3-5-sonnet-20241022',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-3-5-haiku-20241022',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-3-5-sonnet-20241022',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-3-5-opus-20241022'
});
});
it('should filter out empty string values', async () => {
const mockFile: ProfilesFile = {
profiles: [
{
id: 'profile-1',
name: 'Test Profile',
baseUrl: '',
apiKey: 'sk-test-key-12345678',
models: {
default: 'claude-3-5-sonnet-20241022',
haiku: '',
sonnet: ''
},
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: 'profile-1',
version: 1
};
const { loadProfilesFile } = await import('./profile-manager');
vi.mocked(loadProfilesFile).mockResolvedValue(mockFile);
const result = await getAPIProfileEnv();
expect(result).not.toHaveProperty('ANTHROPIC_BASE_URL');
expect(result).not.toHaveProperty('ANTHROPIC_DEFAULT_HAIKU_MODEL');
expect(result).not.toHaveProperty('ANTHROPIC_DEFAULT_SONNET_MODEL');
expect(result).toEqual({
ANTHROPIC_AUTH_TOKEN: 'sk-test-key-12345678',
ANTHROPIC_MODEL: 'claude-3-5-sonnet-20241022'
});
});
});
describe('testConnection', () => {
beforeEach(() => {
mockModelsList.mockReset();
mockMessagesCreate.mockReset();
});
// Helper to create mock errors with proper name property
const createMockError = (name: string, message: string) => {
const error = new Error(message);
error.name = name;
return error;
};
it('should return success for valid credentials (200 response)', async () => {
mockModelsList.mockResolvedValue({ data: [] });
const result = await testConnection('https://api.anthropic.com', 'sk-ant-test-key-12');
expect(result).toEqual({
success: true,
message: 'Connection successful'
});
});
it('should return auth error for invalid API key (401 response)', async () => {
mockModelsList.mockRejectedValue(createMockError('AuthenticationError', 'Unauthorized'));
const result = await testConnection('https://api.anthropic.com', 'sk-invalid-key-12');
expect(result).toEqual({
success: false,
errorType: 'auth',
message: 'Authentication failed. Please check your API key.'
});
});
it('should return network error for connection refused', async () => {
mockModelsList.mockRejectedValue(createMockError('APIConnectionError', 'ECONNREFUSED'));
const result = await testConnection('https://unreachable.example.com', 'sk-test-key-12chars');
expect(result).toEqual({
success: false,
errorType: 'network',
message: 'Network error. Please check your internet connection.'
});
});
it('should return timeout error for AbortError', async () => {
mockModelsList.mockRejectedValue(createMockError('APIConnectionTimeoutError', 'Timeout'));
const result = await testConnection('https://slow.example.com', 'sk-test-key-12chars');
expect(result).toEqual({
success: false,
errorType: 'timeout',
message: 'Connection timeout. The endpoint did not respond.'
});
});
it('should auto-prepend https:// if missing', async () => {
mockModelsList.mockResolvedValue({ data: [] });
const result = await testConnection('api.anthropic.com', 'sk-test-key-12chars');
expect(result).toEqual({
success: true,
message: 'Connection successful'
});
});
it('should return error for empty baseUrl', async () => {
const result = await testConnection('', 'sk-test-key-12chars');
expect(result).toEqual({
success: false,
errorType: 'endpoint',
message: 'Invalid endpoint. Please check the Base URL.'
});
expect(mockModelsList).not.toHaveBeenCalled();
});
it('should return error for invalid API key format', async () => {
const result = await testConnection('https://api.anthropic.com', 'short');
expect(result).toEqual({
success: false,
errorType: 'auth',
message: 'Authentication failed. Please check your API key.'
});
expect(mockModelsList).not.toHaveBeenCalled();
});
});
describe('discoverModels', () => {
beforeEach(() => {
mockModelsList.mockReset();
});
// Helper to create mock errors with proper name property
const createMockError = (name: string, message: string) => {
const error = new Error(message);
error.name = name;
return error;
};
it('should return list of models for successful response', async () => {
mockModelsList.mockResolvedValue({
data: [
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5', created_at: '2024-10-22', type: 'model' },
{ id: 'claude-3-5-haiku-20241022', display_name: 'Claude Haiku 3.5', created_at: '2024-10-22', type: 'model' }
]
});
const result = await discoverModels('https://api.anthropic.com', 'sk-ant-test-key-12');
expect(result).toEqual({
models: [
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' },
{ id: 'claude-3-5-haiku-20241022', display_name: 'Claude Haiku 3.5' }
]
});
});
it('should throw auth error for 401 response', async () => {
mockModelsList.mockRejectedValue(createMockError('AuthenticationError', 'Unauthorized'));
const error = await discoverModels('https://api.anthropic.com', 'sk-invalid-key')
.catch(e => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error & { errorType?: string }).errorType).toBe('auth');
});
it('should throw not_supported error for 404 response', async () => {
mockModelsList.mockRejectedValue(createMockError('NotFoundError', 'Not Found'));
const error = await discoverModels('https://custom-api.com', 'sk-test-key-12345678')
.catch(e => e);
expect(error).toBeInstanceOf(Error);
expect((error as Error & { errorType?: string }).errorType).toBe('not_supported');
});
it('should auto-prepend https:// if missing', async () => {
mockModelsList.mockResolvedValue({ data: [] });
const result = await discoverModels('api.anthropic.com', 'sk-test-key-12chars');
expect(result).toEqual({ models: [] });
});
});
});
@@ -1,613 +0,0 @@
/**
* Profile Service - Validation and profile creation
*
* Provides validation functions for URL, API key, and profile name uniqueness.
* Handles creating new profiles with validation.
* Uses atomic operations with file locking to prevent TOCTOU race conditions.
*/
import Anthropic, {
AuthenticationError,
NotFoundError,
APIConnectionError,
APIConnectionTimeoutError
} from '@anthropic-ai/sdk';
import { loadProfilesFile, generateProfileId, atomicModifyProfiles } from './profile-manager';
import type { APIProfile, TestConnectionResult, ModelInfo, DiscoverModelsResult } from '@shared/types/profile';
/**
* Input type for creating a profile (without id, createdAt, updatedAt)
*/
export type CreateProfileInput = Omit<APIProfile, 'id' | 'createdAt' | 'updatedAt'>;
/**
* Input type for updating a profile (with id, without createdAt, updatedAt)
*/
export type UpdateProfileInput = Pick<APIProfile, 'id'> & CreateProfileInput;
/**
* Validate base URL format
* Accepts HTTP(S) URLs with valid endpoints
*/
export function validateBaseUrl(baseUrl: string): boolean {
if (!baseUrl || baseUrl.trim() === '') {
return false;
}
try {
const url = new URL(baseUrl);
// Only allow http and https protocols
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
/**
* Validate API key format
* Accepts various API key formats (Anthropic, OpenAI, custom)
*/
export function validateApiKey(apiKey: string): boolean {
if (!apiKey || apiKey.trim() === '') {
return false;
}
const trimmed = apiKey.trim();
// Too short to be a real API key
if (trimmed.length < 12) {
return false;
}
// Accept common API key formats
// Anthropic: sk-ant-...
// OpenAI: sk-proj-... or sk-...
// Custom: any reasonable length key with alphanumeric chars
const hasValidChars = /^[a-zA-Z0-9\-_+.]+$/.test(trimmed);
return hasValidChars;
}
/**
* Validate that profile name is unique (case-insensitive, trimmed)
*
* WARNING: This is for UX feedback only. Do NOT rely on this for correctness.
* The actual uniqueness check happens atomically inside create/update operations
* to prevent TOCTOU race conditions.
*/
export async function validateProfileNameUnique(name: string): Promise<boolean> {
const trimmed = name.trim().toLowerCase();
const file = await loadProfilesFile();
// Check if any profile has the same name (case-insensitive)
const exists = file.profiles.some(
(p) => p.name.trim().toLowerCase() === trimmed
);
return !exists;
}
/**
* Delete a profile with validation
* Throws errors for validation failures
* Uses atomic operation to prevent race conditions
*/
export async function deleteProfile(id: string): Promise<void> {
await atomicModifyProfiles((file) => {
// Find the profile
const profileIndex = file.profiles.findIndex((p) => p.id === id);
if (profileIndex === -1) {
throw new Error('Profile not found');
}
// Active Profile Check: Cannot delete active profile (AC3)
if (file.activeProfileId === id) {
throw new Error('Cannot delete active profile. Please switch to another profile or OAuth first.');
}
// Remove profile
file.profiles.splice(profileIndex, 1);
// Last Profile Fallback: If no profiles remain, set activeProfileId to null (AC4)
if (file.profiles.length === 0) {
file.activeProfileId = null;
}
return file;
});
}
/**
* Create a new profile with validation
* Throws errors for validation failures
* Uses atomic operation to prevent race conditions in concurrent profile creation
*/
export async function createProfile(input: CreateProfileInput): Promise<APIProfile> {
// Validate base URL
if (!validateBaseUrl(input.baseUrl)) {
throw new Error('Invalid base URL');
}
// Validate API key
if (!validateApiKey(input.apiKey)) {
throw new Error('Invalid API key');
}
// Use atomic operation to ensure uniqueness check and creation happen together
// This prevents TOCTOU race where another process creates the same profile name
// between our check and write
const newProfile = await atomicModifyProfiles((file) => {
// Re-check uniqueness within the lock (this is the authoritative check)
const trimmed = input.name.trim().toLowerCase();
const exists = file.profiles.some(
(p) => p.name.trim().toLowerCase() === trimmed
);
if (exists) {
throw new Error('A profile with this name already exists');
}
// Create new profile
const now = Date.now();
const profile: APIProfile = {
id: generateProfileId(),
name: input.name.trim(),
baseUrl: input.baseUrl.trim(),
apiKey: input.apiKey.trim(),
models: input.models,
createdAt: now,
updatedAt: now
};
// Add to profiles list
file.profiles.push(profile);
// Set as active if it's the first profile
if (file.profiles.length === 1) {
file.activeProfileId = profile.id;
}
return file;
});
// Find and return the newly created profile
const createdProfile = newProfile.profiles[newProfile.profiles.length - 1];
return createdProfile;
}
/**
* Update an existing profile with validation
* Throws errors for validation failures
* Uses atomic operation to prevent race conditions in concurrent profile updates
*/
export async function updateProfile(input: UpdateProfileInput): Promise<APIProfile> {
// Validate base URL
if (!validateBaseUrl(input.baseUrl)) {
throw new Error('Invalid base URL');
}
// Validate API key
if (!validateApiKey(input.apiKey)) {
throw new Error('Invalid API key');
}
// Use atomic operation to ensure uniqueness check and update happen together
const modifiedFile = await atomicModifyProfiles((file) => {
// Find the profile
const profileIndex = file.profiles.findIndex((p) => p.id === input.id);
if (profileIndex === -1) {
throw new Error('Profile not found');
}
const existingProfile = file.profiles[profileIndex];
// Validate profile name uniqueness (exclude current profile from check)
// This check happens atomically within the lock
if (input.name.trim().toLowerCase() !== existingProfile.name.trim().toLowerCase()) {
const trimmed = input.name.trim().toLowerCase();
const nameExists = file.profiles.some(
(p) => p.id !== input.id && p.name.trim().toLowerCase() === trimmed
);
if (nameExists) {
throw new Error('A profile with this name already exists');
}
}
// Update profile (including name)
const updated: APIProfile = {
...existingProfile,
name: input.name.trim(),
baseUrl: input.baseUrl.trim(),
apiKey: input.apiKey.trim(),
models: input.models,
updatedAt: Date.now()
};
// Replace in profiles list
file.profiles[profileIndex] = updated;
return file;
});
// Find and return the updated profile
const updatedProfile = modifiedFile.profiles.find((p) => p.id === input.id)!;
return updatedProfile;
}
/**
* Get environment variables for the active API profile
*
* Maps the active API profile to SDK environment variables for injection
* into Python subprocess. Returns empty object when no profile is active
* (OAuth mode), allowing CLAUDE_CODE_OAUTH_TOKEN to be used instead.
*
* Environment Variable Mapping:
* - profile.baseUrl → ANTHROPIC_BASE_URL
* - profile.apiKey → ANTHROPIC_AUTH_TOKEN
* - profile.models.default → ANTHROPIC_MODEL
* - profile.models.haiku → ANTHROPIC_DEFAULT_HAIKU_MODEL
* - profile.models.sonnet → ANTHROPIC_DEFAULT_SONNET_MODEL
* - profile.models.opus → ANTHROPIC_DEFAULT_OPUS_MODEL
*
* Empty string values are filtered out (not set as env vars).
*
* @returns Promise<Record<string, string>> Environment variables for active profile
*/
export async function getAPIProfileEnv(): Promise<Record<string, string>> {
// Load profiles.json
const file = await loadProfilesFile();
// If no active profile (null/empty), return empty object (OAuth mode)
if (!file.activeProfileId || file.activeProfileId === '') {
return {};
}
// Find active profile by activeProfileId
const profile = file.profiles.find((p) => p.id === file.activeProfileId);
// If profile not found, return empty object (shouldn't happen with valid data)
if (!profile) {
return {};
}
// Map profile fields to SDK env vars
const envVars: Record<string, string> = {
ANTHROPIC_BASE_URL: profile.baseUrl || '',
ANTHROPIC_AUTH_TOKEN: profile.apiKey || '',
ANTHROPIC_MODEL: profile.models?.default || '',
ANTHROPIC_DEFAULT_HAIKU_MODEL: profile.models?.haiku || '',
ANTHROPIC_DEFAULT_SONNET_MODEL: profile.models?.sonnet || '',
ANTHROPIC_DEFAULT_OPUS_MODEL: profile.models?.opus || '',
};
// Filter out empty/whitespace string values (only set env vars that have values)
// This handles empty strings, null, undefined, and whitespace-only values
const filteredEnvVars: Record<string, string> = {};
for (const [key, value] of Object.entries(envVars)) {
const trimmedValue = value?.trim();
if (trimmedValue && trimmedValue !== '') {
filteredEnvVars[key] = trimmedValue;
}
}
return filteredEnvVars;
}
/**
* Test API profile connection
*
* Validates credentials by making a minimal API request to the /v1/models endpoint.
* Uses the Anthropic SDK for built-in timeout, retry, and error handling.
*
* @param baseUrl - API base URL (will be normalized)
* @param apiKey - API key for authentication
* @param signal - Optional AbortSignal for cancelling the request
* @returns Promise<TestConnectionResult> Result of connection test
*/
export async function testConnection(
baseUrl: string,
apiKey: string,
signal?: AbortSignal
): Promise<TestConnectionResult> {
// Validate API key first (key format doesn't depend on URL normalization)
if (!validateApiKey(apiKey)) {
return {
success: false,
errorType: 'auth',
message: 'Authentication failed. Please check your API key.'
};
}
// Normalize baseUrl BEFORE validation (allows auto-prepending https://)
let normalizedUrl = baseUrl.trim();
// Store original URL for error suggestions
const originalUrl = normalizedUrl;
// If empty, return error
if (!normalizedUrl) {
return {
success: false,
errorType: 'endpoint',
message: 'Invalid endpoint. Please check the Base URL.'
};
}
// Ensure https:// prefix (auto-prepend if NO protocol exists)
if (!normalizedUrl.includes('://')) {
normalizedUrl = `https://${normalizedUrl}`;
}
// Remove trailing slash
normalizedUrl = normalizedUrl.replace(/\/+$/, '');
// Helper function to generate URL suggestions
const getUrlSuggestions = (url: string): string[] => {
const suggestions: string[] = [];
if (!url.includes('://')) {
suggestions.push('Ensure URL starts with https://');
}
if (url.endsWith('/')) {
suggestions.push('Remove trailing slashes from URL');
}
const domainMatch = url.match(/:\/\/([^/]+)/);
if (domainMatch) {
const domain = domainMatch[1];
if (domain.includes('anthropiic') || domain.includes('anthhropic') ||
domain.includes('anhtropic') || domain.length < 10) {
suggestions.push('Check for typos in domain name');
}
}
return suggestions;
};
// Validate the normalized baseUrl
if (!validateBaseUrl(normalizedUrl)) {
const suggestions = getUrlSuggestions(originalUrl);
const message = suggestions.length > 0
? `Invalid endpoint. Please check the Base URL.${suggestions.map(s => ' ' + s).join('')}`
: 'Invalid endpoint. Please check the Base URL.';
return {
success: false,
errorType: 'endpoint',
message
};
}
// Check if signal already aborted
if (signal?.aborted) {
return {
success: false,
errorType: 'timeout',
message: 'Connection timeout. The endpoint did not respond.'
};
}
try {
// Create Anthropic client with SDK
const client = new Anthropic({
apiKey,
baseURL: normalizedUrl,
timeout: 10000, // 10 seconds
maxRetries: 0, // Disable retries for immediate feedback
});
// Make minimal request to test connection (pass signal for cancellation)
// Try models.list first, but some Anthropic-compatible APIs don't support it
try {
await client.models.list({ limit: 1 }, { signal: signal ?? undefined });
} catch (modelsError) {
// If models endpoint returns 404, try messages endpoint instead
// Many Anthropic-compatible APIs (e.g., MiniMax) only support /v1/messages
const modelsErrorName = modelsError instanceof Error ? modelsError.name : '';
if (modelsErrorName === 'NotFoundError' || modelsError instanceof NotFoundError) {
// Fall back to messages endpoint with minimal request
// This will fail with 400 (invalid request) but proves the endpoint is reachable
try {
await client.messages.create({
model: 'test',
max_tokens: 1,
messages: [{ role: 'user', content: 'test' }]
}, { signal: signal ?? undefined });
} catch (messagesError) {
const messagesErrorName = messagesError instanceof Error ? messagesError.name : '';
// 400/422 errors mean the endpoint is valid, just our test request was invalid
// This is expected - we're just testing connectivity
if (messagesErrorName === 'BadRequestError' ||
messagesErrorName === 'InvalidRequestError' ||
(messagesError instanceof Error && 'status' in messagesError &&
((messagesError as { status?: number }).status === 400 ||
(messagesError as { status?: number }).status === 422))) {
// Endpoint is valid, connection successful
return {
success: true,
message: 'Connection successful'
};
}
// Re-throw other errors to be handled by outer catch
throw messagesError;
}
// If messages.create somehow succeeded, connection is valid
return {
success: true,
message: 'Connection successful'
};
}
// Re-throw non-404 errors to be handled by outer catch
throw modelsError;
}
return {
success: true,
message: 'Connection successful'
};
} catch (error) {
// Map SDK errors to TestConnectionResult error types
// Use error.name for instanceof-like checks (works with mocks that set this.name)
const errorName = error instanceof Error ? error.name : '';
if (errorName === 'AuthenticationError' || error instanceof AuthenticationError) {
return {
success: false,
errorType: 'auth',
message: 'Authentication failed. Please check your API key.'
};
}
if (errorName === 'NotFoundError' || error instanceof NotFoundError) {
const suggestions = getUrlSuggestions(baseUrl.trim());
const message = suggestions.length > 0
? `Invalid endpoint. Please check the Base URL.${suggestions.map(s => ' ' + s).join('')}`
: 'Invalid endpoint. Please check the Base URL.';
return {
success: false,
errorType: 'endpoint',
message
};
}
if (errorName === 'APIConnectionTimeoutError' || error instanceof APIConnectionTimeoutError) {
return {
success: false,
errorType: 'timeout',
message: 'Connection timeout. The endpoint did not respond.'
};
}
if (errorName === 'APIConnectionError' || error instanceof APIConnectionError) {
return {
success: false,
errorType: 'network',
message: 'Network error. Please check your internet connection.'
};
}
// APIError or other errors
return {
success: false,
errorType: 'unknown',
message: 'Connection test failed. Please try again.'
};
}
}
/**
* Discover available models from API endpoint
*
* Fetches the list of available models from the Anthropic-compatible /v1/models endpoint.
* Uses the Anthropic SDK for built-in timeout, retry, and error handling.
*
* @param baseUrl - API base URL (will be normalized)
* @param apiKey - API key for authentication
* @param signal - Optional AbortSignal for cancelling the request (checked before request)
* @returns Promise<DiscoverModelsResult> List of available models
* @throws Error with errorType for auth/network/endpoint/timeout/not_supported failures
*/
export async function discoverModels(
baseUrl: string,
apiKey: string,
signal?: AbortSignal
): Promise<DiscoverModelsResult> {
// Validate API key first
if (!validateApiKey(apiKey)) {
const error: Error & { errorType?: string } = new Error('Authentication failed. Please check your API key.');
error.errorType = 'auth';
throw error;
}
// Normalize baseUrl BEFORE validation
let normalizedUrl = baseUrl.trim();
// If empty, throw error
if (!normalizedUrl) {
const error: Error & { errorType?: string } = new Error('Invalid endpoint. Please check the Base URL.');
error.errorType = 'endpoint';
throw error;
}
// Ensure https:// prefix (auto-prepend if NO protocol exists)
if (!normalizedUrl.includes('://')) {
normalizedUrl = `https://${normalizedUrl}`;
}
// Remove trailing slash
normalizedUrl = normalizedUrl.replace(/\/+$/, '');
// Validate the normalized baseUrl
if (!validateBaseUrl(normalizedUrl)) {
const error: Error & { errorType?: string } = new Error('Invalid endpoint. Please check the Base URL.');
error.errorType = 'endpoint';
throw error;
}
// Check if signal already aborted
if (signal?.aborted) {
const error: Error & { errorType?: string } = new Error('Connection timeout. The endpoint did not respond.');
error.errorType = 'timeout';
throw error;
}
try {
// Create Anthropic client with SDK
const client = new Anthropic({
apiKey,
baseURL: normalizedUrl,
timeout: 10000, // 10 seconds
maxRetries: 0, // Disable retries for immediate feedback
});
// Fetch models with pagination (1000 limit to get all), pass signal for cancellation
const response = await client.models.list({ limit: 1000 }, { signal: signal ?? undefined });
// Extract model information from SDK response
const models: ModelInfo[] = response.data
.map((model) => ({
id: model.id || '',
display_name: model.display_name || model.id || ''
}))
.filter((model) => model.id.length > 0);
return { models };
} catch (error) {
// Map SDK errors to thrown errors with errorType property
// Use error.name for instanceof-like checks (works with mocks that set this.name)
const errorName = error instanceof Error ? error.name : '';
if (errorName === 'AuthenticationError' || error instanceof AuthenticationError) {
const authError: Error & { errorType?: string } = new Error('Authentication failed. Please check your API key.');
authError.errorType = 'auth';
throw authError;
}
if (errorName === 'NotFoundError' || error instanceof NotFoundError) {
const notSupportedError: Error & { errorType?: string } = new Error('This API endpoint does not support model listing. Please enter the model name manually.');
notSupportedError.errorType = 'not_supported';
throw notSupportedError;
}
if (errorName === 'APIConnectionTimeoutError' || error instanceof APIConnectionTimeoutError) {
const timeoutError: Error & { errorType?: string } = new Error('Connection timeout. The endpoint did not respond.');
timeoutError.errorType = 'timeout';
throw timeoutError;
}
if (errorName === 'APIConnectionError' || error instanceof APIConnectionError) {
const networkError: Error & { errorType?: string } = new Error('Network error. Please check your internet connection.');
networkError.errorType = 'network';
throw networkError;
}
// APIError or other errors
const unknownError: Error & { errorType?: string } = new Error('Connection test failed. Please try again.');
unknownError.errorType = 'unknown';
throw unknownError;
}
}
@@ -1,199 +0,0 @@
/**
* Tests for profile-manager.ts
*
* Red phase - write failing tests first
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { promises as fsPromises } from 'fs';
import path from 'path';
import { app } from 'electron';
import {
loadProfilesFile,
saveProfilesFile,
generateProfileId,
validateFilePermissions
} from './profile-manager';
import type { ProfilesFile } from '../../shared/types/profile';
// Mock Electron app.getPath
vi.mock('electron', () => ({
app: {
getPath: vi.fn((name: string) => {
if (name === 'userData') {
return '/mock/userdata';
}
return '/mock/path';
})
}
}));
// Mock fs module - mock the promises export which is used by profile-manager.ts
vi.mock('fs', () => {
const promises = {
readFile: vi.fn(),
writeFile: vi.fn(),
mkdir: vi.fn(),
chmod: vi.fn()
};
return {
default: { promises }, // Default export contains promises
promises, // Named export for promises
existsSync: vi.fn(),
constants: {
O_RDONLY: 0,
S_IRUSR: 0o400
}
};
});
describe('profile-manager', () => {
const mockProfilesPath = '/mock/userdata/profiles.json';
beforeEach(() => {
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
});
describe('loadProfilesFile', () => {
it('should return default profiles file when file does not exist', async () => {
vi.mocked(fsPromises.readFile).mockRejectedValue(new Error('ENOENT'));
const result = await loadProfilesFile();
expect(result).toEqual({
profiles: [],
activeProfileId: null,
version: 1
});
});
it('should return default profiles file when file is corrupted JSON', async () => {
vi.mocked(fsPromises.readFile).mockResolvedValue(Buffer.from('invalid json{'));
const result = await loadProfilesFile();
expect(result).toEqual({
profiles: [],
activeProfileId: null,
version: 1
});
});
it('should load valid profiles file', async () => {
const mockData: ProfilesFile = {
profiles: [
{
id: 'test-id-1',
name: 'Test Profile',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: 'test-id-1',
version: 1
};
vi.mocked(fsPromises.readFile).mockResolvedValue(
Buffer.from(JSON.stringify(mockData))
);
const result = await loadProfilesFile();
expect(result).toEqual(mockData);
});
it('should use auto-claude directory for profiles.json path', async () => {
vi.mocked(fsPromises.readFile).mockRejectedValue(new Error('ENOENT'));
await loadProfilesFile();
// Verify the file path includes auto-claude
const readFileCalls = vi.mocked(fsPromises.readFile).mock.calls;
const filePath = readFileCalls[0]?.[0];
expect(filePath).toContain('auto-claude');
expect(filePath).toContain('profiles.json');
});
});
describe('saveProfilesFile', () => {
it('should write profiles file to disk', async () => {
const mockData: ProfilesFile = {
profiles: [],
activeProfileId: null,
version: 1
};
vi.mocked(fsPromises.writeFile).mockResolvedValue(undefined);
await saveProfilesFile(mockData);
expect(fsPromises.writeFile).toHaveBeenCalled();
const writeFileCall = vi.mocked(fsPromises.writeFile).mock.calls[0];
const filePath = writeFileCall?.[0];
const content = writeFileCall?.[1];
expect(filePath).toContain('auto-claude');
expect(filePath).toContain('profiles.json');
expect(content).toBe(JSON.stringify(mockData, null, 2));
});
it('should throw error when write fails', async () => {
const mockData: ProfilesFile = {
profiles: [],
activeProfileId: null,
version: 1
};
vi.mocked(fsPromises.writeFile).mockRejectedValue(new Error('Write failed'));
await expect(saveProfilesFile(mockData)).rejects.toThrow('Write failed');
});
});
describe('generateProfileId', () => {
it('should generate unique UUID v4 format IDs', () => {
const id1 = generateProfileId();
const id2 = generateProfileId();
// UUID v4 format: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
expect(id2).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/);
// IDs should be unique
expect(id1).not.toBe(id2);
});
it('should generate different IDs on consecutive calls', () => {
const ids = new Set<string>();
for (let i = 0; i < 100; i++) {
ids.add(generateProfileId());
}
expect(ids.size).toBe(100);
});
});
describe('validateFilePermissions', () => {
it('should validate user-readable only file permissions', async () => {
// Mock successful chmod
vi.mocked(fsPromises.chmod).mockResolvedValue(undefined);
const result = await validateFilePermissions('/mock/path/to/file.json');
expect(result).toBe(true);
});
it('should return false if chmod fails', async () => {
vi.mocked(fsPromises.chmod).mockRejectedValue(new Error('Permission denied'));
const result = await validateFilePermissions('/mock/path/to/file.json');
expect(result).toBe(false);
});
});
});
@@ -1,90 +0,0 @@
/**
* Profile Manager - File I/O for API profiles
*
* Handles loading and saving profiles.json from the auto-claude directory.
* Provides graceful handling for missing or corrupted files.
*/
import { promises as fs } from 'fs';
import path from 'path';
import { app } from 'electron';
import type { ProfilesFile } from '../../shared/types/profile';
/**
* Get the path to profiles.json in the auto-claude directory
*/
export function getProfilesFilePath(): string {
const userDataPath = app.getPath('userData');
return path.join(userDataPath, 'auto-claude', 'profiles.json');
}
/**
* Load profiles.json from disk
* Returns default empty profiles file if file doesn't exist or is corrupted
*/
export async function loadProfilesFile(): Promise<ProfilesFile> {
const filePath = getProfilesFilePath();
try {
const content = await fs.readFile(filePath, 'utf-8');
const data = JSON.parse(content) as ProfilesFile;
return data;
} catch (error) {
// File doesn't exist or is corrupted - return default
return {
profiles: [],
activeProfileId: null,
version: 1
};
}
}
/**
* Save profiles.json to disk
* Creates the auto-claude directory if it doesn't exist
*/
export async function saveProfilesFile(data: ProfilesFile): Promise<void> {
const filePath = getProfilesFilePath();
const dir = path.dirname(filePath);
// Ensure directory exists
try {
await fs.mkdir(dir, { recursive: true });
} catch (error) {
// Only ignore EEXIST errors (directory already exists)
// Rethrow other errors (e.g., permission issues)
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') {
throw error;
}
}
// Write file with formatted JSON
const content = JSON.stringify(data, null, 2);
await fs.writeFile(filePath, content, 'utf-8');
}
/**
* Generate a unique UUID v4 for a new profile
*/
export function generateProfileId(): string {
// Generate UUID v4
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (Math.random() * 16) | 0;
const v = c === 'x' ? r : (r & 0x3) | 0x8;
return v.toString(16);
});
}
/**
* Validate and set file permissions to user-readable only
* Returns true if successful, false otherwise
*/
export async function validateFilePermissions(filePath: string): Promise<boolean> {
try {
// Set file permissions to user-readable only (0600)
await fs.chmod(filePath, 0o600);
return true;
} catch {
return false;
}
}
+1 -6
View File
@@ -12,7 +12,6 @@ import { GitLabAPI, createGitLabAPI } from './modules/gitlab-api';
import { DebugAPI, createDebugAPI } from './modules/debug-api';
import { ClaudeCodeAPI, createClaudeCodeAPI } from './modules/claude-code-api';
import { McpAPI, createMcpAPI } from './modules/mcp-api';
import { ProfileAPI, createProfileAPI } from './profile-api';
export interface ElectronAPI extends
ProjectAPI,
@@ -27,8 +26,7 @@ export interface ElectronAPI extends
GitLabAPI,
DebugAPI,
ClaudeCodeAPI,
McpAPI,
ProfileAPI {
McpAPI {
github: GitHubAPI;
}
@@ -46,7 +44,6 @@ export const createElectronAPI = (): ElectronAPI => ({
...createDebugAPI(),
...createClaudeCodeAPI(),
...createMcpAPI(),
...createProfileAPI(),
github: createGitHubAPI()
});
@@ -61,7 +58,6 @@ export {
createIdeationAPI,
createInsightsAPI,
createAppUpdateAPI,
createProfileAPI,
createGitHubAPI,
createGitLabAPI,
createDebugAPI,
@@ -79,7 +75,6 @@ export type {
IdeationAPI,
InsightsAPI,
AppUpdateAPI,
ProfileAPI,
GitHubAPI,
GitLabAPI,
DebugAPI,
@@ -1,144 +0,0 @@
import { ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../../shared/constants';
import type { IPCResult } from '../../shared/types';
import type {
APIProfile,
ProfileFormData,
ProfilesFile,
TestConnectionResult,
DiscoverModelsResult
} from '@shared/types/profile';
export interface ProfileAPI {
// Get all profiles
getAPIProfiles: () => Promise<IPCResult<ProfilesFile>>;
// Save/create a profile
saveAPIProfile: (
profile: ProfileFormData
) => Promise<IPCResult<APIProfile>>;
// Update an existing profile
updateAPIProfile: (
profile: APIProfile
) => Promise<IPCResult<APIProfile>>;
// Delete a profile
deleteAPIProfile: (profileId: string) => Promise<IPCResult>;
// Set active profile (null to switch to OAuth)
setActiveAPIProfile: (profileId: string | null) => Promise<IPCResult>;
// Test API profile connection
testConnection: (
baseUrl: string,
apiKey: string,
signal?: AbortSignal
) => Promise<IPCResult<TestConnectionResult>>;
// Discover available models from API
discoverModels: (
baseUrl: string,
apiKey: string,
signal?: AbortSignal
) => Promise<IPCResult<DiscoverModelsResult>>;
}
let testConnectionRequestId = 0;
let discoverModelsRequestId = 0;
export const createProfileAPI = (): ProfileAPI => ({
// Get all profiles
getAPIProfiles: (): Promise<IPCResult<ProfilesFile>> =>
ipcRenderer.invoke(IPC_CHANNELS.PROFILES_GET),
// Save/create a profile
saveAPIProfile: (
profile: ProfileFormData
): Promise<IPCResult<APIProfile>> =>
ipcRenderer.invoke(IPC_CHANNELS.PROFILES_SAVE, profile),
// Update an existing profile
updateAPIProfile: (
profile: APIProfile
): Promise<IPCResult<APIProfile>> =>
ipcRenderer.invoke(IPC_CHANNELS.PROFILES_UPDATE, profile),
// Delete a profile
deleteAPIProfile: (profileId: string): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.PROFILES_DELETE, profileId),
// Set active profile (null to switch to OAuth)
setActiveAPIProfile: (profileId: string | null): Promise<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.PROFILES_SET_ACTIVE, profileId),
// Test API profile connection
testConnection: (
baseUrl: string,
apiKey: string,
signal?: AbortSignal
): Promise<IPCResult<TestConnectionResult>> => {
const requestId = ++testConnectionRequestId;
// Check if already aborted before initiating request
if (signal && signal.aborted) {
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
}
// Setup abort listener AFTER checking aborted status to avoid race condition
if (signal && typeof signal.addEventListener === 'function') {
try {
signal.addEventListener('abort', () => {
ipcRenderer.send(IPC_CHANNELS.PROFILES_TEST_CONNECTION_CANCEL, requestId);
}, { once: true });
} catch (err) {
console.error('[preload/profile-api] Error adding abort listener:', err);
}
} else if (signal) {
console.warn('[preload/profile-api] signal provided but addEventListener not available - signal may have been serialized');
}
return ipcRenderer.invoke(IPC_CHANNELS.PROFILES_TEST_CONNECTION, baseUrl, apiKey, requestId);
},
// Discover available models from API
discoverModels: (
baseUrl: string,
apiKey: string,
signal?: AbortSignal
): Promise<IPCResult<DiscoverModelsResult>> => {
console.log('[preload/profile-api] discoverModels START');
console.log('[preload/profile-api] baseUrl, apiKey:', baseUrl, apiKey?.slice(-4));
const requestId = ++discoverModelsRequestId;
console.log('[preload/profile-api] Request ID:', requestId);
// Check if already aborted before initiating request
if (signal && signal.aborted) {
console.log('[preload/profile-api] Already aborted, rejecting');
return Promise.reject(new DOMException('The operation was aborted.', 'AbortError'));
}
// Setup abort listener AFTER checking aborted status to avoid race condition
if (signal && typeof signal.addEventListener === 'function') {
console.log('[preload/profile-api] Setting up abort listener...');
try {
signal.addEventListener('abort', () => {
console.log('[preload/profile-api] Abort signal received for request:', requestId);
ipcRenderer.send(IPC_CHANNELS.PROFILES_DISCOVER_MODELS_CANCEL, requestId);
}, { once: true });
console.log('[preload/profile-api] Abort listener added successfully');
} catch (err) {
console.error('[preload/profile-api] Error adding abort listener:', err);
}
} else if (signal) {
console.warn('[preload/profile-api] signal provided but addEventListener not available - signal may have been serialized');
}
const channel = 'profiles:discover-models';
console.log('[preload/profile-api] About to invoke IPC channel:', channel);
const promise = ipcRenderer.invoke(channel, baseUrl, apiKey, requestId);
console.log('[preload/profile-api] IPC invoke called, promise returned');
return promise;
}
});
+3 -27
View File
@@ -16,7 +16,6 @@ import {
} from '@dnd-kit/sortable';
import { TooltipProvider } from './components/ui/tooltip';
import { Button } from './components/ui/button';
import { Toaster } from './components/ui/toaster';
import {
Dialog,
DialogContent,
@@ -52,8 +51,7 @@ import { ProactiveSwapListener } from './components/ProactiveSwapListener';
import { GitHubSetupModal } from './components/GitHubSetupModal';
import { useProjectStore, loadProjects, addProject, initializeProject, removeProject } from './stores/project-store';
import { useTaskStore, loadTasks } from './stores/task-store';
import { useSettingsStore, loadSettings, loadProfiles } from './stores/settings-store';
import { useClaudeProfileStore } from './stores/claude-profile-store';
import { useSettingsStore, loadSettings } from './stores/settings-store';
import { useTerminalStore, restoreTerminalSessions } from './stores/terminal-store';
import { initializeGitHubListeners } from './stores/github';
import { initDownloadProgressListener } from './stores/download-store';
@@ -121,13 +119,6 @@ export function App() {
const settings = useSettingsStore((state) => state.settings);
const settingsLoading = useSettingsStore((state) => state.isLoading);
// API Profile state
const profiles = useSettingsStore((state) => state.profiles);
const activeProfileId = useSettingsStore((state) => state.activeProfileId);
// Claude Profile state (OAuth)
const claudeProfiles = useClaudeProfileStore((state) => state.profiles);
// UI State
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [isNewTaskDialogOpen, setIsNewTaskDialogOpen] = useState(false);
@@ -176,7 +167,6 @@ export function App() {
useEffect(() => {
loadProjects();
loadSettings();
loadProfiles();
// Initialize global GitHub listeners (PR reviews, etc.) so they persist across navigation
initializeGitHubListeners();
// Initialize global download progress listener for Ollama model downloads
@@ -249,21 +239,10 @@ export function App() {
// First-run detection - show onboarding wizard if not completed
// Only check AFTER settings have been loaded from disk to avoid race condition
useEffect(() => {
// Check if either auth method is configured
// API profiles: if profiles exist, auth is configured (user has gone through setup)
const hasAPIProfileConfigured = profiles.length > 0;
const hasOAuthConfigured = claudeProfiles.some(p =>
p.oauthToken || (p.isDefault && p.configDir)
);
const hasAnyAuth = hasAPIProfileConfigured || hasOAuthConfigured;
// Only show wizard if onboarding not completed AND no auth is configured
if (settingsHaveLoaded &&
settings.onboardingCompleted === false &&
!hasAnyAuth) {
if (settingsHaveLoaded && settings.onboardingCompleted === false) {
setIsOnboardingWizardOpen(true);
}
}, [settingsHaveLoaded, settings.onboardingCompleted, profiles, claudeProfiles]);
}, [settingsHaveLoaded, settings.onboardingCompleted]);
// Sync i18n language with settings
const { t, i18n } = useTranslation('dialogs');
@@ -1022,9 +1001,6 @@ export function App() {
{/* Global Download Indicator - shows Ollama model download progress */}
<GlobalDownloadIndicator />
{/* Toast notifications */}
<Toaster />
</div>
</TooltipProvider>
</ViewStateProvider>
@@ -1,143 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Tests for AuthStatusIndicator component
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen } from '@testing-library/react';
import { AuthStatusIndicator } from './AuthStatusIndicator';
import { useSettingsStore } from '../stores/settings-store';
import type { APIProfile } from '@shared/types/profile';
// Mock the settings store
vi.mock('../stores/settings-store', () => ({
useSettingsStore: vi.fn()
}));
/**
* Creates a mock settings store with optional overrides
* @param overrides - Partial store state to override defaults
* @returns Complete mock settings store object
*/
function createUseSettingsStoreMock(overrides?: Partial<ReturnType<typeof useSettingsStore>>) {
return {
profiles: testProfiles,
activeProfileId: null,
deleteProfile: vi.fn().mockResolvedValue(true),
setActiveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
settings: {} as any,
isLoading: false,
error: null,
setSettings: vi.fn(),
updateSettings: vi.fn(),
setLoading: vi.fn(),
setError: vi.fn(),
setProfiles: vi.fn(),
setProfilesLoading: vi.fn(),
setProfilesError: vi.fn(),
saveProfile: vi.fn().mockResolvedValue(true),
updateProfile: vi.fn().mockResolvedValue(true),
profilesError: null,
...overrides
};
}
// Test profile data
const testProfiles: APIProfile[] = [
{
id: 'profile-1',
name: 'Production API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-prod-key-1234',
models: { default: 'claude-3-5-sonnet-20241022' },
createdAt: Date.now(),
updatedAt: Date.now()
},
{
id: 'profile-2',
name: 'Development API',
baseUrl: 'https://dev-api.example.com/v1',
apiKey: 'sk-ant-test-key-5678',
models: undefined,
createdAt: Date.now(),
updatedAt: Date.now()
}
];
describe('AuthStatusIndicator', () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe('when using OAuth (no active profile)', () => {
beforeEach(() => {
vi.mocked(useSettingsStore).mockReturnValue(
createUseSettingsStoreMock({ activeProfileId: null })
);
});
it('should display OAuth with Lock icon', () => {
render(<AuthStatusIndicator />);
expect(screen.getByText('OAuth')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /authentication method: oauth/i })).toBeInTheDocument();
});
it('should have correct aria-label for OAuth', () => {
render(<AuthStatusIndicator />);
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication method: OAuth');
});
});
describe('when using API profile', () => {
beforeEach(() => {
vi.mocked(useSettingsStore).mockReturnValue(
createUseSettingsStoreMock({ activeProfileId: 'profile-1' })
);
});
it('should display profile name with Key icon', () => {
render(<AuthStatusIndicator />);
expect(screen.getByText('Production API')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /authentication method: production api/i })).toBeInTheDocument();
});
it('should have correct aria-label for profile', () => {
render(<AuthStatusIndicator />);
expect(screen.getByRole('button')).toHaveAttribute('aria-label', 'Authentication method: Production API');
});
});
describe('when active profile ID references non-existent profile', () => {
beforeEach(() => {
vi.mocked(useSettingsStore).mockReturnValue(
createUseSettingsStoreMock({ activeProfileId: 'non-existent-id' })
);
});
it('should fallback to OAuth display', () => {
render(<AuthStatusIndicator />);
expect(screen.getByText('OAuth')).toBeInTheDocument();
});
});
describe('component structure', () => {
beforeEach(() => {
vi.mocked(useSettingsStore).mockReturnValue(
createUseSettingsStoreMock()
);
});
it('should be a valid React component', () => {
expect(() => render(<AuthStatusIndicator />)).not.toThrow();
});
});
});
@@ -1,73 +0,0 @@
/**
* AuthStatusIndicator - Display current authentication method in header
*
* Shows the active authentication method:
* - API Profile name with Key icon when a profile is active
* - "OAuth" with Lock icon when using OAuth authentication
*/
import { useMemo } from 'react';
import { Key, Lock } from 'lucide-react';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from './ui/tooltip';
import { useSettingsStore } from '../stores/settings-store';
export function AuthStatusIndicator() {
// Subscribe to profile state from settings store
const { profiles, activeProfileId } = useSettingsStore();
// Compute auth status directly using useMemo to avoid unnecessary re-renders
const authStatus = useMemo(() => {
if (activeProfileId) {
const activeProfile = profiles.find(p => p.id === activeProfileId);
if (activeProfile) {
return { type: 'profile' as const, name: activeProfile.name };
}
// Profile ID set but profile not found - fallback to OAuth
return { type: 'oauth' as const, name: 'OAuth' };
}
return { type: 'oauth' as const, name: 'OAuth' };
}, [activeProfileId, profiles]);
const isOAuth = authStatus.type === 'oauth';
const Icon = isOAuth ? Lock : Key;
return (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 px-2.5 py-1.5 rounded-md border bg-primary/10 text-primary border-primary/20 transition-all hover:opacity-80 hover:bg-primary/15"
aria-label={`Authentication method: ${authStatus.name}`}
>
<Icon className="h-3.5 w-3.5" />
<span className="text-xs font-semibold">
{authStatus.name}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs max-w-xs">
<div className="space-y-1">
<div className="flex items-center justify-between gap-4">
<span className="text-muted-foreground font-medium">Authentication</span>
<span className="font-semibold">{isOAuth ? 'OAuth' : 'API Profile'}</span>
</div>
{!isOAuth && authStatus.name && (
<>
<div className="h-px bg-border" />
<div className="text-[10px] text-muted-foreground">
Using profile: <span className="text-foreground font-medium">{authStatus.name}</span>
</div>
</>
)}
</div>
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
@@ -277,7 +277,7 @@ const DroppableColumn = memo(function DroppableColumn({ status, tasks, onTaskCli
);
}, droppableColumnPropsAreEqual);
export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isRefreshing }: KanbanBoardProps) {
export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick }: KanbanBoardProps) {
const { t } = useTranslation('tasks');
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [overColumnId, setOverColumnId] = useState<string | null>(null);
@@ -412,21 +412,6 @@ export function KanbanBoard({ tasks, onTaskClick, onNewTaskClick, onRefresh, isR
return (
<div className="flex h-full flex-col">
{/* Kanban header with refresh button */}
{onRefresh && (
<div className="flex items-center justify-end px-6 pt-4 pb-2">
<Button
variant="ghost"
size="sm"
onClick={onRefresh}
disabled={isRefreshing}
className="gap-2 text-muted-foreground hover:text-foreground"
>
<RefreshCw className={cn("h-4 w-4", isRefreshing && "animate-spin")} />
{isRefreshing ? 'Refreshing...' : 'Refresh Tasks'}
</Button>
</div>
)}
{/* Kanban columns */}
<DndContext
sensors={sensors}
@@ -268,24 +268,15 @@ export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps)
onClick={onClick}
>
<CardContent className="p-4">
{/* Title - full width, no wrapper */}
<h3
className="font-semibold text-sm text-foreground line-clamp-2 leading-snug"
title={task.title}
>
{task.title}
</h3>
{/* Description - sanitized to handle markdown content (memoized) */}
{sanitizedDescription && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{sanitizedDescription}
</p>
)}
{/* Metadata badges */}
{(task.metadata || isStuck || isIncomplete || hasActiveExecution || reviewReasonInfo) && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{/* Header - improved visual hierarchy */}
<div className="flex items-start justify-between gap-3">
<h3
className="font-semibold text-sm text-foreground line-clamp-2 leading-snug flex-1 min-w-0"
title={task.title}
>
{task.title}
</h3>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end max-w-[180px]">
{/* Stuck indicator - highest priority */}
{isStuck && (
<Badge
@@ -347,8 +338,21 @@ export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps)
{reviewReasonInfo.label}
</Badge>
)}
</div>
</div>
{/* Description - sanitized to handle markdown content (memoized) */}
{sanitizedDescription && (
<p className="mt-2 text-xs text-muted-foreground line-clamp-2">
{sanitizedDescription}
</p>
)}
{/* Metadata badges */}
{task.metadata && (
<div className="mt-2.5 flex flex-wrap gap-1.5">
{/* Category badge with icon */}
{task.metadata?.category && (
{task.metadata.category && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_CATEGORY_COLORS[task.metadata.category])}
@@ -363,7 +367,7 @@ export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps)
</Badge>
)}
{/* Impact badge - high visibility for important tasks */}
{task.metadata?.impact && (task.metadata.impact === 'high' || task.metadata.impact === 'critical') && (
{task.metadata.impact && (task.metadata.impact === 'high' || task.metadata.impact === 'critical') && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_IMPACT_COLORS[task.metadata.impact])}
@@ -372,7 +376,7 @@ export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps)
</Badge>
)}
{/* Complexity badge */}
{task.metadata?.complexity && (
{task.metadata.complexity && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_COMPLEXITY_COLORS[task.metadata.complexity])}
@@ -381,7 +385,7 @@ export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps)
</Badge>
)}
{/* Priority badge - only show urgent/high */}
{task.metadata?.priority && (task.metadata.priority === 'urgent' || task.metadata.priority === 'high') && (
{task.metadata.priority && (task.metadata.priority === 'urgent' || task.metadata.priority === 'high') && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_PRIORITY_COLORS[task.metadata.priority])}
@@ -390,12 +394,12 @@ export const TaskCard = memo(function TaskCard({ task, onClick }: TaskCardProps)
</Badge>
)}
{/* Security severity - always show */}
{task.metadata?.securitySeverity && (
{task.metadata.securitySeverity && (
<Badge
variant="outline"
className={cn('text-[10px] px-1.5 py-0', TASK_IMPACT_COLORS[task.metadata.securitySeverity])}
>
{task.metadata.securitySeverity} {t('metadata.severity')}
{task.metadata.securitySeverity} severity
</Badge>
)}
</div>
@@ -1,5 +0,0 @@
// TODO: Define proper props interface when implementing
// Stub component - to be implemented
export function EnvConfigModal(_props: Record<string, unknown>) {
return null;
}
@@ -1,616 +0,0 @@
/**
* Unit tests for useIdeationAuth hook
* Tests combined authentication logic from source OAuth token and API profiles
*
* @vitest-environment jsdom
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
// Import browser mock to get full ElectronAPI structure
import '../../../../lib/browser-mock';
// Import the hook to test
import { useIdeationAuth } from '../useIdeationAuth';
// Import the store to set test state
import { useSettingsStore } from '../../../../stores/settings-store';
// Mock checkSourceToken function
const mockCheckSourceToken = vi.fn();
describe('useIdeationAuth', () => {
beforeEach(() => {
// Reset all mocks
vi.clearAllMocks();
// Reset store to initial state (minimal settings, actual settings loaded by store)
useSettingsStore.setState({
profiles: [],
activeProfileId: null,
profilesLoading: false,
profilesError: null,
isTestingConnection: false,
testConnectionResult: null
} as Partial<typeof useSettingsStore.getState>);
// Setup window.electronAPI mock
if (window.electronAPI) {
window.electronAPI.checkSourceToken = mockCheckSourceToken;
}
// Default mock implementation - has source token
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: true, sourcePath: '/mock/auto-claude' }
});
});
afterEach(() => {
vi.clearAllMocks();
});
describe('initial state and loading', () => {
it('should start with loading state', () => {
const { result } = renderHook(() => useIdeationAuth());
expect(result.current.isLoading).toBe(true);
expect(result.current.hasToken).toBe(null);
expect(result.current.error).toBe(null);
});
it('should complete loading after check', async () => {
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(true); // default mock has token
});
it('should provide checkAuth function', () => {
const { result } = renderHook(() => useIdeationAuth());
expect(typeof result.current.checkAuth).toBe('function');
});
});
describe('source OAuth token authentication', () => {
it('should return hasToken true when source OAuth token exists', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: true, sourcePath: '/mock/auto-claude' }
});
// No API profile active
useSettingsStore.setState({
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(true);
expect(mockCheckSourceToken).toHaveBeenCalled();
});
it('should return hasToken false when source OAuth token does not exist', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
// No API profile active
useSettingsStore.setState({
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(false);
});
it('should handle checkSourceToken API returning success: false gracefully', async () => {
mockCheckSourceToken.mockResolvedValue({
success: false,
error: 'Failed to check source token'
});
useSettingsStore.setState({
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// When API returns success: false, hasToken should be false (no exception thrown)
expect(result.current.hasToken).toBe(false);
expect(result.current.error).toBe(null); // No error set for API failure without exception
});
it('should handle checkSourceToken exception', async () => {
mockCheckSourceToken.mockRejectedValue(new Error('Network error'));
useSettingsStore.setState({
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(false);
expect(result.current.error).toBe('Network error');
});
});
describe('API profile authentication', () => {
it('should return hasToken true when API profile is active', async () => {
// Source token does not exist
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
// Active API profile
useSettingsStore.setState({
profiles: [{
id: 'profile-1',
name: 'Custom API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}],
activeProfileId: 'profile-1'
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(true);
});
it('should return hasToken false when no API profile is active', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
useSettingsStore.setState({
profiles: [{
id: 'profile-1',
name: 'Custom API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}],
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(false);
});
it('should return hasToken false when activeProfileId is empty string', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
useSettingsStore.setState({
profiles: [],
activeProfileId: ''
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(false);
});
});
describe('combined authentication (source token OR API profile)', () => {
it('should return hasToken true when both source token and API profile exist', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: true, sourcePath: '/mock/auto-claude' }
});
useSettingsStore.setState({
profiles: [{
id: 'profile-1',
name: 'Custom API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}],
activeProfileId: 'profile-1'
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(true);
});
it('should return hasToken true when only source token exists (no API profile)', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: true, sourcePath: '/mock/auto-claude' }
});
useSettingsStore.setState({
profiles: [],
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(true);
});
it('should return hasToken true when only API profile exists (no source token)', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
useSettingsStore.setState({
profiles: [{
id: 'profile-1',
name: 'Custom API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}],
activeProfileId: 'profile-1'
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(true);
});
it('should return hasToken false when neither source token nor API profile exists', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
useSettingsStore.setState({
profiles: [],
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(false);
});
});
describe('profile switching and re-checking', () => {
it('should re-check authentication when activeProfileId changes', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
const { result } = renderHook(() => useIdeationAuth());
// Initial state - no active profile
useSettingsStore.setState({
profiles: [{
id: 'profile-1',
name: 'Custom API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}],
activeProfileId: null
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(false);
// Switch to active profile
act(() => {
useSettingsStore.setState({
activeProfileId: 'profile-1'
});
});
await waitFor(() => {
expect(result.current.hasToken).toBe(true);
});
// Effect runs when activeProfileId changes
expect(mockCheckSourceToken).toHaveBeenCalled();
});
it('should re-check authentication when switching from API profile to none', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
const { result } = renderHook(() => useIdeationAuth());
// Initial state - active profile
useSettingsStore.setState({
profiles: [{
id: 'profile-1',
name: 'Custom API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}],
activeProfileId: 'profile-1'
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(true);
// Switch to no active profile
act(() => {
useSettingsStore.setState({
activeProfileId: null
});
});
await waitFor(() => {
expect(result.current.hasToken).toBe(false);
});
});
});
describe('manual checkAuth function', () => {
it('should manually re-check authentication when checkAuth is called', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
// Initial state - no active profile
useSettingsStore.setState({
profiles: [],
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(false);
// Update to have active profile
act(() => {
useSettingsStore.setState({
profiles: [{
id: 'profile-1',
name: 'Custom API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-test-key',
createdAt: Date.now(),
updatedAt: Date.now()
}],
activeProfileId: 'profile-1'
});
});
// Manually trigger re-check
act(() => {
result.current.checkAuth();
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(true);
});
it('should set loading state during manual checkAuth', async () => {
mockCheckSourceToken.mockImplementation(
() => new Promise(resolve => {
setTimeout(() => {
resolve({
success: true,
data: { hasToken: true }
});
}, 100);
})
);
useSettingsStore.setState({
profiles: [],
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
// Wait for initial check
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Trigger manual check
act(() => {
result.current.checkAuth();
});
expect(result.current.isLoading).toBe(true);
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
});
it('should clear error on successful manual re-check', async () => {
// First call throws error
mockCheckSourceToken.mockRejectedValueOnce(new Error('Network error'));
// Second call succeeds
mockCheckSourceToken.mockResolvedValueOnce({
success: true,
data: { hasToken: true }
});
useSettingsStore.setState({
profiles: [],
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBe('Network error');
// Manually re-check
act(() => {
result.current.checkAuth();
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBe(null);
expect(result.current.hasToken).toBe(true);
});
});
describe('edge cases', () => {
it('should handle activeProfileId as null', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: true }
});
useSettingsStore.setState({
profiles: [],
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Should still check source token
expect(result.current.hasToken).toBe(true);
});
it('should handle unknown error type in catch block', async () => {
mockCheckSourceToken.mockRejectedValue('string error');
useSettingsStore.setState({
profiles: [],
activeProfileId: null
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasToken).toBe(false);
expect(result.current.error).toBe('Unknown error');
});
it('should handle profiles array with API profiles', async () => {
mockCheckSourceToken.mockResolvedValue({
success: true,
data: { hasToken: false }
});
// Multiple profiles, one active
useSettingsStore.setState({
profiles: [
{
id: 'profile-1',
name: 'API 1',
baseUrl: 'https://api1.anthropic.com',
apiKey: 'sk-ant-key-1',
createdAt: Date.now(),
updatedAt: Date.now()
},
{
id: 'profile-2',
name: 'API 2',
baseUrl: 'https://api2.anthropic.com',
apiKey: 'sk-ant-key-2',
createdAt: Date.now(),
updatedAt: Date.now()
}
],
activeProfileId: 'profile-2'
});
const { result } = renderHook(() => useIdeationAuth());
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
// Has active profile
expect(result.current.hasToken).toBe(true);
});
});
});
@@ -1,69 +0,0 @@
import { useState, useEffect } from 'react';
import { useSettingsStore } from '../../../stores/settings-store';
/**
* Hook to check if the ideation feature has valid authentication.
* This combines two sources of authentication:
* 1. OAuth token from source .env (checked via checkSourceToken)
* 2. Active API profile (custom Anthropic-compatible endpoint)
*
* @returns { hasToken, isLoading, error, checkAuth }
* - hasToken: true if either source OAuth token exists OR active API profile is configured
* - isLoading: true while checking authentication status
* - error: any error that occurred during auth check
* - checkAuth: function to manually re-check authentication status
*/
export function useIdeationAuth() {
const [hasToken, setHasToken] = useState<boolean | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Get active API profile info from settings store
const activeProfileId = useSettingsStore((state) => state.activeProfileId);
useEffect(() => {
const performCheck = async () => {
setIsLoading(true);
setError(null);
try {
// Check for OAuth token from source .env
const sourceTokenResult = await window.electronAPI.checkSourceToken();
const hasSourceOAuthToken = sourceTokenResult.success && sourceTokenResult.data?.hasToken;
// Check if active API profile is configured
const hasAPIProfile = Boolean(activeProfileId && activeProfileId !== '');
// Auth is valid if either source token or API profile exists
setHasToken(hasSourceOAuthToken || hasAPIProfile);
} catch (err) {
setHasToken(false);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
performCheck();
}, [activeProfileId]);
// Expose checkAuth for manual re-checks
const checkAuth = async () => {
setIsLoading(true);
setError(null);
try {
const sourceTokenResult = await window.electronAPI.checkSourceToken();
const hasSourceOAuthToken = sourceTokenResult.success && sourceTokenResult.data?.hasToken;
const hasAPIProfile = Boolean(activeProfileId && activeProfileId !== '');
setHasToken(hasSourceOAuthToken || hasAPIProfile);
} catch (err) {
setHasToken(false);
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setIsLoading(false);
}
};
return { hasToken, isLoading, error, checkAuth };
}
@@ -1,321 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* AuthChoiceStep component tests
*
* Tests for the authentication choice step in the onboarding wizard.
* Verifies OAuth button, API Key button, skip button, and ProfileEditDialog integration.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { AuthChoiceStep } from './AuthChoiceStep';
import type { APIProfile } from '@shared/types/profile';
// Mock the settings store
const mockGoToNext = vi.fn();
const mockGoToPrevious = vi.fn();
const mockSkipWizard = vi.fn();
const mockOnAPIKeyPathComplete = vi.fn();
// Dynamic profiles state for testing
let mockProfiles: APIProfile[] = [];
const mockUseSettingsStore = (selector?: any) => {
const state = {
profiles: mockProfiles,
profilesLoading: false,
profilesError: null,
setProfiles: vi.fn((newProfiles) => { mockProfiles = newProfiles; }),
setProfilesLoading: vi.fn(),
setProfilesError: vi.fn(),
saveProfile: vi.fn(),
updateProfile: vi.fn(),
deleteProfile: vi.fn(),
setActiveProfile: vi.fn()
};
if (!selector || selector.toString().includes('profiles')) {
return state;
}
return selector(state);
};
vi.mock('../../stores/settings-store', () => ({
useSettingsStore: vi.fn((selector) => mockUseSettingsStore(selector))
}));
// Mock ProfileEditDialog
vi.mock('../settings/ProfileEditDialog', () => ({
ProfileEditDialog: ({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) => {
if (!open) return null;
return (
<div data-testid="profile-edit-dialog">
<button type="button" onClick={() => onOpenChange(false)}>Close Dialog</button>
</div>
);
}
}));
describe('AuthChoiceStep', () => {
beforeEach(() => {
vi.clearAllMocks();
// Reset profiles state to ensure clean state for each test
mockProfiles = [];
});
describe('Rendering', () => {
it('should render the auth choice step with all elements', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
// Check for heading
expect(screen.getByText('Choose Your Authentication Method')).toBeInTheDocument();
// Check for OAuth option
expect(screen.getByText('Sign in with Anthropic')).toBeInTheDocument();
// Check for API Key option
expect(screen.getByText('Use Custom API Key')).toBeInTheDocument();
// Check for skip button
expect(screen.getByText('Skip for now')).toBeInTheDocument();
});
it('should display two auth option cards with equal visual weight', () => {
const { container } = render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
// Check for grid layout with two columns
const grid = container.querySelector('.grid');
expect(grid).toBeInTheDocument();
expect(grid?.className).toContain('lg:grid-cols-2');
});
it('should show icons for each auth option', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
// Both cards should have icon containers
const iconContainers = document.querySelectorAll('.bg-primary\\/10');
expect(iconContainers.length).toBeGreaterThanOrEqual(2);
});
});
describe('OAuth Button Handler', () => {
it('should call onNext when OAuth button is clicked', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
const oauthButton = screen.getByText('Sign in with Anthropic').closest('.cursor-pointer');
fireEvent.click(oauthButton!);
expect(mockGoToNext).toHaveBeenCalledTimes(1);
});
it('should proceed to oauth step when OAuth is selected', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
const oauthButton = screen.getByText('Sign in with Anthropic').closest('.cursor-pointer');
fireEvent.click(oauthButton!);
expect(mockGoToNext).toHaveBeenCalled();
expect(mockOnAPIKeyPathComplete).not.toHaveBeenCalled();
});
});
describe('API Key Button Handler', () => {
it('should open ProfileEditDialog when API Key button is clicked', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
const apiKeyButton = screen.getByText('Use Custom API Key').closest('.cursor-pointer');
fireEvent.click(apiKeyButton!);
// ProfileEditDialog should be rendered
expect(screen.getByTestId('profile-edit-dialog')).toBeInTheDocument();
});
it('should accept onAPIKeyPathComplete callback prop', async () => {
// This test verifies the component accepts the callback prop
// Full integration testing of profile creation detection requires E2E tests
// due to the complex state management between dialog and store
mockProfiles = [];
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
onAPIKeyPathComplete={mockOnAPIKeyPathComplete}
/>
);
// Click API Key button to open dialog
const apiKeyButton = screen.getByText('Use Custom API Key').closest('.cursor-pointer');
fireEvent.click(apiKeyButton!);
// Dialog should be open - verifies the API key path works
expect(screen.getByTestId('profile-edit-dialog')).toBeInTheDocument();
// Close dialog without creating profile
const closeButton = screen.getByText('Close Dialog');
fireEvent.click(closeButton);
// Callback should NOT be called when no profile was created (profiles still empty)
expect(mockOnAPIKeyPathComplete).not.toHaveBeenCalled();
});
});
describe('Skip Button Handler', () => {
it('should call onSkip when skip button is clicked', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
const skipButton = screen.getByText('Skip for now');
fireEvent.click(skipButton);
expect(mockSkipWizard).toHaveBeenCalledTimes(1);
});
it('should have ghost variant for skip button', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
const skipButton = screen.getByText('Skip for now');
// Ghost variant buttons have specific styling classes
expect(skipButton.className).toContain('text-muted-foreground');
expect(skipButton.className).toContain('hover:text-foreground');
});
});
describe('Visual Consistency', () => {
it('should follow WelcomeStep visual pattern', () => {
const { container } = render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
// Check for container with proper classes
const mainContainer = container.querySelector('.flex.h-full.flex-col');
expect(mainContainer).toBeInTheDocument();
// Check for max-w-2xl content wrapper
const contentWrapper = container.querySelector('.max-w-2xl');
expect(contentWrapper).toBeInTheDocument();
// Check for centered text
const centeredText = container.querySelector('.text-center');
expect(centeredText).toBeInTheDocument();
});
it('should display hero icon with shield', () => {
const { container } = render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
// Shield icon should be in a circle
const heroIcon = container.querySelector('.h-16.w-16');
expect(heroIcon).toBeInTheDocument();
});
});
describe('Accessibility', () => {
it('should have descriptive text for each auth option', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
// OAuth option description
expect(screen.getByText(/Use your Anthropic account to authenticate/)).toBeInTheDocument();
// API Key option description
expect(screen.getByText(/Bring your own API key/)).toBeInTheDocument();
});
it('should have helper text explaining both options', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
expect(screen.getByText(/Both options provide full access to Claude Code features/)).toBeInTheDocument();
});
});
describe('AC Coverage', () => {
it('AC1: should display first-run screen with two clear options', () => {
render(
<AuthChoiceStep
onNext={mockGoToNext}
onBack={mockGoToPrevious}
onSkip={mockSkipWizard}
/>
);
// Two main options visible
expect(screen.getByText('Sign in with Anthropic')).toBeInTheDocument();
expect(screen.getByText('Use Custom API Key')).toBeInTheDocument();
// Both should be clickable cards
const cards = document.querySelectorAll('.cursor-pointer');
expect(cards.length).toBeGreaterThanOrEqual(2);
});
});
});
@@ -1,171 +0,0 @@
import { useState, useEffect, useRef } from 'react';
import { LogIn, Key, Shield } from 'lucide-react';
import { Button } from '../ui/button';
import { Card, CardContent } from '../ui/card';
import { ProfileEditDialog } from '../settings/ProfileEditDialog';
import { useSettingsStore } from '../../stores/settings-store';
interface AuthChoiceStepProps {
onNext: () => void;
onBack: () => void;
onSkip: () => void;
onAPIKeyPathComplete?: () => void; // Called when profile is created (skips oauth)
}
interface AuthOptionCardProps {
icon: React.ReactNode;
title: string;
description: string;
onClick: () => void;
variant?: 'default' | 'oauth';
'data-testid'?: string;
}
function AuthOptionCard({ icon, title, description, onClick, variant = 'default', 'data-testid': dataTestId }: AuthOptionCardProps) {
return (
<Card
data-testid={dataTestId}
className={`border border-border bg-card/50 backdrop-blur-sm cursor-pointer transition-all hover:border-primary/50 hover:shadow-md ${
variant === 'oauth' ? 'hover:bg-accent/5' : ''
}`}
onClick={onClick}
>
<CardContent className="p-6">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary">
{icon}
</div>
<div className="flex-1">
<h3 className="font-semibold text-foreground text-lg">{title}</h3>
<p className="mt-2 text-sm text-muted-foreground leading-relaxed">{description}</p>
</div>
</div>
</CardContent>
</Card>
);
}
/**
* AuthChoiceStep component for the onboarding wizard.
*
* Allows new users to choose between:
* 1. OAuth authentication (Sign in with Anthropic)
* 2. Custom API key authentication (Use Custom API Key)
*
* Features:
* - Two equal-weight authentication options
* - Skip button for users who want to configure later
* - API key path opens ProfileEditDialog for profile creation
* - OAuth path proceeds to OAuthStep
*
* AC Coverage:
* - AC1: Displays first-run screen with two clear options
*/
export function AuthChoiceStep({ onNext, onBack, onSkip, onAPIKeyPathComplete }: AuthChoiceStepProps) {
const [isProfileDialogOpen, setIsProfileDialogOpen] = useState(false);
const profiles = useSettingsStore((state) => state.profiles);
// Track initial profiles length to detect new profile creation
const initialProfilesLengthRef = useRef(profiles.length);
// Update the ref when profiles change (to track the initial state before dialog opened)
useEffect(() => {
// Only update the ref when dialog is NOT open
// This captures the state before user opens the dialog
if (!isProfileDialogOpen) {
initialProfilesLengthRef.current = profiles.length;
}
}, [profiles.length, isProfileDialogOpen]);
// OAuth button handler - proceeds to OAuth step
const handleOAuthChoice = () => {
onNext();
};
// API Key button handler - opens profile dialog
const handleAPIKeyChoice = () => {
setIsProfileDialogOpen(true);
};
// Profile dialog close handler - detects profile creation and skips oauth step
const handleProfileDialogClose = (open: boolean) => {
const wasEmpty = initialProfilesLengthRef.current === 0;
const hasProfilesNow = profiles.length > 0;
setIsProfileDialogOpen(open);
// If dialog closed and profile was created (was empty, now has profiles), skip to graphiti step
if (!open && wasEmpty && hasProfilesNow && onAPIKeyPathComplete) {
// Call the callback to skip oauth and go directly to graphiti
onAPIKeyPathComplete();
}
};
return (
<>
<div className="flex h-full flex-col items-center justify-center px-8 py-6">
<div className="w-full max-w-2xl">
{/* Hero Section */}
<div className="text-center mb-8">
<div className="flex justify-center mb-4">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Shield className="h-8 w-8 text-primary" />
</div>
</div>
<h1 className="text-3xl font-bold text-foreground tracking-tight">
Choose Your Authentication Method
</h1>
<p className="mt-3 text-muted-foreground text-lg">
Select how you want to authenticate with Claude. You can change this later in Settings.
</p>
</div>
{/* Authentication Options - Equal Visual Weight */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-10">
<AuthOptionCard
icon={<LogIn className="h-6 w-6" />}
title="Sign in with Anthropic"
description="Use your Anthropic account to authenticate. Simple and secure OAuth flow."
onClick={handleOAuthChoice}
variant="oauth"
data-testid="auth-option-oauth"
/>
<AuthOptionCard
icon={<Key className="h-6 w-6" />}
title="Use Custom API Key"
description="Bring your own API key from Anthropic or a compatible API provider."
onClick={handleAPIKeyChoice}
data-testid="auth-option-apikey"
/>
</div>
{/* Info text */}
<div className="text-center mb-8">
<p className="text-muted-foreground text-sm">
Both options provide full access to Claude Code features. Choose based on your preference.
</p>
</div>
{/* Skip Button */}
<div className="flex justify-center">
<Button
size="lg"
variant="ghost"
onClick={onSkip}
className="text-muted-foreground hover:text-foreground"
>
Skip for now
</Button>
</div>
</div>
</div>
{/* Profile Edit Dialog for API Key Path */}
<ProfileEditDialog
open={isProfileDialogOpen}
onOpenChange={handleProfileDialogClose}
// No profile prop = create mode
/>
</>
);
}
@@ -1,377 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* OnboardingWizard integration tests
*
* Integration tests for the complete onboarding wizard flow.
* Verifies step navigation, OAuth/API key paths, back button behavior,
* and progress indicator.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { OnboardingWizard } from './OnboardingWizard';
// Mock react-i18next to avoid initialization issues
vi.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => {
// Return the key itself or provide specific translations
// Keys are without namespace since component uses useTranslation('namespace')
const translations: Record<string, string> = {
'welcome.title': 'Welcome to Auto Claude',
'welcome.subtitle': 'AI-powered autonomous coding assistant',
'welcome.getStarted': 'Get Started',
'welcome.skip': 'Skip Setup',
'wizard.helpText': 'Let us help you get started with Auto Claude',
'welcome.features.aiPowered.title': 'AI-Powered',
'welcome.features.aiPowered.description': 'Powered by Claude',
'welcome.features.specDriven.title': 'Spec-Driven',
'welcome.features.specDriven.description': 'Create from specs',
'welcome.features.memory.title': 'Memory',
'welcome.features.memory.description': 'Remembers context',
'welcome.features.parallel.title': 'Parallel',
'welcome.features.parallel.description': 'Work in parallel',
'authChoice.title': 'Choose Your Authentication Method',
'authChoice.subtitle': 'Select how you want to authenticate',
'authChoice.oauthTitle': 'Sign in with Anthropic',
'authChoice.oauthDesc': 'OAuth authentication',
'authChoice.apiKeyTitle': 'Use Custom API Key',
'authChoice.apiKeyDesc': 'Enter your own API key',
'authChoice.skip': 'Skip for now',
// Common translations
'common:actions.close': 'Close'
};
return translations[key] || key;
},
i18n: { language: 'en' }
}),
Trans: ({ children }: { children: React.ReactNode }) => children
}));
// Mock the settings store
const mockUpdateSettings = vi.fn();
const mockLoadSettings = vi.fn();
const mockProfiles: any[] = [];
vi.mock('../../stores/settings-store', () => ({
useSettingsStore: vi.fn((selector) => {
const state = {
settings: { onboardingCompleted: false },
isLoading: false,
profiles: mockProfiles,
activeProfileId: null,
updateSettings: mockUpdateSettings,
loadSettings: mockLoadSettings
};
if (!selector) return state;
return selector(state);
})
}));
// Mock electronAPI
const mockSaveSettings = vi.fn().mockResolvedValue({ success: true });
Object.defineProperty(window, 'electronAPI', {
value: {
saveSettings: mockSaveSettings,
onAppUpdateDownloaded: vi.fn(),
// OAuth-related methods needed for OAuthStep component
onTerminalOAuthToken: vi.fn(() => vi.fn()), // Returns unsubscribe function
getOAuthToken: vi.fn().mockResolvedValue(null),
startOAuthFlow: vi.fn().mockResolvedValue({ success: true }),
loadProfiles: vi.fn().mockResolvedValue([])
},
writable: true
});
describe('OnboardingWizard Integration Tests', () => {
const defaultProps = {
open: true,
onOpenChange: vi.fn()
};
beforeEach(() => {
vi.clearAllMocks();
});
describe('OAuth Path Navigation', () => {
// Skipped: OAuth integration tests require full OAuth step mocking - not API Profile related
it.skip('should navigate: welcome → auth-choice → oauth', async () => {
render(<OnboardingWizard {...defaultProps} />);
// Start at welcome step
expect(screen.getByText(/Welcome to Auto Claude/)).toBeInTheDocument();
// Click "Get Started" to go to auth-choice
const getStartedButton = screen.getByRole('button', { name: /Get Started/ });
fireEvent.click(getStartedButton);
// Should now show auth choice step
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
// Click OAuth option
const oauthButton = screen.getByTestId('auth-option-oauth');
fireEvent.click(oauthButton);
// Should navigate to oauth step
await waitFor(() => {
expect(screen.getByText(/Sign in with Anthropic/)).toBeInTheDocument();
});
});
// Skipped: OAuth path test requires full OAuth step mocking
it.skip('should show correct progress indicator for OAuth path', async () => {
render(<OnboardingWizard {...defaultProps} />);
// Click through to auth-choice
fireEvent.click(screen.getByRole('button', { name: /Get Started/ }));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
// Verify progress indicator shows 5 steps
const progressIndicators = document.querySelectorAll('[class*="step"]');
expect(progressIndicators.length).toBeGreaterThanOrEqual(4); // At least 4 steps shown
});
});
describe('API Key Path Navigation', () => {
// Skipped: Test requires ProfileEditDialog integration mock
it.skip('should skip oauth step when API key path chosen', async () => {
render(<OnboardingWizard {...defaultProps} />);
// Start at welcome step
expect(screen.getByText(/Welcome to Auto Claude/)).toBeInTheDocument();
// Click "Get Started" to go to auth-choice
fireEvent.click(screen.getByRole('button', { name: /Get Started/ }));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
// Click API Key option
const apiKeyButton = screen.getByTestId('auth-option-apikey');
fireEvent.click(apiKeyButton);
// Profile dialog should open
await waitFor(() => {
expect(screen.getByTestId('profile-edit-dialog')).toBeInTheDocument();
});
// Close dialog (simulating profile creation - in real scenario this would trigger skip)
const closeButton = screen.queryByText(/Close|Cancel/);
if (closeButton) {
fireEvent.click(closeButton);
}
});
it('should not show OAuth step text on auth-choice screen', async () => {
render(<OnboardingWizard {...defaultProps} />);
// Navigate to auth-choice
fireEvent.click(screen.getByRole('button', { name: /Get Started/ }));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
// When profile is created via API key path, should skip oauth
// This is tested via component behavior - the wizard should advance
// directly to graphiti step, bypassing oauth
const oauthStepText = screen.queryByText(/OAuth Authentication/);
// Before API key selection, oauth text from different context shouldn't be visible
expect(oauthStepText).toBeNull();
});
});
describe('Back Button Behavior After API Key Path', () => {
it('should go back to auth-choice (not oauth) when coming from API key path', async () => {
render(<OnboardingWizard {...defaultProps} />);
// This test verifies that when oauth is bypassed (API key path taken),
// going back from graphiti returns to auth-choice, not oauth
// Navigate: welcome → auth-choice
fireEvent.click(screen.getByText(/Get Started/));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
// The back button behavior is controlled by oauthBypassed state
// When API key path is taken, oauthBypassed=true
// Going back from graphiti should skip oauth step
const authChoiceHeading = screen.getByText(/Choose Your Authentication Method/);
expect(authChoiceHeading).toBeInTheDocument();
});
});
describe('First-Run Detection', () => {
it('should show wizard for users with no auth configured', () => {
render(<OnboardingWizard {...defaultProps} open={true} />);
// Wizard should be visible
expect(screen.getByText(/Welcome to Auto Claude/)).toBeInTheDocument();
});
it('should not show wizard for users with existing OAuth', () => {
// This is tested in App.tsx integration tests
// Here we verify the wizard can be closed
const { rerender } = render(<OnboardingWizard {...defaultProps} open={true} />);
expect(screen.getByText(/Welcome to Auto Claude/)).toBeInTheDocument();
// Close wizard
rerender(<OnboardingWizard {...defaultProps} open={false} />);
// Wizard content should not be visible
expect(screen.queryByText(/Welcome to Auto Claude/)).not.toBeInTheDocument();
});
it('should not show wizard for users with existing API profiles', () => {
// This is tested in App.tsx integration tests
// The wizard respects the open prop
render(<OnboardingWizard {...defaultProps} open={false} />);
expect(screen.queryByText(/Welcome to Auto Claude/)).not.toBeInTheDocument();
});
});
describe('Skip and Completion', () => {
it('should complete wizard when skip is clicked', async () => {
render(<OnboardingWizard {...defaultProps} />);
// Click skip on welcome step
const skipButton = screen.getByRole('button', { name: /Skip Setup/ });
fireEvent.click(skipButton);
// Should call saveSettings
await waitFor(() => {
expect(mockSaveSettings).toHaveBeenCalledWith({ onboardingCompleted: true });
});
});
it('should call onOpenChange when wizard is closed', async () => {
const mockOnOpenChange = vi.fn();
render(<OnboardingWizard {...defaultProps} onOpenChange={mockOnOpenChange} />);
// Click skip to close wizard
const skipButton = screen.getByRole('button', { name: /Skip Setup/ });
fireEvent.click(skipButton);
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
});
});
describe('Step Progress Indicator', () => {
// Skipped: Progress indicator tests require step-by-step CSS class inspection
it.skip('should display progress indicator for non-welcome/completion steps', async () => {
render(<OnboardingWizard {...defaultProps} />);
// On welcome step, no progress indicator shown
expect(screen.queryByText(/Welcome/)).toBeInTheDocument();
const progressBeforeNav = document.querySelector('[class*="progress"]');
// Progress indicator may not be visible on welcome step
// Navigate to auth-choice
fireEvent.click(screen.getByRole('button', { name: /Get Started/ }));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
// Progress indicator should now be visible
// The WizardProgress component should be rendered
const progressElement = document.querySelector('[class*="step"]');
expect(progressElement).toBeTruthy();
});
// Skipped: Step count test requires i18n step labels
it.skip('should show correct number of steps (5 total)', async () => {
render(<OnboardingWizard {...defaultProps} />);
// Navigate to auth-choice
fireEvent.click(screen.getByRole('button', { name: /Get Started/ }));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
// Check for step labels in progress indicator
const steps = [
'Welcome',
'Auth Method',
'OAuth',
'Memory',
'Done'
];
// At least some step labels should be present (not all may be visible at current step)
const visibleSteps = steps.filter(step => screen.queryByText(step));
expect(visibleSteps.length).toBeGreaterThan(0);
});
});
describe('AC Coverage', () => {
it('AC1: First-run screen displays with two auth options', async () => {
render(<OnboardingWizard {...defaultProps} />);
// Navigate to auth-choice
fireEvent.click(screen.getByRole('button', { name: /Get Started/ }));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
// Both options should be visible
expect(screen.getByText(/Sign in with Anthropic/)).toBeInTheDocument();
expect(screen.getByText(/Use Custom API Key/)).toBeInTheDocument();
});
// Skipped: OAuth path test requires full OAuth step mocking
it.skip('AC2: OAuth path initiates existing OAuth flow', async () => {
render(<OnboardingWizard {...defaultProps} />);
fireEvent.click(screen.getByText(/Get Started/));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
const oauthButton = screen.getByTestId('auth-option-oauth');
fireEvent.click(oauthButton);
// Should proceed to OAuth step
await waitFor(() => {
// OAuth step content should be visible
expect(document.querySelector('.fullscreen-dialog')).toBeInTheDocument();
});
});
it('AC3: API Key path opens profile management dialog', async () => {
render(<OnboardingWizard {...defaultProps} />);
fireEvent.click(screen.getByText(/Get Started/));
await waitFor(() => {
expect(screen.getByText(/Choose Your Authentication Method/)).toBeInTheDocument();
});
const apiKeyButton = screen.getByTestId('auth-option-apikey');
fireEvent.click(apiKeyButton);
// ProfileEditDialog should open
await waitFor(() => {
expect(screen.getByTestId('profile-edit-dialog')).toBeInTheDocument();
});
});
it('AC4: Existing auth skips wizard', () => {
// Wizard with open=false simulates existing auth scenario
render(<OnboardingWizard {...defaultProps} open={false} />);
// Wizard should not be visible
expect(screen.queryByText(/Welcome to Auto Claude/)).not.toBeInTheDocument();
});
});
});
@@ -12,11 +12,10 @@ import {
import { ScrollArea } from '../ui/scroll-area';
import { WizardProgress, WizardStep } from './WizardProgress';
import { WelcomeStep } from './WelcomeStep';
import { AuthChoiceStep } from './AuthChoiceStep';
import { OAuthStep } from './OAuthStep';
import { ClaudeCodeStep } from './ClaudeCodeStep';
import { DevToolsStep } from './DevToolsStep';
import { GraphitiStep } from './GraphitiStep';
import { MemoryStep } from './MemoryStep';
import { CompletionStep } from './CompletionStep';
import { useSettingsStore } from '../../stores/settings-store';
@@ -28,16 +27,15 @@ interface OnboardingWizardProps {
}
// Wizard step identifiers
type WizardStepId = 'welcome' | 'auth-choice' | 'oauth' | 'claude-code' | 'devtools' | 'graphiti' | 'completion';
type WizardStepId = 'welcome' | 'oauth' | 'claude-code' | 'devtools' | 'memory' | 'completion';
// Step configuration with translation keys
const WIZARD_STEPS: { id: WizardStepId; labelKey: string }[] = [
{ id: 'welcome', labelKey: 'steps.welcome' },
{ id: 'auth-choice', labelKey: 'steps.authChoice' },
{ id: 'oauth', labelKey: 'steps.auth' },
{ id: 'claude-code', labelKey: 'steps.claudeCode' },
{ id: 'devtools', labelKey: 'steps.devtools' },
{ id: 'graphiti', labelKey: 'steps.memory' },
{ id: 'memory', labelKey: 'steps.memory' },
{ id: 'completion', labelKey: 'steps.done' }
];
@@ -62,8 +60,6 @@ export function OnboardingWizard({
const { updateSettings } = useSettingsStore();
const [currentStepIndex, setCurrentStepIndex] = useState(0);
const [completedSteps, setCompletedSteps] = useState<Set<WizardStepId>>(new Set());
// Track if oauth step was bypassed (API key path chosen)
const [oauthBypassed, setOauthBypassed] = useState(false);
// Get current step ID
const currentStepId = WIZARD_STEPS[currentStepIndex].id;
@@ -80,46 +76,21 @@ export function OnboardingWizard({
// Mark current step as completed
setCompletedSteps(prev => new Set(prev).add(currentStepId));
// If leaving auth-choice, reset oauth bypassed flag
if (currentStepId === 'auth-choice') {
setOauthBypassed(false);
}
if (currentStepIndex < WIZARD_STEPS.length - 1) {
setCurrentStepIndex(prev => prev + 1);
}
}, [currentStepIndex, currentStepId]);
const goToPreviousStep = useCallback(() => {
// If going back from graphiti and oauth was bypassed, go back to auth-choice (skip oauth)
if (currentStepId === 'graphiti' && oauthBypassed) {
// Find index of auth-choice step
const authChoiceIndex = WIZARD_STEPS.findIndex(step => step.id === 'auth-choice');
setCurrentStepIndex(authChoiceIndex);
setOauthBypassed(false);
return;
}
if (currentStepIndex > 0) {
setCurrentStepIndex(prev => prev - 1);
}
}, [currentStepIndex, currentStepId, oauthBypassed]);
// Handler for when API key path is chosen - skips oauth step
const handleSkipToGraphiti = useCallback(() => {
setOauthBypassed(true);
setCompletedSteps(prev => new Set(prev).add('auth-choice'));
// Find index of graphiti step
const graphitiIndex = WIZARD_STEPS.findIndex(step => step.id === 'graphiti');
setCurrentStepIndex(graphitiIndex);
}, []);
}, [currentStepIndex]);
// Reset wizard state (for re-running) - defined before skipWizard/finishWizard that use it
const resetWizard = useCallback(() => {
setCurrentStepIndex(0);
setCompletedSteps(new Set());
setOauthBypassed(false);
}, []);
const skipWizard = useCallback(async () => {
@@ -180,15 +151,6 @@ export function OnboardingWizard({
onSkip={skipWizard}
/>
);
case 'auth-choice':
return (
<AuthChoiceStep
onNext={goToNextStep}
onBack={goToPreviousStep}
onSkip={skipWizard}
onAPIKeyPathComplete={handleSkipToGraphiti}
/>
);
case 'oauth':
return (
<OAuthStep
@@ -212,12 +174,11 @@ export function OnboardingWizard({
onBack={goToPreviousStep}
/>
);
case 'graphiti':
case 'memory':
return (
<GraphitiStep
<MemoryStep
onNext={goToNextStep}
onBack={goToPreviousStep}
onSkip={skipWizard}
/>
);
case 'completion':
@@ -5,7 +5,6 @@
export { OnboardingWizard } from './OnboardingWizard';
export { WelcomeStep } from './WelcomeStep';
export { AuthChoiceStep } from './AuthChoiceStep';
export { OAuthStep } from './OAuthStep';
export { MemoryStep } from './MemoryStep';
export { OllamaModelSelector } from './OllamaModelSelector';
@@ -18,8 +18,7 @@ import {
Monitor,
Globe,
Code,
Bug,
Server
Bug
} from 'lucide-react';
// GitLab icon component (lucide-react doesn't have one)
@@ -52,7 +51,6 @@ import { IntegrationSettings } from './IntegrationSettings';
import { AdvancedSettings } from './AdvancedSettings';
import { DevToolsSettings } from './DevToolsSettings';
import { DebugSettings } from './DebugSettings';
import { ProfileList } from './ProfileList';
import { ProjectSelector } from './ProjectSelector';
import { ProjectSettingsContent, ProjectSettingsSection } from './ProjectSettingsContent';
import { useProjectStore } from '../../stores/project-store';
@@ -67,7 +65,7 @@ interface AppSettingsDialogProps {
}
// App-level settings sections
export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'integrations' | 'api-profiles' | 'updates' | 'notifications' | 'debug';
export type AppSection = 'appearance' | 'display' | 'language' | 'devtools' | 'agent' | 'paths' | 'integrations' | 'updates' | 'notifications' | 'debug';
interface NavItemConfig<T extends string> {
id: T;
@@ -82,7 +80,6 @@ const appNavItemsConfig: NavItemConfig<AppSection>[] = [
{ id: 'agent', icon: Bot },
{ id: 'paths', icon: FolderOpen },
{ id: 'integrations', icon: Key },
{ id: 'api-profiles', icon: Server },
{ id: 'updates', icon: Package },
{ id: 'notifications', icon: Bell },
{ id: 'debug', icon: Bug }
@@ -194,8 +191,6 @@ export function AppSettingsDialog({ open, onOpenChange, initialSection, initialP
return <GeneralSettings settings={settings} onSettingsChange={setSettings} section="paths" />;
case 'integrations':
return <IntegrationSettings settings={settings} onSettingsChange={setSettings} isOpen={open} />;
case 'api-profiles':
return <ProfileList />;
case 'updates':
return <AdvancedSettings settings={settings} onSettingsChange={setSettings} section="updates" version={version} />;
case 'notifications':
@@ -1,358 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Tests for ModelSearchableSelect component
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ModelSearchableSelect } from './ModelSearchableSelect';
import { useSettingsStore } from '../../stores/settings-store';
// Mock the settings store
vi.mock('../../stores/settings-store');
describe('ModelSearchableSelect', () => {
const mockDiscoverModels = vi.fn();
const mockOnChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(useSettingsStore).mockImplementation((selector?: (state: any) => any): any => {
const state = { discoverModels: mockDiscoverModels };
return selector ? selector(state) : state;
});
});
it('should render input with placeholder', () => {
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
placeholder="Select a model"
/>
);
expect(screen.getByPlaceholderText('Select a model')).toBeInTheDocument();
});
it('should render with initial value', () => {
render(
<ModelSearchableSelect
value="claude-3-5-sonnet-20241022"
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByDisplayValue('claude-3-5-sonnet-20241022');
expect(input).toBeInTheDocument();
});
it('should fetch models when dropdown opens', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' },
{ id: 'claude-3-5-haiku-20241022', display_name: 'Claude Haiku 3.5' }
]);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
// Click to open dropdown
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
expect(mockDiscoverModels).toHaveBeenCalledWith(
'https://api.anthropic.com',
'sk-test-key-12chars',
expect.any(AbortSignal)
);
});
});
it('should display loading state while fetching', async () => {
mockDiscoverModels.mockImplementation(
() => new Promise(() => {}) // Never resolves
);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
// Component shows a Loader2 spinner with animate-spin class
const spinner = document.querySelector('.animate-spin');
expect(spinner).toBeInTheDocument();
});
});
it('should display fetched models in dropdown', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' },
{ id: 'claude-3-5-haiku-20241022', display_name: 'Claude Haiku 3.5' }
]);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
expect(screen.getByText('Claude Sonnet 3.5')).toBeInTheDocument();
expect(screen.getByText('claude-3-5-sonnet-20241022')).toBeInTheDocument();
});
});
it('should select model and close dropdown', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' }
]);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
const modelButton = screen.getByText('Claude Sonnet 3.5');
fireEvent.click(modelButton);
});
expect(mockOnChange).toHaveBeenCalledWith('claude-3-5-sonnet-20241022');
});
it('should allow manual text input', async () => {
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.change(input, { target: { value: 'custom-model-name' } });
expect(mockOnChange).toHaveBeenCalledWith('custom-model-name');
});
it('should filter models based on search query', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' },
{ id: 'claude-3-5-haiku-20241022', display_name: 'Claude Haiku 3.5' },
{ id: 'claude-3-opus-20240229', display_name: 'Claude Opus 3' }
]);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
// Wait for models to load
await waitFor(() => {
expect(screen.getByText('Claude Sonnet 3.5')).toBeInTheDocument();
});
// Type search query
const searchInput = screen.getByPlaceholderText('Search models...');
fireEvent.change(searchInput, { target: { value: 'haiku' } });
// Should only show Haiku
await waitFor(() => {
expect(screen.getByText('Claude Haiku 3.5')).toBeInTheDocument();
expect(screen.queryByText('Claude Sonnet 3.5')).not.toBeInTheDocument();
expect(screen.queryByText('Claude Opus 3')).not.toBeInTheDocument();
});
});
it('should show fallback mode on fetch failure', async () => {
mockDiscoverModels.mockRejectedValue(
new Error('This API endpoint does not support model listing')
);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://custom-api.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
// Component falls back to manual input mode with info message
expect(screen.getByText(/Model discovery not available/)).toBeInTheDocument();
});
});
it('should close dropdown when no models returned', async () => {
mockDiscoverModels.mockResolvedValue([]);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
// Component closes dropdown when no models, dropdown should not be visible
expect(screen.queryByPlaceholderText('Search models...')).not.toBeInTheDocument();
});
});
it('should show no results message when search does not match', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' }
]);
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
expect(screen.getByText('Claude Sonnet 3.5')).toBeInTheDocument();
});
// Search for non-existent model
const searchInput = screen.getByPlaceholderText('Search models...');
fireEvent.change(searchInput, { target: { value: 'nonexistent' } });
await waitFor(() => {
expect(screen.getByText('No models match your search')).toBeInTheDocument();
});
});
it('should be disabled when disabled prop is true', () => {
render(
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
disabled={true}
/>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
expect(input).toBeDisabled();
});
it('should highlight selected model', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' },
{ id: 'claude-3-5-haiku-20241022', display_name: 'Claude Haiku 3.5' }
]);
render(
<ModelSearchableSelect
value="claude-3-5-sonnet-20241022"
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
);
const input = screen.getByDisplayValue('claude-3-5-sonnet-20241022');
fireEvent.focus(input);
await waitFor(() => {
// Selected model should have Check icon indicator (via background color)
const sonnetButton = screen.getByText('Claude Sonnet 3.5').closest('button');
expect(sonnetButton).toHaveClass('bg-accent');
});
});
it('should close dropdown when clicking outside', async () => {
mockDiscoverModels.mockResolvedValue([
{ id: 'claude-3-5-sonnet-20241022', display_name: 'Claude Sonnet 3.5' }
]);
render(
<div>
<ModelSearchableSelect
value=""
onChange={mockOnChange}
baseUrl="https://api.anthropic.com"
apiKey="sk-test-key-12chars"
/>
<div data-testid="outside-element">Outside</div>
</div>
);
const input = screen.getByPlaceholderText('Select a model or type manually');
fireEvent.focus(input);
await waitFor(() => {
expect(screen.getByText('Claude Sonnet 3.5')).toBeInTheDocument();
});
// Click outside
fireEvent.mouseDown(screen.getByTestId('outside-element'));
await waitFor(() => {
expect(screen.queryByText('Claude Sonnet 3.5')).not.toBeInTheDocument();
});
});
});
@@ -1,315 +0,0 @@
/**
* ModelSearchableSelect - Searchable dropdown for API model selection
*
* A custom dropdown component that:
* - Fetches available models from the API when opened
* - Displays loading state during fetch
* - Allows search/filter within dropdown
* - Falls back to manual text input if API doesn't support model listing
* - Cancels pending requests when closed
*
* Features:
* - Lazy loading: fetches models on first open, not on mount
* - Search filtering: type to filter model list
* - Error handling: shows error with fallback to manual input
* - Per-credential caching: reuses fetched models for same (baseUrl, apiKey)
* - Request cancellation: aborts pending fetch when closed
*/
import { useState, useEffect, useRef } from 'react';
import { Loader2, AlertCircle, ChevronDown, Search, Check, Info } from 'lucide-react';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { cn } from '../../lib/utils';
import { useSettingsStore } from '../../stores/settings-store';
import type { ModelInfo } from '@shared/types/profile';
interface ModelSearchableSelectProps {
/** Currently selected model ID */
value: string;
/** Callback when model is selected */
onChange: (modelId: string) => void;
/** Placeholder text when no model selected */
placeholder?: string;
/** Base URL for API (used for caching key) */
baseUrl: string;
/** API key for authentication (used for caching key) */
apiKey: string;
/** Disabled state */
disabled?: boolean;
/** Additional CSS classes */
className?: string;
}
/**
* ModelSearchableSelect Component
*
* @example
* ```tsx
* <ModelSearchableSelect
* value="claude-3-5-sonnet-20241022"
* onChange={(modelId) => setModel(modelId)}
* baseUrl="https://api.anthropic.com"
* apiKey="sk-ant-..."
* placeholder="Select a model"
* />
* ```
*/
export function ModelSearchableSelect({
value,
onChange,
placeholder = 'Select a model or type manually',
baseUrl,
apiKey,
disabled = false,
className
}: ModelSearchableSelectProps) {
const discoverModels = useSettingsStore((state) => state.discoverModels);
// Dropdown open state
const [isOpen, setIsOpen] = useState(false);
// Model discovery state
const [models, setModels] = useState<ModelInfo[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [modelDiscoveryNotSupported, setModelDiscoveryNotSupported] = useState(false);
// Search state
const [searchQuery, setSearchQuery] = useState('');
// Manual input mode (when API doesn't support model listing)
const [isManualInput, setIsManualInput] = useState(false);
// AbortController for cancelling fetch requests
const abortControllerRef = useRef<AbortController | null>(null);
// Container ref for click-outside detection
const containerRef = useRef<HTMLDivElement>(null);
/**
* Fetch models from API.
* Uses store's discoverModels action which has built-in caching.
*/
const fetchModels = async () => {
console.log('[ModelSearchableSelect] fetchModels called with:', { baseUrl, apiKey: `${apiKey.slice(-4)}` });
// Fetch from API
setIsLoading(true);
setError(null);
setModelDiscoveryNotSupported(false);
abortControllerRef.current = new AbortController();
try {
const result = await discoverModels(baseUrl, apiKey, abortControllerRef.current.signal);
console.log('[ModelSearchableSelect] discoverModels result:', result);
if (result && Array.isArray(result)) {
setModels(result);
// If no models returned, close dropdown
if (result.length === 0) {
setIsOpen(false);
}
} else {
// No result - treat as not supported
setModelDiscoveryNotSupported(true);
setIsOpen(false);
}
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
// Check if it's specifically "not supported" or a general error
if (err.message.includes('does not support model listing') ||
err.message.includes('not_supported')) {
setModelDiscoveryNotSupported(true);
} else {
// For other errors, also treat as "not supported" for better UX
// User can still type manually
setModelDiscoveryNotSupported(true);
console.warn('[ModelSearchableSelect] Model discovery failed:', err.message);
}
setIsOpen(false); // Close dropdown - user should type directly
}
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
/**
* Handle dropdown open.
* Triggers model fetch on first open.
* If model discovery is not supported, don't open dropdown - just allow typing.
*/
const handleOpen = () => {
if (disabled) return;
// If we already know model discovery isn't supported, don't open dropdown
if (modelDiscoveryNotSupported) {
setIsManualInput(true);
return;
}
setIsOpen(true);
setSearchQuery('');
// Fetch models on first open
if (models.length === 0 && !isLoading && !error) {
fetchModels();
}
};
/**
* Handle dropdown close.
* Cancels any pending fetch requests.
*/
const handleClose = () => {
setIsOpen(false);
// Cancel pending fetch
abortControllerRef.current?.abort();
abortControllerRef.current = null;
};
/**
* Handle model selection from dropdown.
*/
const handleSelectModel = (modelId: string) => {
onChange(modelId);
handleClose();
};
/**
* Handle manual input change.
*/
const handleManualInputChange = (inputValue: string) => {
onChange(inputValue);
setSearchQuery(inputValue);
};
/**
* Filter models based on search query.
*/
const filteredModels = models.filter(model =>
model.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
model.display_name.toLowerCase().includes(searchQuery.toLowerCase())
);
// Click-outside detection for closing dropdown
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
handleClose();
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
// Cleanup on unmount
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
};
}, []);
return (
<div ref={containerRef} className={cn('relative', className)}>
{/* Main input with loading/dropdown indicator */}
<div className="relative">
<Input
value={value || ''}
onChange={(e) => {
handleManualInputChange(e.target.value);
}}
onFocus={() => {
// Only open dropdown if we have models or haven't tried fetching yet
if (!modelDiscoveryNotSupported) {
handleOpen();
}
}}
placeholder={modelDiscoveryNotSupported ? 'Enter model name (e.g., claude-3-5-sonnet-20241022)' : placeholder}
disabled={disabled}
className="pr-10"
/>
{/* Right side indicator: loading spinner, dropdown arrow, or nothing for manual mode */}
<div className="absolute right-0 top-0 h-full flex items-center px-3">
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
) : !modelDiscoveryNotSupported ? (
<Button
type="button"
variant="ghost"
size="sm"
onClick={isOpen ? handleClose : handleOpen}
disabled={disabled}
className="h-6 w-6 p-0 hover:bg-accent"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', isOpen && 'rotate-180')} />
</Button>
) : null}
</div>
</div>
{/* Dropdown panel - only show when we have models to display */}
{isOpen && !isLoading && !modelDiscoveryNotSupported && models.length > 0 && (
<div className="absolute z-50 w-full mt-1 bg-background border rounded-md shadow-lg max-h-60 overflow-hidden flex flex-col">
{/* Search input */}
<div className="p-2 border-b">
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search models..."
className="pl-8"
autoFocus
/>
</div>
</div>
{/* Model list */}
<div className="flex-1 overflow-y-auto py-1">
{filteredModels.length === 0 ? (
<div className="p-3 text-center text-sm text-muted-foreground">
No models match your search
</div>
) : (
filteredModels.map((model) => (
<button
key={model.id}
type="button"
onClick={() => handleSelectModel(model.id)}
className={cn(
'w-full px-3 py-2 text-left text-sm hover:bg-accent flex items-start gap-2',
value === model.id && 'bg-accent'
)}
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">{model.display_name}</div>
<div className="text-xs text-muted-foreground truncate">{model.id}</div>
</div>
{value === model.id && (
<Check className="h-4 w-4 text-primary shrink-0 mt-0.5" />
)}
</button>
))
)}
</div>
</div>
)}
{/* Info/error messages below input */}
{modelDiscoveryNotSupported && (
<p className="text-sm text-muted-foreground mt-1 flex items-center gap-1">
<Info className="h-3 w-3" />
Model discovery not available. Enter model name manually.
</p>
)}
{error && !modelDiscoveryNotSupported && (
<p className="text-sm text-destructive mt-1">{error}</p>
)}
</div>
);
}
@@ -1,642 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* ProfileEditDialog Tests
*
* Tests both create and edit modes for the API profile dialog.
* Following Story 1.3: Edit Existing Profile
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
import { ProfileEditDialog } from './ProfileEditDialog';
import type { APIProfile } from '@shared/types/profile';
// Mock the settings store
vi.mock('../../stores/settings-store', () => ({
useSettingsStore: vi.fn()
}));
import { useSettingsStore } from '../../stores/settings-store';
describe('ProfileEditDialog - Edit Mode', () => {
const mockOnOpenChange = vi.fn();
const mockOnSaved = vi.fn();
const mockProfile: APIProfile = {
id: '123e4567-e89b-12d3-a456-426614174000',
name: 'Test Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-ant-api123-test-key-abc123',
models: {
default: 'claude-3-5-sonnet-20241022',
haiku: 'claude-3-5-haiku-20241022'
},
createdAt: 1700000000000,
updatedAt: 1700000000000
};
beforeEach(() => {
vi.clearAllMocks();
// Mock store to return updateProfile action
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
saveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
});
afterEach(() => {
vi.clearAllMocks();
});
// Test 5 from story: Pre-populated form data
it('should pre-populate all fields with existing values when editing', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
saveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
onSaved={mockOnSaved}
profile={mockProfile}
/>
);
// Verify all fields are pre-populated
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('Test Profile');
expect(screen.getByLabelText(/base url/i)).toHaveValue('https://api.example.com');
});
// Note: Model fields use ModelSearchableSelect component which doesn't use standard
// label/input associations. The model field functionality is tested via E2E tests.
});
// Test 6 from story: API key displays masked
it('should display masked API key in edit mode', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
saveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
// API key field displays four mask characters (••••) plus only the last four characters of the full key
// Example: full key "sk-ant-api123-test-key-abc123" => masked display "••••c123"
await waitFor(() => {
const maskedInput = screen.getByDisplayValue(/••••c123/);
expect(maskedInput).toBeDisabled();
});
});
// Test 1 from story: Edit profile name
it('should update profile when form is modified and saved', async () => {
const mockUpdateFn = vi.fn().mockResolvedValue(true);
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: mockUpdateFn,
saveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
onSaved={mockOnSaved}
profile={mockProfile}
/>
);
// Wait for form to populate
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('Test Profile');
});
// Change the name
const nameInput = screen.getByLabelText(/name/i);
fireEvent.change(nameInput, { target: { value: 'Updated Profile Name' } });
// Click save
const saveButton = screen.getByText(/save profile/i);
fireEvent.click(saveButton);
// Verify updateProfile was called (not saveProfile)
await waitFor(() => {
expect(mockUpdateFn).toHaveBeenCalled();
});
});
// Dialog title should say "Edit Profile" in edit mode
it('should show "Edit Profile" title in edit mode', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
saveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
await waitFor(() => {
expect(screen.getByText('Edit Profile')).toBeInTheDocument();
});
});
// Test 7 from story: Cancel button
it('should close dialog without saving when Cancel is clicked', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
saveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
const cancelButton = screen.getByText('Cancel');
fireEvent.click(cancelButton);
await waitFor(() => {
expect(mockOnOpenChange).toHaveBeenCalledWith(false);
});
});
// Test 8 from story: Models fields pre-populate
it('should pre-populate optional model fields with existing values', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
saveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('Test Profile');
});
// Find model inputs by their labels
const modelLabels = screen.getAllByText(/model/i);
expect(modelLabels.length).toBeGreaterThan(0);
});
});
describe('ProfileEditDialog - Create Mode', () => {
const mockOnOpenChange = vi.fn();
const mockOnSaved = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
saveProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
});
// Dialog title should say "Add API Profile" in create mode
it('should show "Add API Profile" title in create mode', () => {
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
onSaved={mockOnSaved}
/>
);
expect(screen.getByText('Add API Profile')).toBeInTheDocument();
});
// Fields should be empty in create mode
it('should have empty fields in create mode', () => {
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
/>
);
expect(screen.getByLabelText(/name/i)).toHaveValue('');
expect(screen.getByLabelText(/base url/i)).toHaveValue('');
});
// API key input should be normal (not masked) in create mode
it('should show normal API key input in create mode', () => {
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
/>
);
const apiKeyInput = screen.getByLabelText(/api key/i);
expect(apiKeyInput).toHaveAttribute('type', 'password');
expect(apiKeyInput).not.toBeDisabled();
});
});
describe('ProfileEditDialog - Validation', () => {
const mockOnOpenChange = vi.fn();
const mockProfile: APIProfile = {
id: 'test-id',
name: 'Test',
baseUrl: 'https://api.example.com',
apiKey: 'sk-ant-test123',
createdAt: Date.now(),
updatedAt: Date.now()
};
// Test 4 from story: Invalid Base URL validation
it('should show inline error for invalid Base URL', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
profilesLoading: false,
profilesError: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
await waitFor(() => {
expect(screen.getByLabelText(/base url/i)).toHaveValue('https://api.example.com');
});
// Enter invalid URL
const urlInput = screen.getByLabelText(/base url/i);
fireEvent.change(urlInput, { target: { value: 'not-a-valid-url' } });
// Click save to trigger validation
const saveButton = screen.getByText(/save profile/i);
fireEvent.click(saveButton);
// Should show error
await waitFor(() => {
expect(screen.getByText(/invalid url/i)).toBeInTheDocument();
});
});
// Test 2 from story: Edit profile name to duplicate existing name
it('should show error when editing to duplicate name', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(false), // Simulating duplicate name error
profilesLoading: false,
profilesError: 'A profile with this name already exists'
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('Test');
});
// Change name to a duplicate
const nameInput = screen.getByLabelText(/name/i);
fireEvent.change(nameInput, { target: { value: 'Duplicate Name' } });
// Click save
const saveButton = screen.getByText(/save profile/i);
fireEvent.click(saveButton);
// Should show error from store
await waitFor(() => {
expect(screen.getByText(/A profile with this name already exists/i)).toBeInTheDocument();
});
});
// Test 3 from story: Edit active profile
it('should keep profile active after editing', async () => {
const mockUpdateFn = vi.fn().mockResolvedValue(true);
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: mockUpdateFn,
profilesLoading: false,
profilesError: null,
profiles: [{ ...mockProfile, id: 'active-id' }],
activeProfileId: 'active-id'
});
const activeProfile: APIProfile = {
...mockProfile,
id: 'active-id',
name: 'Active Profile'
};
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={activeProfile}
/>
);
await waitFor(() => {
expect(screen.getByLabelText(/name/i)).toHaveValue('Active Profile');
});
// Change the name
const nameInput = screen.getByLabelText(/name/i);
fireEvent.change(nameInput, { target: { value: 'Updated Active Profile' } });
// Click save
const saveButton = screen.getByText(/save profile/i);
fireEvent.click(saveButton);
// Verify updateProfile was called
await waitFor(() => {
expect(mockUpdateFn).toHaveBeenCalled();
});
});
});
describe('ProfileEditDialog - Test Connection Feature', () => {
const mockOnOpenChange = vi.fn();
const mockOnSaved = vi.fn();
const mockTestConnection = vi.fn();
const mockProfile: APIProfile = {
id: 'test-id',
name: 'Test Profile',
baseUrl: 'https://api.example.com',
apiKey: 'sk-ant-test12345678',
createdAt: Date.now(),
updatedAt: Date.now()
};
beforeEach(() => {
vi.clearAllMocks();
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
saveProfile: vi.fn().mockResolvedValue(true),
testConnection: mockTestConnection,
profilesLoading: false,
profilesError: null,
isTestingConnection: false,
testConnectionResult: null
});
});
afterEach(() => {
vi.clearAllMocks();
});
it('should show Test Connection button', async () => {
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
await waitFor(() => {
expect(screen.getByText('Test Connection')).toBeInTheDocument();
});
});
it('should call testConnection when button is clicked', async () => {
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
const testButton = await screen.findByText('Test Connection');
fireEvent.click(testButton);
await waitFor(() => {
expect(mockTestConnection).toHaveBeenCalledWith(
'https://api.example.com',
'sk-ant-test12345678',
expect.any(AbortSignal)
);
});
});
it('should show loading state while testing connection', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
testConnection: mockTestConnection,
profilesLoading: false,
profilesError: null,
isTestingConnection: true,
testConnectionResult: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
await waitFor(() => {
expect(screen.getByText('Testing...')).toBeInTheDocument();
});
const testButton = screen.getByText('Testing...');
expect(testButton).toBeDisabled();
});
it('should show success message when connection succeeds', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
testConnection: mockTestConnection,
profilesLoading: false,
profilesError: null,
isTestingConnection: false,
testConnectionResult: {
success: true,
message: 'Connection successful'
}
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
await waitFor(() => {
expect(screen.getByText('Connection Successful')).toBeInTheDocument();
expect(screen.getByText('Connection successful')).toBeInTheDocument();
});
});
it('should show error message when connection fails', async () => {
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
testConnection: mockTestConnection,
profilesLoading: false,
profilesError: null,
isTestingConnection: false,
testConnectionResult: {
success: false,
errorType: 'auth',
message: 'Authentication failed. Please check your API key.'
}
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
await waitFor(() => {
expect(screen.getByText('Connection Failed')).toBeInTheDocument();
expect(screen.getByText('Authentication failed. Please check your API key.')).toBeInTheDocument();
});
});
it('should validate baseUrl before testing connection', async () => {
const testConnectionFn = vi.fn();
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
testConnection: testConnectionFn,
profilesLoading: false,
profilesError: null,
isTestingConnection: false,
testConnectionResult: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
/>
);
// Fill name (required to enable Test Connection button)
const nameInput = screen.getByLabelText(/name/i);
fireEvent.change(nameInput, { target: { value: 'Test Profile' } });
// Fill apiKey but leave baseUrl empty
const keyInput = screen.getByLabelText(/api key/i);
fireEvent.change(keyInput, { target: { value: 'sk-ant-test12345678' } });
// Test button should still be disabled since baseUrl is empty
const testButton = screen.getByText('Test Connection');
expect(testButton).toBeDisabled();
// Should NOT call testConnection
expect(testConnectionFn).not.toHaveBeenCalled();
});
it('should validate apiKey before testing connection', async () => {
const testConnectionFn = vi.fn();
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
testConnection: testConnectionFn,
profilesLoading: false,
profilesError: null,
isTestingConnection: false,
testConnectionResult: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
/>
);
// Fill name (required to enable Test Connection button)
const nameInput = screen.getByLabelText(/name/i);
fireEvent.change(nameInput, { target: { value: 'Test Profile' } });
// Fill baseUrl but leave apiKey empty
const urlInput = screen.getByLabelText(/base url/i);
fireEvent.change(urlInput, { target: { value: 'https://api.example.com' } });
// Test button should still be disabled since apiKey is empty
const testButton = screen.getByText('Test Connection');
expect(testButton).toBeDisabled();
// Should NOT call testConnection
expect(testConnectionFn).not.toHaveBeenCalled();
});
it('should use profile.apiKey when testing in edit mode without changing key', async () => {
const testConnectionFn = vi.fn();
(useSettingsStore as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
updateProfile: vi.fn().mockResolvedValue(true),
testConnection: testConnectionFn,
profilesLoading: false,
profilesError: null,
isTestingConnection: false,
testConnectionResult: null
});
render(
<ProfileEditDialog
open={true}
onOpenChange={mockOnOpenChange}
profile={mockProfile}
/>
);
const testButton = await screen.findByText('Test Connection');
fireEvent.click(testButton);
await waitFor(() => {
expect(testConnectionFn).toHaveBeenCalledWith(
'https://api.example.com',
'sk-ant-test12345678',
expect.any(AbortSignal)
);
});
});
});
@@ -1,522 +0,0 @@
/**
* ProfileEditDialog - Dialog for creating/editing API profiles
*
* Allows users to configure custom Anthropic-compatible API endpoints.
* Supports all profile fields including optional model name mappings.
*
* Features:
* - Required fields: Name, Base URL, API Key
* - Optional model fields: Default, Haiku, Sonnet, Opus
* - Form validation with error display
* - Save button triggers store action (create or update)
* - Close button cancels without saving
* - Edit mode: pre-populates form with existing profile data
* - Edit mode: API key masked with "Change" button
*/
import { useState, useEffect, useRef } from 'react';
import { Loader2, AlertCircle, CheckCircle2 } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '../ui/dialog';
import { Button } from '../ui/button';
import { Input } from '../ui/input';
import { Label } from '../ui/label';
import { useSettingsStore } from '../../stores/settings-store';
import { ModelSearchableSelect } from './ModelSearchableSelect';
import { useToast } from '../../hooks/use-toast';
import { isValidUrl, isValidApiKey } from '../../lib/profile-utils';
import type { APIProfile, ProfileFormData, TestConnectionResult } from '@shared/types/profile';
import { maskApiKey } from '../../lib/profile-utils';
interface ProfileEditDialogProps {
/** Whether the dialog is open */
open: boolean;
/** Callback when the dialog open state changes */
onOpenChange: (open: boolean) => void;
/** Optional callback when profile is successfully saved */
onSaved?: () => void;
/** Optional profile for edit mode (undefined = create mode) */
profile?: APIProfile;
}
export function ProfileEditDialog({ open, onOpenChange, onSaved, profile }: ProfileEditDialogProps) {
const {
saveProfile,
updateProfile,
profilesLoading,
profilesError,
testConnection,
isTestingConnection,
testConnectionResult
} = useSettingsStore();
const { toast } = useToast();
// Edit mode detection: profile prop determines mode
const isEditMode = !!profile;
// Form state
const [name, setName] = useState('');
const [baseUrl, setBaseUrl] = useState('');
const [apiKey, setApiKey] = useState('');
const [defaultModel, setDefaultModel] = useState('');
const [haikuModel, setHaikuModel] = useState('');
const [sonnetModel, setSonnetModel] = useState('');
const [opusModel, setOpusModel] = useState('');
// API key change state (for edit mode)
const [isChangingApiKey, setIsChangingApiKey] = useState(false);
// Validation errors
const [nameError, setNameError] = useState<string | null>(null);
const [urlError, setUrlError] = useState<string | null>(null);
const [keyError, setKeyError] = useState<string | null>(null);
// AbortController ref for test connection cleanup
const abortControllerRef = useRef<AbortController | null>(null);
// Local state for auto-hiding test result display
const [showTestResult, setShowTestResult] = useState(false);
// Auto-hide test result after 5 seconds
useEffect(() => {
if (testConnectionResult) {
setShowTestResult(true);
const timeoutId = setTimeout(() => {
setShowTestResult(false);
}, 5000);
return () => clearTimeout(timeoutId);
}
}, [testConnectionResult]);
// Cleanup AbortController when dialog closes or unmounts
useEffect(() => {
return () => {
abortControllerRef.current?.abort();
abortControllerRef.current = null;
};
}, []);
// Reset form and pre-populate when dialog opens
// Note: Only reset when dialog opens/closes, not when profile prop changes
// This prevents race conditions if user rapidly clicks edit on different profiles
useEffect(() => {
if (open) {
if (isEditMode && profile) {
// Pre-populate form with existing profile data
setName(profile.name);
setBaseUrl(profile.baseUrl);
setApiKey(''); // Start empty - masked display shown instead
setDefaultModel(profile.models?.default || '');
setHaikuModel(profile.models?.haiku || '');
setSonnetModel(profile.models?.sonnet || '');
setOpusModel(profile.models?.opus || '');
setIsChangingApiKey(false);
} else {
// Reset to empty form for create mode
setName('');
setBaseUrl('');
setApiKey('');
setDefaultModel('');
setHaikuModel('');
setSonnetModel('');
setOpusModel('');
setIsChangingApiKey(false);
}
// Clear validation errors
setNameError(null);
setUrlError(null);
setKeyError(null);
} else {
// Clear test result display when dialog closes
setShowTestResult(false);
}
}, [open]);
// Validate form
const validateForm = (): boolean => {
let isValid = true;
// Name validation
if (!name.trim()) {
setNameError('Name is required');
isValid = false;
} else {
setNameError(null);
}
// Base URL validation
if (!baseUrl.trim()) {
setUrlError('Base URL is required');
isValid = false;
} else if (!isValidUrl(baseUrl)) {
setUrlError('Invalid URL format (must be http:// or https://)');
isValid = false;
} else {
setUrlError(null);
}
// API Key validation (only in create mode or when changing key in edit mode)
if (!isEditMode || isChangingApiKey) {
if (!apiKey.trim()) {
setKeyError('API Key is required');
isValid = false;
} else if (!isValidApiKey(apiKey)) {
setKeyError('Invalid API Key format');
isValid = false;
} else {
setKeyError(null);
}
} else {
setKeyError(null);
}
return isValid;
};
// Handle test connection
const handleTestConnection = async () => {
// Determine API key to use for testing
const apiKeyForTest = isEditMode && !isChangingApiKey && profile
? profile.apiKey
: apiKey;
// Basic validation before testing
if (!baseUrl.trim()) {
setUrlError('Base URL is required');
return;
}
if (!apiKeyForTest.trim()) {
setKeyError('API Key is required');
return;
}
// Create AbortController for this test
abortControllerRef.current = new AbortController();
await testConnection(baseUrl.trim(), apiKeyForTest.trim(), abortControllerRef.current.signal);
};
// Check if form has minimum required fields for test connection
const isFormValidForTest = () => {
if (!name.trim() || !baseUrl.trim()) {
return false;
}
// In create mode or when changing key, need apiKey
if (!isEditMode || isChangingApiKey) {
return apiKey.trim().length > 0;
}
// In edit mode without changing key, existing profile has apiKey
return true;
};
// Handle save
const handleSave = async () => {
if (!validateForm()) {
return;
}
if (isEditMode && profile) {
// Update existing profile
const updatedProfile: APIProfile = {
...profile,
name: name.trim(),
baseUrl: baseUrl.trim(),
// Only update API key if user is changing it
...(isChangingApiKey && { apiKey: apiKey.trim() }),
// Update models if provided
...(defaultModel || haikuModel || sonnetModel || opusModel ? {
models: {
...(defaultModel && { default: defaultModel.trim() }),
...(haikuModel && { haiku: haikuModel.trim() }),
...(sonnetModel && { sonnet: sonnetModel.trim() }),
...(opusModel && { opus: opusModel.trim() })
}
} : { models: undefined })
};
const success = await updateProfile(updatedProfile);
if (success) {
toast({
title: 'Profile updated',
description: `"${name.trim()}" has been updated successfully.`,
});
onOpenChange(false);
onSaved?.();
}
} else {
// Create new profile
const profileData: ProfileFormData = {
name: name.trim(),
baseUrl: baseUrl.trim(),
apiKey: apiKey.trim()
};
// Add optional models if provided
if (defaultModel || haikuModel || sonnetModel || opusModel) {
profileData.models = {};
if (defaultModel) profileData.models.default = defaultModel.trim();
if (haikuModel) profileData.models.haiku = haikuModel.trim();
if (sonnetModel) profileData.models.sonnet = sonnetModel.trim();
if (opusModel) profileData.models.opus = opusModel.trim();
}
const success = await saveProfile(profileData);
if (success) {
toast({
title: 'Profile created',
description: `"${name.trim()}" has been added successfully.`,
});
onOpenChange(false);
onSaved?.();
}
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md max-h-[90vh] overflow-y-auto" data-testid="profile-edit-dialog">
<DialogHeader>
<DialogTitle>{isEditMode ? 'Edit Profile' : 'Add API Profile'}</DialogTitle>
<DialogDescription>
Configure a custom Anthropic-compatible API endpoint for your builds.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{/* Name field (required) */}
<div className="space-y-2">
<Label htmlFor="profile-name">
Name <span className="text-destructive">*</span>
</Label>
<Input
id="profile-name"
placeholder="My Custom API"
value={name}
onChange={(e) => setName(e.target.value)}
className={nameError ? 'border-destructive' : ''}
/>
{nameError && <p className="text-sm text-destructive">{nameError}</p>}
</div>
{/* Base URL field (required) */}
<div className="space-y-2">
<Label htmlFor="profile-url">
Base URL <span className="text-destructive">*</span>
</Label>
<Input
id="profile-url"
placeholder="https://api.anthropic.com"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
className={urlError ? 'border-destructive' : ''}
/>
{urlError && <p className="text-sm text-destructive">{urlError}</p>}
<p className="text-xs text-muted-foreground">
Example: https://api.anthropic.com or http://localhost:8080
</p>
</div>
{/* API Key field (required for create, masked in edit mode) */}
<div className="space-y-2">
<Label htmlFor="profile-key">
API Key <span className="text-destructive">*</span>
</Label>
{isEditMode && !isChangingApiKey && profile ? (
// Edit mode: show masked API key
<div className="flex items-center gap-2">
<Input
id="profile-key"
value={maskApiKey(profile.apiKey)}
disabled
className="flex-1"
/>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsChangingApiKey(true)}
>
Change
</Button>
</div>
) : (
// Create mode or changing key: show password input
<>
<Input
id="profile-key"
type="password"
placeholder="sk-ant-..."
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className={keyError ? 'border-destructive' : ''}
/>
{isEditMode && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => {
setIsChangingApiKey(false);
setApiKey('');
setKeyError(null);
}}
>
Cancel
</Button>
)}
</>
)}
{keyError && <p className="text-sm text-destructive">{keyError}</p>}
</div>
{/* Test Connection button */}
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleTestConnection}
disabled={isTestingConnection || !isFormValidForTest()}
>
{isTestingConnection ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
'Test Connection'
)}
</Button>
{/* Inline connection test result */}
{showTestResult && testConnectionResult && (
<div className={`flex items-start gap-2 p-3 rounded-lg border ${
testConnectionResult.success
? 'bg-green-50 border-green-200 dark:bg-green-950 dark:border-green-800'
: 'bg-red-50 border-red-200 dark:bg-red-950 dark:border-red-800'
}`}>
{testConnectionResult.success ? (
<CheckCircle2 className="h-5 w-5 text-green-600 dark:text-green-400 mt-0.5 flex-shrink-0" />
) : (
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-400 mt-0.5 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className={`text-sm font-medium ${
testConnectionResult.success
? 'text-green-800 dark:text-green-200'
: 'text-red-800 dark:text-red-200'
}`}>
{testConnectionResult.success
? 'Connection Successful'
: 'Connection Failed'}
</p>
<p className={`text-sm ${
testConnectionResult.success
? 'text-green-700 dark:text-green-300'
: 'text-red-700 dark:text-red-300'
}`}>
{testConnectionResult.message}
</p>
</div>
</div>
)}
{/* Optional model mappings */}
<div className="space-y-3 pt-2 border-t">
<Label className="text-base">Optional: Model Name Mappings</Label>
<p className="text-xs text-muted-foreground">
Select models from your API provider. Leave blank to use defaults.
</p>
<div className="space-y-2">
<Label htmlFor="model-default" className="text-sm text-muted-foreground">
Default Model (Optional)
</Label>
<ModelSearchableSelect
value={defaultModel}
onChange={setDefaultModel}
placeholder="e.g., claude-3-5-sonnet-20241022"
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-haiku" className="text-sm text-muted-foreground">
Haiku Model (Optional)
</Label>
<ModelSearchableSelect
value={haikuModel}
onChange={setHaikuModel}
placeholder="e.g., claude-3-5-haiku-20241022"
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-sonnet" className="text-sm text-muted-foreground">
Sonnet Model (Optional)
</Label>
<ModelSearchableSelect
value={sonnetModel}
onChange={setSonnetModel}
placeholder="e.g., claude-3-5-sonnet-20241022"
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
<div className="space-y-2">
<Label htmlFor="model-opus" className="text-sm text-muted-foreground">
Opus Model (Optional)
</Label>
<ModelSearchableSelect
value={opusModel}
onChange={setOpusModel}
placeholder="e.g., claude-3-5-opus-20241022"
baseUrl={baseUrl}
apiKey={isEditMode && !isChangingApiKey && profile ? profile.apiKey : apiKey}
/>
</div>
</div>
{/* General error display */}
{profilesError && (
<div className="p-3 bg-destructive/10 border border-destructive/20 rounded-lg">
<p className="text-sm text-destructive">{profilesError}</p>
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={profilesLoading}
>
Cancel
</Button>
<Button
type="button"
onClick={handleSave}
disabled={profilesLoading}
>
{profilesLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
'Save Profile'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -1,300 +0,0 @@
/**
* @vitest-environment jsdom
*/
/**
* Component and utility tests for ProfileList
* Tests utility functions and verifies component structure
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import '@testing-library/jest-dom/vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { ProfileList } from './ProfileList';
import { maskApiKey } from '../../lib/profile-utils';
import { useSettingsStore } from '../../stores/settings-store';
import type { APIProfile } from '@shared/types/profile';
import { TooltipProvider } from '../ui/tooltip';
// Wrapper for components that need TooltipProvider
function TestWrapper({ children }: { children: React.ReactNode }) {
return <TooltipProvider>{children}</TooltipProvider>;
}
// Custom render with wrapper
function renderWithWrapper(ui: React.ReactElement) {
return render(ui, { wrapper: TestWrapper });
}
// Mock the settings store
vi.mock('../../stores/settings-store', () => ({
useSettingsStore: vi.fn()
}));
// Mock the toast hook
vi.mock('../../hooks/use-toast', () => ({
useToast: () => ({
toast: vi.fn()
})
}));
// Test profile data
const testProfiles: APIProfile[] = [
{
id: 'profile-1',
name: 'Production API',
baseUrl: 'https://api.anthropic.com',
apiKey: 'sk-ant-prod-key-1234',
models: { default: 'claude-3-5-sonnet-20241022' },
createdAt: Date.now(),
updatedAt: Date.now()
},
{
id: 'profile-2',
name: 'Development API',
baseUrl: 'https://dev-api.example.com/v1',
apiKey: 'sk-ant-test-key-5678',
models: undefined,
createdAt: Date.now(),
updatedAt: Date.now()
}
];
/**
* Factory function to create a default settings store mock
* Override properties by spreading with custom values
*/
function createSettingsStoreMock(overrides: Partial<ReturnType<typeof useSettingsStore>> = {}) {
const mockDeleteProfile = vi.fn().mockResolvedValue(true);
const mockSetActiveProfile = vi.fn().mockResolvedValue(true);
return {
profiles: testProfiles,
activeProfileId: 'profile-1' as string | null,
deleteProfile: mockDeleteProfile,
setActiveProfile: mockSetActiveProfile,
profilesLoading: false,
settings: {} as any,
isLoading: false,
error: null,
setSettings: vi.fn(),
updateSettings: vi.fn(),
setLoading: vi.fn(),
setError: vi.fn(),
setProfiles: vi.fn(),
setProfilesLoading: vi.fn(),
setProfilesError: vi.fn(),
saveProfile: vi.fn().mockResolvedValue(true),
updateProfile: vi.fn().mockResolvedValue(true),
profilesError: null,
...overrides
};
}
describe('ProfileList - maskApiKey Utility', () => {
it('should mask API key showing only last 4 characters', () => {
const apiKey = 'sk-ant-prod-key-1234';
const masked = maskApiKey(apiKey);
expect(masked).toBe('••••1234');
});
it('should return dots for keys with 4 or fewer characters', () => {
expect(maskApiKey('key')).toBe('••••');
expect(maskApiKey('1234')).toBe('••••');
expect(maskApiKey('')).toBe('••••');
});
it('should handle undefined or null keys', () => {
expect(maskApiKey(undefined as unknown as string)).toBe('••••');
expect(maskApiKey(null as unknown as string)).toBe('••••');
});
it('should mask long API keys correctly', () => {
const longKey = 'sk-ant-api03-very-long-key-abc123xyz789';
const masked = maskApiKey(longKey);
expect(masked).toBe('••••z789'); // Last 4 chars
expect(masked.length).toBe(8); // 4 dots + 4 chars
});
it('should mask keys with exactly 5 characters', () => {
const key = 'abcde';
const masked = maskApiKey(key);
expect(masked).toBe('••••bcde'); // Last 4 chars when length > 4
});
});
describe('ProfileList - Profile Data Structure', () => {
it('should have valid API profile structure', () => {
expect(testProfiles[0]).toMatchObject({
id: expect.any(String),
name: expect.any(String),
baseUrl: expect.any(String),
apiKey: expect.any(String),
models: expect.any(Object)
});
});
it('should support profiles without optional models field', () => {
expect(testProfiles[1].models).toBeUndefined();
});
it('should have non-empty required fields', () => {
testProfiles.forEach(profile => {
expect(profile.id).toBeTruthy();
expect(profile.name).toBeTruthy();
expect(profile.baseUrl).toBeTruthy();
expect(profile.apiKey).toBeTruthy();
});
});
});
describe('ProfileList - Component Export', () => {
it('should be able to import ProfileList component', async () => {
const { ProfileList } = await import('./ProfileList');
expect(ProfileList).toBeDefined();
expect(typeof ProfileList).toBe('function');
});
it('should be a named export', async () => {
const module = await import('./ProfileList');
expect(Object.keys(module)).toContain('ProfileList');
});
});
describe('ProfileList - URL Extraction', () => {
it('should extract host from valid URLs', () => {
const url1 = new URL(testProfiles[0].baseUrl);
expect(url1.host).toBe('api.anthropic.com');
const url2 = new URL(testProfiles[1].baseUrl);
expect(url2.host).toBe('dev-api.example.com');
});
it('should handle URLs with paths', () => {
const url = new URL('https://api.example.com/v1/messages');
expect(url.host).toBe('api.example.com');
expect(url.pathname).toBe('/v1/messages');
});
it('should handle URLs with ports', () => {
const url = new URL('https://localhost:8080/api');
expect(url.host).toBe('localhost:8080');
});
});
describe('ProfileList - Active Profile Logic', () => {
it('should identify active profile correctly', () => {
const activeProfileId = 'profile-1';
const activeProfile = testProfiles.find(p => p.id === activeProfileId);
expect(activeProfile?.id).toBe('profile-1');
expect(activeProfile?.name).toBe('Production API');
});
it('should return undefined for non-matching profile', () => {
const activeProfileId = 'non-existent';
const activeProfile = testProfiles.find(p => p.id === activeProfileId);
expect(activeProfile).toBeUndefined();
});
it('should handle null active profile ID', () => {
const activeProfileId = null;
const activeProfile = testProfiles.find(p => p.id === activeProfileId);
expect(activeProfile).toBeUndefined();
});
});
// Test 1: Delete confirmation dialog shows profile name correctly
describe('ProfileList - Delete Confirmation Dialog', () => {
beforeEach(() => {
vi.mocked(useSettingsStore).mockReturnValue(createSettingsStoreMock());
});
it('should show delete confirmation dialog with profile name', () => {
renderWithWrapper(<ProfileList />);
// Click delete button on first profile (find by test id)
const deleteButton = screen.getByTestId('profile-delete-button-profile-1');
fireEvent.click(deleteButton);
// Check dialog appears with profile name
expect(screen.getByText(/Delete Profile\?/i)).toBeInTheDocument();
expect(screen.getByText(/Are you sure you want to delete "Production API"\?/i)).toBeInTheDocument();
expect(screen.getByText(/Cancel/i)).toBeInTheDocument();
// Use getAllByText since there are multiple "Delete" elements (title + button)
expect(screen.getAllByText(/Delete/i).length).toBeGreaterThan(0);
});
// Test 5: Cancel delete → dialog closes, profile remains in list
it('should close dialog when cancel is clicked', () => {
const mockStore = createSettingsStoreMock();
vi.mocked(useSettingsStore).mockReturnValue(mockStore);
renderWithWrapper(<ProfileList />);
// Click delete button (find by test id)
const deleteButton = screen.getByTestId('profile-delete-button-profile-1');
fireEvent.click(deleteButton);
// Click cancel
fireEvent.click(screen.getByText(/Cancel/i));
// Dialog should be closed
expect(screen.queryByText(/Delete Profile\?/i)).not.toBeInTheDocument();
// Profiles should still be visible
expect(screen.getByText('Production API')).toBeInTheDocument();
expect(mockStore.deleteProfile).not.toHaveBeenCalled();
});
// Test 6: Delete confirmation dialog has delete action button
it('should show delete action button in confirmation dialog', () => {
vi.mocked(useSettingsStore).mockReturnValue(
createSettingsStoreMock({ activeProfileId: 'profile-2' })
);
renderWithWrapper(<ProfileList />);
// Click delete button on inactive profile (find by test id)
const deleteButton = screen.getByTestId('profile-delete-button-profile-1');
fireEvent.click(deleteButton);
// Dialog should have Delete elements (title "Delete Profile?" and "Delete" button)
const deleteElements = screen.getAllByText(/Delete/i);
expect(deleteElements.length).toBeGreaterThan(1); // At least title + button
});
});
describe('ProfileList - Switch to OAuth Button', () => {
beforeEach(() => {
vi.mocked(useSettingsStore).mockReturnValue(createSettingsStoreMock());
});
it('should show "Switch to OAuth" button when a profile is active', () => {
renderWithWrapper(<ProfileList />);
// Button should be visible when activeProfileId is set
expect(screen.getByText(/Switch to OAuth/i)).toBeInTheDocument();
});
it('should NOT show "Switch to OAuth" button when no profile is active', () => {
vi.mocked(useSettingsStore).mockReturnValue(
createSettingsStoreMock({ activeProfileId: null })
);
renderWithWrapper(<ProfileList />);
// Button should NOT be visible when activeProfileId is null
expect(screen.queryByText(/Switch to OAuth/i)).not.toBeInTheDocument();
});
it('should call setActiveProfile with null when "Switch to OAuth" is clicked', () => {
const mockStore = createSettingsStoreMock();
vi.mocked(useSettingsStore).mockReturnValue(mockStore);
renderWithWrapper(<ProfileList />);
// Click the "Switch to OAuth" button
const switchButton = screen.getByText(/Switch to OAuth/i);
fireEvent.click(switchButton);
// Should call setActiveProfile with null to switch to OAuth
expect(mockStore.setActiveProfile).toHaveBeenCalledWith(null);
});
});
@@ -1,308 +0,0 @@
/**
* ProfileList - Display and manage API profiles
*
* Shows all configured API profiles with an "Add Profile" button.
* Displays empty state when no profiles exist.
* Allows setting active profile, editing, and deleting profiles.
*/
import { useState } from 'react';
import { Plus, Trash2, Check, Server, Globe, Pencil } from 'lucide-react';
import { Button } from '../ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '../ui/tooltip';
import { useSettingsStore } from '../../stores/settings-store';
import { ProfileEditDialog } from './ProfileEditDialog';
import { maskApiKey } from '../../lib/profile-utils';
import { cn } from '../../lib/utils';
import { useToast } from '../../hooks/use-toast';
import type { APIProfile } from '@shared/types/profile';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '../ui/alert-dialog';
interface ProfileListProps {
/** Optional callback when a profile is saved */
onProfileSaved?: () => void;
}
export function ProfileList({ onProfileSaved }: ProfileListProps) {
const {
profiles,
activeProfileId,
deleteProfile,
setActiveProfile,
profilesError
} = useSettingsStore();
const { toast } = useToast();
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
const [editProfile, setEditProfile] = useState<APIProfile | null>(null);
const [deleteConfirmProfile, setDeleteConfirmProfile] = useState<APIProfile | null>(null);
const [isDeleting, setIsDeleting] = useState(false);
const [isSettingActive, setIsSettingActive] = useState(false);
const handleDeleteProfile = async () => {
if (!deleteConfirmProfile) return;
setIsDeleting(true);
const success = await deleteProfile(deleteConfirmProfile.id);
setIsDeleting(false);
if (success) {
toast({
title: 'Profile deleted',
description: `"${deleteConfirmProfile.name}" has been removed.`,
});
setDeleteConfirmProfile(null);
if (onProfileSaved) {
onProfileSaved();
}
} else {
// Show error toast - handles both active profile error and other errors
toast({
variant: 'destructive',
title: 'Failed to delete profile',
description: profilesError || 'An error occurred while deleting the profile.',
});
}
};
/**
* Handle setting a profile as active or switching to OAuth
* @param profileId - The profile ID to activate, or null to switch to OAuth
*/
const handleSetActiveProfile = async (profileId: string | null) => {
// Allow switching to OAuth (null) even when no profile is active
if (profileId !== null && profileId === activeProfileId) return;
setIsSettingActive(true);
const success = await setActiveProfile(profileId);
setIsSettingActive(false);
if (success) {
// Show success toast
if (profileId === null) {
// Switched to OAuth
toast({
title: 'Switched to OAuth',
description: 'Now using OAuth authentication',
});
} else {
// Switched to profile
const activeProfile = profiles.find(p => p.id === profileId);
if (activeProfile) {
toast({
title: 'Profile activated',
description: `Now using ${activeProfile.name}`,
});
}
}
if (onProfileSaved) {
onProfileSaved();
}
} else {
// Show error toast on failure
toast({
variant: 'destructive',
title: 'Failed to switch authentication',
description: profilesError || 'An error occurred while switching authentication method.',
});
}
};
const getHostFromUrl = (url: string): string => {
try {
const urlObj = new URL(url);
return urlObj.host;
} catch {
return url;
}
};
return (
<div className="space-y-4">
{/* Header with Add button */}
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold">API Profiles</h3>
<p className="text-sm text-muted-foreground">
Configure custom Anthropic-compatible API endpoints
</p>
</div>
<Button onClick={() => setIsAddDialogOpen(true)} size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Profile
</Button>
</div>
{/* Empty state */}
{profiles.length === 0 && (
<div className="flex flex-col items-center justify-center py-12 px-4 border border-dashed rounded-lg">
<Server className="h-12 w-12 text-muted-foreground mb-4" />
<h4 className="text-lg font-medium mb-2">No API profiles configured</h4>
<p className="text-sm text-muted-foreground text-center max-w-sm mb-4">
Create a profile to configure custom API endpoints for your builds.
</p>
<Button onClick={() => setIsAddDialogOpen(true)} variant="outline">
<Plus className="h-4 w-4 mr-2" />
Create First Profile
</Button>
</div>
)}
{/* Profile list */}
{profiles.length > 0 && (
<div className="space-y-2">
{/* Switch to OAuth button (visible when a profile is active) */}
{activeProfileId && (
<div className="flex items-center justify-end pb-2">
<Button
variant="outline"
size="sm"
onClick={() => handleSetActiveProfile(null)}
disabled={isSettingActive}
>
{isSettingActive ? 'Switching...' : 'Switch to OAuth'}
</Button>
</div>
)}
{profiles.map((profile) => (
<div
key={profile.id}
className={cn(
'flex items-center justify-between p-4 rounded-lg border transition-colors',
activeProfileId === profile.id
? 'border-primary bg-primary/5'
: 'border-border hover:bg-accent/50'
)}
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<h4 className="font-medium truncate">{profile.name}</h4>
{activeProfileId === profile.id && (
<span className="flex items-center text-xs text-primary">
<Check className="h-3 w-3 mr-1" />
Active
</span>
)}
</div>
<div className="flex items-center gap-4 text-sm text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-1">
<Globe className="h-3 w-3" />
<span className="truncate max-w-[200px]">
{getHostFromUrl(profile.baseUrl)}
</span>
</div>
</TooltipTrigger>
<TooltipContent>
<p>{profile.baseUrl}</p>
</TooltipContent>
</Tooltip>
<div className="truncate">
{maskApiKey(profile.apiKey)}
</div>
</div>
{profile.models && Object.keys(profile.models).length > 0 && (
<div className="mt-2 text-xs text-muted-foreground">
Custom models: {Object.keys(profile.models).join(', ')}
</div>
)}
</div>
<div className="flex items-center gap-2">
{activeProfileId !== profile.id && (
<Button
variant="ghost"
size="sm"
onClick={() => handleSetActiveProfile(profile.id)}
disabled={isSettingActive}
>
{isSettingActive ? 'Setting...' : 'Set Active'}
</Button>
)}
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setEditProfile(profile)}
>
<Pencil className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Edit profile</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteConfirmProfile(profile)}
className="text-destructive hover:text-destructive"
data-testid={`profile-delete-button-${profile.id}`}
aria-label={`Delete profile ${profile.name}`}
>
<Trash2 className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Delete profile</TooltipContent>
</Tooltip>
</div>
</div>
))}
</div>
)}
{/* Add/Edit Dialog */}
<ProfileEditDialog
open={isAddDialogOpen || editProfile !== null}
onOpenChange={(open) => {
if (!open) {
setIsAddDialogOpen(false);
setEditProfile(null);
}
}}
onSaved={() => {
setIsAddDialogOpen(false);
setEditProfile(null);
onProfileSaved?.();
}}
profile={editProfile ?? undefined}
/>
{/* Delete Confirmation Dialog */}
<AlertDialog
open={deleteConfirmProfile !== null}
onOpenChange={() => setDeleteConfirmProfile(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Profile?</AlertDialogTitle>
<AlertDialogDescription>
Are you sure you want to delete "{deleteConfirmProfile?.name}"? This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleDeleteProfile}
disabled={isDeleting}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isDeleting ? 'Deleting...' : 'Delete'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,130 +0,0 @@
/**
* Toast UI Components
*
* Based on Radix UI Toast for non-intrusive notifications.
*/
import * as React from 'react';
import * as ToastPrimitives from '@radix-ui/react-toast';
import { cva, type VariantProps } from 'class-variance-authority';
import { X } from 'lucide-react';
import { cn } from '../../lib/utils';
const ToastProvider = ToastPrimitives.Provider;
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
'fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]',
className
)}
{...props}
/>
));
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
const toastVariants = cva(
'group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full',
{
variants: {
variant: {
default: 'border bg-card text-foreground',
destructive: 'destructive group border-destructive bg-destructive text-destructive-foreground',
},
},
defaultVariants: {
variant: 'default',
},
}
);
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> & VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
);
});
Toast.displayName = ToastPrimitives.Root.displayName;
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
'inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive',
className
)}
{...props}
/>
));
ToastAction.displayName = ToastPrimitives.Action.displayName;
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
'absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600',
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
));
ToastClose.displayName = ToastPrimitives.Close.displayName;
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn('text-sm font-semibold', className)}
{...props}
/>
));
ToastTitle.displayName = ToastPrimitives.Title.displayName;
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn('text-sm opacity-90', className)}
{...props}
/>
));
ToastDescription.displayName = ToastPrimitives.Description.displayName;
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
type ToastActionElement = React.ReactElement<typeof ToastAction>;
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
};
@@ -1,39 +0,0 @@
/**
* Toaster Component
*
* Renders the toast viewport where toasts are displayed.
* Should be included once in the app root.
*/
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from './toast';
import { useToast } from '../../hooks/use-toast';
export function Toaster() {
const { toasts } = useToast();
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
);
})}
<ToastViewport />
</ToastProvider>
);
}
@@ -1,192 +0,0 @@
/**
* Toast Hook
*
* Manages toast state for displaying notifications.
*/
import * as React from 'react';
import type { ToastActionElement, ToastProps } from '../components/ui/toast';
const TOAST_LIMIT = 1;
const TOAST_REMOVE_DELAY = 1000000;
type ToasterToast = ToastProps & {
id: string;
title?: React.ReactNode;
description?: React.ReactNode;
action?: ToastActionElement;
};
const actionTypes = {
ADD_TOAST: 'ADD_TOAST',
UPDATE_TOAST: 'UPDATE_TOAST',
DISMISS_TOAST: 'DISMISS_TOAST',
REMOVE_TOAST: 'REMOVE_TOAST',
} as const;
let count = 0;
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER;
return count.toString();
}
type ActionType = typeof actionTypes;
type Action =
| {
type: ActionType['ADD_TOAST'];
toast: ToasterToast;
}
| {
type: ActionType['UPDATE_TOAST'];
toast: Partial<ToasterToast>;
}
| {
type: ActionType['DISMISS_TOAST'];
toastId?: ToasterToast['id'];
}
| {
type: ActionType['REMOVE_TOAST'];
toastId?: ToasterToast['id'];
};
interface State {
toasts: ToasterToast[];
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>();
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return;
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId);
dispatch({
type: 'REMOVE_TOAST',
toastId: toastId,
});
}, TOAST_REMOVE_DELAY);
toastTimeouts.set(toastId, timeout);
};
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case 'ADD_TOAST':
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
};
case 'UPDATE_TOAST':
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
};
case 'DISMISS_TOAST': {
const { toastId } = action;
if (toastId) {
addToRemoveQueue(toastId);
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id);
});
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
};
}
case 'REMOVE_TOAST':
if (action.toastId === undefined) {
return {
...state,
toasts: [],
};
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
};
}
};
const listeners: Array<(state: State) => void> = [];
let memoryState: State = { toasts: [] };
function dispatch(action: Action) {
memoryState = reducer(memoryState, action);
listeners.forEach((listener) => {
listener(memoryState);
});
}
type Toast = Omit<ToasterToast, 'id'>;
function toast({ ...props }: Toast) {
const id = genId();
const update = (props: ToasterToast) =>
dispatch({
type: 'UPDATE_TOAST',
toast: { ...props, id },
});
const dismiss = () => dispatch({ type: 'DISMISS_TOAST', toastId: id });
dispatch({
type: 'ADD_TOAST',
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss();
},
},
});
return {
id: id,
dismiss,
update,
};
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState);
React.useEffect(() => {
listeners.push(setState);
return () => {
const index = listeners.indexOf(setState);
if (index > -1) {
listeners.splice(index, 1);
}
};
}, []);
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: 'DISMISS_TOAST', toastId }),
};
}
export { useToast, toast };
@@ -110,57 +110,6 @@ const browserMockAPI: ElectronAPI = {
// Infrastructure & Docker Operations
...infrastructureMock,
// API Profile Management (custom Anthropic-compatible endpoints)
getAPIProfiles: async () => ({
success: true,
data: {
profiles: [],
activeProfileId: null,
version: 1
}
}),
saveAPIProfile: async (profile) => ({
success: true,
data: {
id: `mock-profile-${Date.now()}`,
...profile,
createdAt: Date.now(),
updatedAt: Date.now()
}
}),
updateAPIProfile: async (profile) => ({
success: true,
data: {
...profile,
updatedAt: Date.now()
}
}),
deleteAPIProfile: async (_profileId: string) => ({
success: true
}),
setActiveAPIProfile: async (_profileId: string | null) => ({
success: true
}),
testConnection: async (_baseUrl: string, _apiKey: string, _signal?: AbortSignal) => ({
success: true,
data: {
success: true,
message: 'Connection successful (mock)'
}
}),
discoverModels: async (_baseUrl: string, _apiKey: string, _signal?: AbortSignal) => ({
success: true,
data: {
models: []
}
}),
// GitHub API
github: {
getGitHubRepositories: async () => ({ success: true, data: [] }),
@@ -1,49 +0,0 @@
/**
* Profile Utility Functions
*
* Helper functions for API profile management in the renderer process.
*/
/**
* Mask API key for display - shows only last 4 characters
* Example: sk-ant-test-key-1234 -> 1234
*/
export function maskApiKey(key: string): string {
if (!key || key.length <= 4) {
return '••••';
}
return `••••${key.slice(-4)}`;
}
/**
* Validate if a string is a valid URL format
*/
export function isValidUrl(url: string): boolean {
if (!url || url.trim() === '') {
return false;
}
try {
const urlObj = new URL(url);
return urlObj.protocol === 'http:' || urlObj.protocol === 'https:';
} catch {
return false;
}
}
/**
* Validate if a string looks like a valid API key
* (basic length and character check)
*/
export function isValidApiKey(key: string): boolean {
if (!key || key.trim() === '') {
return false;
}
const trimmed = key.trim();
if (trimmed.length < 12) {
return false;
}
return /^[a-zA-Z0-9\-_+.]+$/.test(trimmed);
}
@@ -1,45 +1,17 @@
import { create } from 'zustand';
import type { AppSettings } from '../../shared/types';
import type { APIProfile, ProfileFormData, TestConnectionResult, DiscoverModelsResult, ModelInfo } from '@shared/types/profile';
import { DEFAULT_APP_SETTINGS } from '../../shared/constants';
import { toast } from '../hooks/use-toast';
interface SettingsState {
settings: AppSettings;
isLoading: boolean;
error: string | null;
// API Profile state
profiles: APIProfile[];
activeProfileId: string | null;
profilesLoading: boolean;
profilesError: string | null;
// Test connection state
isTestingConnection: boolean;
testConnectionResult: TestConnectionResult | null;
// Model discovery state
modelsLoading: boolean;
modelsError: string | null;
discoveredModels: Map<string, ModelInfo[]>; // Cache key -> models mapping
// Actions
setSettings: (settings: AppSettings) => void;
updateSettings: (updates: Partial<AppSettings>) => void;
setLoading: (loading: boolean) => void;
setError: (error: string | null) => void;
// Profile actions
setProfiles: (profiles: APIProfile[], activeProfileId: string | null) => void;
setProfilesLoading: (loading: boolean) => void;
setProfilesError: (error: string | null) => void;
saveProfile: (profile: ProfileFormData) => Promise<boolean>;
updateProfile: (profile: APIProfile) => Promise<boolean>;
deleteProfile: (profileId: string) => Promise<boolean>;
setActiveProfile: (profileId: string | null) => Promise<boolean>;
testConnection: (baseUrl: string, apiKey: string, signal?: AbortSignal) => Promise<TestConnectionResult | null>;
discoverModels: (baseUrl: string, apiKey: string, signal?: AbortSignal) => Promise<ModelInfo[] | null>;
}
export const useSettingsStore = create<SettingsState>((set) => ({
@@ -47,21 +19,6 @@ export const useSettingsStore = create<SettingsState>((set) => ({
isLoading: true, // Start as true since we load settings on app init
error: null,
// API Profile state
profiles: [],
activeProfileId: null,
profilesLoading: false,
profilesError: null,
// Test connection state
isTestingConnection: false,
testConnectionResult: null,
// Model discovery state
modelsLoading: false,
modelsError: null,
discoveredModels: new Map<string, ModelInfo[]>(),
setSettings: (settings) => set({ settings }),
updateSettings: (updates) =>
@@ -71,227 +28,7 @@ export const useSettingsStore = create<SettingsState>((set) => ({
setLoading: (isLoading) => set({ isLoading }),
setError: (error) => set({ error }),
// Profile actions
setProfiles: (profiles, activeProfileId) => set({ profiles, activeProfileId }),
setProfilesLoading: (profilesLoading) => set({ profilesLoading }),
setProfilesError: (profilesError) => set({ profilesError }),
saveProfile: async (profile: ProfileFormData): Promise<boolean> => {
set({ profilesLoading: true, profilesError: null });
try {
const result = await window.electronAPI.saveAPIProfile(profile);
if (result.success && result.data) {
// Re-fetch profiles from backend to get authoritative activeProfileId
// (backend only auto-activates the first profile)
try {
const profilesResult = await window.electronAPI.getAPIProfiles();
if (profilesResult.success && profilesResult.data) {
set({
profiles: profilesResult.data.profiles,
activeProfileId: profilesResult.data.activeProfileId,
profilesLoading: false
});
} else {
// Fallback: add profile locally but don't assume activeProfileId
set((state) => ({
profiles: [...state.profiles, result.data!],
profilesLoading: false
}));
}
} catch {
// Fallback on fetch error: add profile locally
set((state) => ({
profiles: [...state.profiles, result.data!],
profilesLoading: false
}));
}
return true;
}
set({
profilesError: result.error || 'Failed to save profile',
profilesLoading: false
});
return false;
} catch (error) {
set({
profilesError: error instanceof Error ? error.message : 'Failed to save profile',
profilesLoading: false
});
return false;
}
},
updateProfile: async (profile: APIProfile): Promise<boolean> => {
set({ profilesLoading: true, profilesError: null });
try {
const result = await window.electronAPI.updateAPIProfile(profile);
if (result.success && result.data) {
set((state) => ({
profiles: state.profiles.map((p) =>
p.id === result.data!.id ? result.data! : p
),
profilesLoading: false
}));
return true;
}
set({
profilesError: result.error || 'Failed to update profile',
profilesLoading: false
});
return false;
} catch (error) {
set({
profilesError: error instanceof Error ? error.message : 'Failed to update profile',
profilesLoading: false
});
return false;
}
},
deleteProfile: async (profileId: string): Promise<boolean> => {
set({ profilesLoading: true, profilesError: null });
try {
const result = await window.electronAPI.deleteAPIProfile(profileId);
if (result.success) {
set((state) => ({
profiles: state.profiles.filter((p) => p.id !== profileId),
activeProfileId: state.activeProfileId === profileId ? null : state.activeProfileId,
profilesLoading: false
}));
return true;
}
set({
profilesError: result.error || 'Failed to delete profile',
profilesLoading: false
});
return false;
} catch (error) {
set({
profilesError: error instanceof Error ? error.message : 'Failed to delete profile',
profilesLoading: false
});
return false;
}
},
setActiveProfile: async (profileId: string | null): Promise<boolean> => {
set({ profilesLoading: true, profilesError: null });
try {
const result = await window.electronAPI.setActiveAPIProfile(profileId);
if (result.success) {
set({ activeProfileId: profileId, profilesLoading: false });
return true;
}
set({
profilesError: result.error || 'Failed to set active profile',
profilesLoading: false
});
return false;
} catch (error) {
set({
profilesError: error instanceof Error ? error.message : 'Failed to set active profile',
profilesLoading: false
});
return false;
}
},
testConnection: async (baseUrl: string, apiKey: string, signal?: AbortSignal): Promise<TestConnectionResult | null> => {
set({ isTestingConnection: true, testConnectionResult: null });
try {
const result = await window.electronAPI.testConnection(baseUrl, apiKey, signal);
// Type narrowing pattern
if (result.success && result.data) {
set({ testConnectionResult: result.data, isTestingConnection: false });
// Show toast on success
// TODO: Use i18n translation keys (settings:connection.successTitle, settings:connection.successDescription)
// Note: Zustand stores can't use useTranslation() hook - need to pass t() or use i18n.t()
if (result.data.success) {
toast({
title: 'Connection successful',
description: 'Your API credentials are valid.'
});
}
return result.data;
}
// Error from IPC layer - set testConnectionResult for inline display
const errorResult: TestConnectionResult = {
success: false,
errorType: 'unknown',
message: result.error || 'Failed to test connection'
};
set({ testConnectionResult: errorResult, isTestingConnection: false });
toast({
variant: 'destructive',
title: 'Connection test failed',
description: result.error || 'Failed to test connection'
});
return errorResult;
} catch (error) {
// Unexpected error - set testConnectionResult for inline display
const errorResult: TestConnectionResult = {
success: false,
errorType: 'unknown',
message: error instanceof Error ? error.message : 'Failed to test connection'
};
set({ testConnectionResult: errorResult, isTestingConnection: false });
toast({
variant: 'destructive',
title: 'Connection test failed',
description: error instanceof Error ? error.message : 'Failed to test connection'
});
return errorResult;
}
},
discoverModels: async (baseUrl: string, apiKey: string, signal?: AbortSignal): Promise<ModelInfo[] | null> => {
console.log('[settings-store] discoverModels called with:', { baseUrl, apiKey: `${apiKey.slice(-4)}` });
// Generate cache key from baseUrl and apiKey (last 4 chars)
const cacheKey = `${baseUrl}::${apiKey.slice(-4)}`;
// Check cache first
const state = useSettingsStore.getState();
const cached = state.discoveredModels.get(cacheKey);
if (cached) {
console.log('[settings-store] Returning cached models');
return cached;
}
// Fetch from API
set({ modelsLoading: true, modelsError: null });
try {
console.log('[settings-store] Calling window.electronAPI.discoverModels...');
const result = await window.electronAPI.discoverModels(baseUrl, apiKey, signal);
console.log('[settings-store] discoverModels result:', result);
if (result.success && result.data) {
const models = result.data.models;
// Cache the results
set((state) => ({
discoveredModels: new Map(state.discoveredModels).set(cacheKey, models),
modelsLoading: false
}));
return models;
}
// Error from IPC layer
set({ modelsError: result.error || 'Failed to discover models', modelsLoading: false });
return null;
} catch (error) {
set({
modelsError: error instanceof Error ? error.message : 'Failed to discover models',
modelsLoading: false
});
return null;
}
}
setError: (error) => set({ error })
}));
/**
@@ -367,22 +104,3 @@ export async function saveSettings(updates: Partial<AppSettings>): Promise<boole
return false;
}
}
/**
* Load API profiles from main process
*/
export async function loadProfiles(): Promise<void> {
const store = useSettingsStore.getState();
store.setProfilesLoading(true);
try {
const result = await window.electronAPI.getAPIProfiles();
if (result.success && result.data) {
store.setProfiles(result.data.profiles, result.data.activeProfileId);
}
} catch (error) {
store.setProfilesError(error instanceof Error ? error.message : 'Failed to load profiles');
} finally {
store.setProfilesLoading(false);
}
}
-11
View File
@@ -111,17 +111,6 @@ export const IPC_CHANNELS = {
SETTINGS_SAVE: 'settings:save',
SETTINGS_GET_CLI_TOOLS_INFO: 'settings:getCliToolsInfo',
// API Profile management (custom Anthropic-compatible endpoints)
PROFILES_GET: 'profiles:get',
PROFILES_SAVE: 'profiles:save',
PROFILES_UPDATE: 'profiles:update',
PROFILES_DELETE: 'profiles:delete',
PROFILES_SET_ACTIVE: 'profiles:setActive',
PROFILES_TEST_CONNECTION: 'profiles:test-connection',
PROFILES_TEST_CONNECTION_CANCEL: 'profiles:test-connection-cancel',
PROFILES_DISCOVER_MODELS: 'profiles:discover-models',
PROFILES_DISCOVER_MODELS_CANCEL: 'profiles:discover-models-cancel',
// Dialogs
DIALOG_SELECT_DIRECTORY: 'dialog:selectDirectory',
DIALOG_CREATE_PROJECT_FOLDER: 'dialog:createProjectFolder',
@@ -63,7 +63,6 @@
},
"steps": {
"welcome": "Welcome",
"authChoice": "Auth Method",
"auth": "Auth",
"claudeCode": "CLI",
"devtools": "Dev Tools",
@@ -33,10 +33,6 @@
"title": "Integrations",
"description": "API keys & Claude accounts"
},
"api-profiles": {
"title": "API Profiles",
"description": "Custom API endpoint profiles"
},
"updates": {
"title": "Updates",
"description": "Auto Claude updates"
@@ -95,8 +95,5 @@
"retry": "Retry",
"selectFile": "Select a file to view its contents",
"openInIDE": "Open in IDE"
},
"metadata": {
"severity": "severity"
}
}
@@ -63,7 +63,6 @@
},
"steps": {
"welcome": "Bienvenue",
"authChoice": "Méthode d'auth",
"auth": "Auth",
"claudeCode": "CLI",
"devtools": "Outils dev",
@@ -33,10 +33,6 @@
"title": "Intégrations",
"description": "Clés API & comptes Claude"
},
"api-profiles": {
"title": "Profils API",
"description": "Profils d'endpoint API personnalisés"
},
"updates": {
"title": "Mises à jour",
"description": "Mises à jour Auto Claude"
@@ -95,8 +95,5 @@
"retry": "Réessayer",
"selectFile": "Sélectionnez un fichier pour voir son contenu",
"openInIDE": "Ouvrir dans l'IDE"
},
"metadata": {
"severity": "sévérité"
}
}
-11
View File
@@ -123,7 +123,6 @@ import type {
GitLabMRReviewProgress,
GitLabNewCommitsCheck
} from './integrations';
import type { APIProfile, ProfilesFile, TestConnectionResult, DiscoverModelsResult } from './profile';
// Electron API exposed via contextBridge
// Tab state interface (persisted in main process)
@@ -264,16 +263,6 @@ export interface ElectronAPI {
claude: import('./cli').ToolDetectionResult;
}>>;
// API Profile management (custom Anthropic-compatible endpoints)
getAPIProfiles: () => Promise<IPCResult<ProfilesFile>>;
saveAPIProfile: (profile: Omit<APIProfile, 'id' | 'createdAt' | 'updatedAt'>) => Promise<IPCResult<APIProfile>>;
updateAPIProfile: (profile: APIProfile) => Promise<IPCResult<APIProfile>>;
deleteAPIProfile: (profileId: string) => Promise<IPCResult>;
setActiveAPIProfile: (profileId: string | null) => Promise<IPCResult>;
// Note: AbortSignal is handled in preload via separate cancel IPC channels, not passed through IPC
testConnection: (baseUrl: string, apiKey: string, signal?: AbortSignal) => Promise<IPCResult<TestConnectionResult>>;
discoverModels: (baseUrl: string, apiKey: string, signal?: AbortSignal) => Promise<IPCResult<DiscoverModelsResult>>;
// Dialog operations
selectDirectory: () => Promise<string | null>;
createProjectFolder: (location: string, name: string, initGit: boolean) => Promise<IPCResult<CreateProjectFolderResult>>;
-92
View File
@@ -1,92 +0,0 @@
/**
* API Profile Management Types
*
* Users can configure custom Anthropic-compatible API endpoints with profiles.
* Each profile contains name, base URL, API key, and optional model mappings.
*
* NOTE: These types are intentionally duplicated from libs/profile-service/src/types/profile.ts
* because the frontend build (Electron + Vite) doesn't consume the workspace library types directly.
* Keep these definitions in sync with the library types when making changes.
*/
/**
* API Profile - represents a custom API endpoint configuration
* IMPORTANT: Named APIProfile (not Profile) to avoid conflicts with user profiles
*/
export interface APIProfile {
id: string; // UUID v4
name: string; // User-friendly name
baseUrl: string; // API endpoint URL (e.g., https://api.anthropic.com)
apiKey: string; // Full API key (never display in UI - use maskApiKey())
models?: {
// OPTIONAL - only specify models to override
default?: string; // Maps to ANTHROPIC_MODEL
haiku?: string; // Maps to ANTHROPIC_DEFAULT_HAIKU_MODEL
sonnet?: string; // Maps to ANTHROPIC_DEFAULT_SONNET_MODEL
opus?: string; // Maps to ANTHROPIC_DEFAULT_OPUS_MODEL
};
createdAt: number; // Unix timestamp (ms)
updatedAt: number; // Unix timestamp (ms)
}
/**
* Profile file structure - stored in profiles.json
*/
export interface ProfilesFile {
profiles: APIProfile[];
activeProfileId: string | null;
version: number;
}
/**
* Form data type for creating/editing profiles (without id, models optional)
*/
export interface ProfileFormData {
name: string;
baseUrl: string;
apiKey: string;
models?: {
default?: string;
haiku?: string;
sonnet?: string;
opus?: string;
};
}
/**
* Shared error type for connection-related errors
* Used by both TestConnectionResult and DiscoverModelsError
*/
export type ConnectionErrorType = 'auth' | 'network' | 'endpoint' | 'timeout' | 'not_supported' | 'unknown';
/**
* Test connection result - returned by profile:test-connection
*/
export interface TestConnectionResult {
success: boolean;
errorType?: ConnectionErrorType;
message: string;
}
/**
* Model information from /v1/models endpoint
*/
export interface ModelInfo {
id: string; // Model ID (e.g., "claude-sonnet-4-20250514")
display_name: string; // Human-readable name (e.g., "Claude Sonnet 4")
}
/**
* Result from discoverModels operation
*/
export interface DiscoverModelsResult {
models: ModelInfo[];
}
/**
* Error from discoverModels operation
*/
export interface DiscoverModelsError {
errorType: ConnectionErrorType;
message: string;
}
+10 -16
View File
@@ -1,30 +1,24 @@
{
"spec_id": "025-improving-task-card-title-readability",
"spec_id": "011-fix-scale-adjustment-and-view-reload-issues",
"subtasks": [
{
"id": "1",
"title": "Restructure TaskCard header: Remove flex wrapper around title, make title standalone with full width",
"status": "completed"
},
{
"id": "2",
"title": "Relocate status badges from header to metadata section",
"status": "completed"
},
{
"id": "3",
"title": "Add localization for security severity badge label",
"title": "Fix slider to defer view reload until drag ends and add zoom button functionality",
"status": "completed"
}
],
"qa_signoff": {
"status": "fixes_applied",
"timestamp": "2026-01-01T11:58:40Z",
"fix_session": 1,
"timestamp": "2025-12-27T02:20:00Z",
"fix_session": 0,
"issues_fixed": [
{
"title": "Missing localization for hardcoded 'severity' string in TaskCard",
"fix_commit": "de0c8e4"
"title": "Remove preview hint textbox",
"fix_commit": "2653019"
},
{
"title": "Remove unused preview translation keys",
"fix_commit": "e17536c"
}
],
"ready_for_qa_revalidation": true
+2 -16596
View File
File diff suppressed because it is too large Load Diff
+1 -8
View File
@@ -4,10 +4,6 @@
"description": "Autonomous multi-agent coding framework powered by Claude AI",
"license": "AGPL-3.0",
"author": "Auto Claude Team",
"workspaces": [
"apps/*",
"libs/*"
],
"scripts": {
"install:backend": "node scripts/install-backend.js",
"install:frontend": "cd apps/frontend && npm install",
@@ -40,8 +36,5 @@
"coding",
"agents",
"electron"
],
"devDependencies": {
"jsdom": "^27.4.0"
}
]
}
+2 -3
View File
@@ -27,11 +27,10 @@ function run(cmd, options = {}) {
}
// Find Python 3.12+
// Prefer 3.12 first since it has the most stable wheel support for native packages
function findPython() {
const candidates = isWindows
? ['py -3.12', 'py -3.13', 'py -3.14', 'python3.12', 'python3.13', 'python3.14', 'python3', 'python']
: ['python3.12', 'python3.13', 'python3.14', 'python3', 'python'];
? ['py -3.14', 'py -3.13', 'py -3.12', 'python3.14', 'python3.13', 'python3.12', 'python3', 'python']
: ['python3.14', 'python3.13', 'python3.12', 'python3', 'python'];
for (const cmd of candidates) {
try {
+12 -197
View File
@@ -13,22 +13,22 @@ Tests the project_analyzer.py module functionality including:
"""
import json
import pytest
from pathlib import Path
import pytest
from project_analyzer import (
BASE_COMMANDS,
DATABASE_COMMANDS,
FRAMEWORK_COMMANDS,
INFRASTRUCTURE_COMMANDS,
LANGUAGE_COMMANDS,
CustomScripts,
ProjectAnalyzer,
SecurityProfile,
TechnologyStack,
CustomScripts,
get_or_create_profile,
is_command_allowed,
needs_validation,
BASE_COMMANDS,
LANGUAGE_COMMANDS,
FRAMEWORK_COMMANDS,
DATABASE_COMMANDS,
INFRASTRUCTURE_COMMANDS,
)
@@ -489,7 +489,6 @@ class TestSecurityProfileGeneration:
# Force re-analysis
import time
time.sleep(0.1) # Ensure different timestamp
profile2 = get_or_create_profile(python_project, force_reanalyze=True)
@@ -583,23 +582,11 @@ class TestSecurityProfileSerialization:
"stack_commands": ["python"],
"script_commands": [],
"custom_commands": [],
"detected_stack": {
"languages": ["python"],
"package_managers": [],
"frameworks": [],
"databases": [],
"infrastructure": [],
"cloud_providers": [],
"code_quality_tools": [],
"version_managers": [],
},
"custom_scripts": {
"npm_scripts": [],
"make_targets": [],
"poetry_scripts": [],
"cargo_aliases": [],
"shell_scripts": [],
},
"detected_stack": {"languages": ["python"], "package_managers": [], "frameworks": [],
"databases": [], "infrastructure": [], "cloud_providers": [],
"code_quality_tools": [], "version_managers": []},
"custom_scripts": {"npm_scripts": [], "make_targets": [], "poetry_scripts": [],
"cargo_aliases": [], "shell_scripts": []},
"project_dir": "/test",
"created_at": "2024-01-01",
"project_hash": "abc123",
@@ -630,175 +617,3 @@ class TestSecurityProfileSerialization:
assert "ls" in loaded.base_commands
assert "python" in loaded.stack_commands
assert loaded.project_hash == "test123"
class TestDartFlutterDetection:
"""Tests for Dart/Flutter language and framework detection."""
def test_detects_dart_language(self, temp_dir: Path):
"""Detects Dart from pubspec.yaml."""
pubspec = """name: my_app
version: 1.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
"""
(temp_dir / "pubspec.yaml").write_text(pubspec)
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_languages()
assert "dart" in analyzer.profile.detected_stack.languages
def test_detects_dart_from_files(self, temp_dir: Path):
"""Detects Dart from .dart files."""
(temp_dir / "lib").mkdir()
(temp_dir / "lib" / "main.dart").write_text("void main() {}")
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_languages()
assert "dart" in analyzer.profile.detected_stack.languages
def test_detects_flutter_framework(self, temp_dir: Path):
"""Detects Flutter framework from pubspec.yaml."""
pubspec = """name: my_flutter_app
version: 1.0.0
environment:
sdk: ">=3.0.0 <4.0.0"
flutter: ">=3.0.0"
dependencies:
flutter:
sdk: flutter
"""
(temp_dir / "pubspec.yaml").write_text(pubspec)
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_frameworks()
assert "flutter" in analyzer.profile.detected_stack.frameworks
def test_detects_pub_package_manager(self, temp_dir: Path):
"""Detects pub package manager from pubspec.yaml."""
pubspec = """name: my_app
version: 1.0.0
"""
(temp_dir / "pubspec.yaml").write_text(pubspec)
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_package_managers()
assert "pub" in analyzer.profile.detected_stack.package_managers
def test_detects_pub_from_lock_file(self, temp_dir: Path):
"""Detects pub package manager from pubspec.lock."""
(temp_dir / "pubspec.lock").write_text("packages:\n")
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_package_managers()
assert "pub" in analyzer.profile.detected_stack.package_managers
class TestMelosMonorepoDetection:
"""Tests for Melos monorepo tool detection."""
def test_detects_melos_from_config(self, temp_dir: Path):
"""Detects Melos from melos.yaml."""
melos_config = """name: my_workspace
packages:
- packages/*
"""
(temp_dir / "melos.yaml").write_text(melos_config)
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_package_managers()
assert "melos" in analyzer.profile.detected_stack.package_managers
def test_melos_commands_allowed(self, temp_dir: Path):
"""Melos commands are allowed when detected."""
melos_config = """name: my_workspace
packages:
- packages/*
"""
(temp_dir / "melos.yaml").write_text(melos_config)
profile = get_or_create_profile(temp_dir, force_reanalyze=True)
assert "melos" in profile.stack_commands
class TestFvmVersionManagerDetection:
"""Tests for Flutter Version Manager (FVM) detection."""
def test_detects_fvm_from_directory(self, temp_dir: Path):
"""Detects FVM from .fvm directory."""
(temp_dir / ".fvm").mkdir()
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_version_managers()
assert "fvm" in analyzer.profile.detected_stack.version_managers
def test_detects_fvm_from_config(self, temp_dir: Path):
"""Detects FVM from fvm_config.json."""
fvm_config = '{"flutterSdkVersion": "3.19.0"}'
(temp_dir / "fvm_config.json").write_text(fvm_config)
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_version_managers()
assert "fvm" in analyzer.profile.detected_stack.version_managers
def test_detects_fvm_from_fvmrc(self, temp_dir: Path):
"""Detects FVM from .fvmrc file."""
(temp_dir / ".fvmrc").write_text('{"flutter": "3.19.0"}')
analyzer = ProjectAnalyzer(temp_dir)
analyzer._detect_version_managers()
assert "fvm" in analyzer.profile.detected_stack.version_managers
def test_fvm_commands_allowed(self, temp_dir: Path):
"""FVM commands are allowed when detected."""
(temp_dir / ".fvm").mkdir()
profile = get_or_create_profile(temp_dir, force_reanalyze=True)
assert "fvm" in profile.stack_commands
class TestDartFlutterCommandsAllowed:
"""Tests that Dart/Flutter commands are properly allowed."""
def test_dart_commands_allowed_for_dart_project(self, temp_dir: Path):
"""Dart commands are allowed when Dart is detected."""
pubspec = """name: my_app
version: 1.0.0
"""
(temp_dir / "pubspec.yaml").write_text(pubspec)
profile = get_or_create_profile(temp_dir, force_reanalyze=True)
# Core Dart commands
assert "dart" in profile.stack_commands
assert "pub" in profile.stack_commands
# Flutter should be available for Dart projects
assert "flutter" in profile.stack_commands
def test_flutter_commands_allowed_for_flutter_project(self, temp_dir: Path):
"""Flutter commands are allowed when Flutter is detected."""
pubspec = """name: my_flutter_app
version: 1.0.0
dependencies:
flutter:
sdk: flutter
"""
(temp_dir / "pubspec.yaml").write_text(pubspec)
profile = get_or_create_profile(temp_dir, force_reanalyze=True)
assert "flutter" in profile.stack_commands
assert "dart" in profile.stack_commands
assert "pub" in profile.stack_commands
+2 -3
View File
@@ -184,7 +184,7 @@ class TestSpecOrchestratorInit:
assert orchestrator.spec_dir == custom_spec_dir
def test_init_default_model(self, temp_dir: Path):
"""Uses default model (shorthand)."""
"""Uses default model."""
with patch('spec.pipeline.init_auto_claude_dir') as mock_init:
mock_init.return_value = (temp_dir / ".auto-claude", False)
specs_dir = temp_dir / ".auto-claude" / "specs"
@@ -192,8 +192,7 @@ class TestSpecOrchestratorInit:
orchestrator = SpecOrchestrator(project_dir=temp_dir)
# Default is now "sonnet" shorthand (resolved via API Profile if configured)
assert orchestrator.model == "sonnet"
assert orchestrator.model == "claude-sonnet-4-5-20250929"
def test_init_custom_model(self, temp_dir: Path):
"""Uses custom model."""