Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0301212b9d | |||
| 272b8792cd | |||
| fd5341f14c |
@@ -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"
|
||||
|
||||
@@ -163,4 +163,3 @@ _bmad-output/
|
||||
.claude/
|
||||
/docs
|
||||
OPUS_ANALYSIS_AND_IDEAS.md
|
||||
/.github/agents
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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"},
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
"""
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
Generated
+2321
-1326
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
|
||||
@@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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)
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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: ''
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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', () => ({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
-616
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Generated
+2
-16596
File diff suppressed because it is too large
Load Diff
+1
-8
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user