From cf96a3253c58a3ce8001923113a44dc19f3d0896 Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Sat, 7 Mar 2026 08:24:24 +0000 Subject: [PATCH] fix(tests): replace hardcoded /tmp paths with tempdir + add 300 unit tests (#659) * test: add unit tests across 20 modules for coverage push Add 300+ unit tests covering config, context, evaluation, extensions, LLM, secrets, tools/builder, and tools/mcp modules. All tests are pure unit tests (no mocks) exercising serde roundtrips, edge cases, error paths, and business logic. Co-Authored-By: Claude Opus 4.6 * fix(tests): replace hardcoded /tmp paths with tempfile::tempdir The e2e_metrics_test::test_metrics_collected_from_tool_trace test was failing because setup_test_dir() created /tmp/ironclaw_metrics_test but the fixture referenced /tmp/ironclaw_e2e_test/hello.txt (path mismatch). Added LlmTrace::replace_paths() to substitute fixture paths at runtime, then converted all 12 test files from hardcoded /tmp/ironclaw_* paths to tempfile::tempdir(). Tests are now isolated, parallel-safe, and leave no debris on disk. Regression test: test_metrics_collected_from_tool_trace now passes consistently regardless of prior /tmp state. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 --- COVERAGE_PLAN.md | 862 +++++++++++++++++++++++++++ src/config/channels.rs | 159 +++++ src/config/llm.rs | 2 +- src/config/mod.rs | 2 +- src/config/sandbox.rs | 201 +++++++ src/config/tunnel.rs | 212 +++++++ src/context/manager.rs | 387 ++++++++++++ src/context/memory.rs | 272 +++++++++ src/evaluation/metrics.rs | 216 +++++++ src/evaluation/success.rs | 255 +++++++- src/extensions/discovery.rs | 176 ++++++ src/extensions/mod.rs | 414 +++++++++++++ src/llm/nearai_chat.rs | 595 ++++++++++++++++++ src/llm/session.rs | 150 +++++ src/secrets/crypto.rs | 104 ++++ src/secrets/types.rs | 222 +++++++ src/setup/wizard.rs | 6 +- src/tools/builder/core.rs | 376 +++++++++++- src/tools/builder/templates.rs | 159 +++++ src/tools/builder/validation.rs | 160 ++++- src/tools/builtin/extension_tools.rs | 4 +- src/tools/mcp/auth.rs | 306 ++++++++++ src/tools/mcp/client.rs | 157 +++++ src/tools/mcp/protocol.rs | 273 +++++++++ src/tools/mcp/session.rs | 104 ++++ tests/e2e_advanced_traces.rs | 50 +- tests/e2e_metrics_test.rs | 14 +- tests/e2e_spot_checks.rs | 20 +- tests/e2e_tool_coverage.rs | 38 +- tests/e2e_trace_file_tools.rs | 18 +- tests/e2e_worker_coverage.rs | 17 +- tests/support/trace_llm.rs | 54 ++ tests/support_unit_tests.rs | 38 +- 33 files changed, 5904 insertions(+), 119 deletions(-) create mode 100644 COVERAGE_PLAN.md diff --git a/COVERAGE_PLAN.md b/COVERAGE_PLAN.md new file mode 100644 index 00000000..c9d7d73b --- /dev/null +++ b/COVERAGE_PLAN.md @@ -0,0 +1,862 @@ +# IronClaw Coverage Plan: 63.3% to 95% + +> Generated 2025-03-06 from [Codecov](https://app.codecov.io/gh/nearai/ironclaw/tree/main/src) + +## Current State + +| Metric | Value | +|--------|-------| +| **Current coverage** | 48,571 / 76,694 lines = **63.33%** | +| **Target** | 72,859 / 76,694 lines = **95.0%** | +| **Gap** | **24,288 lines** need coverage | +| **Files >= 95%** | 43 / 239 | +| **Files < 95%** | 196 (27,872 total misses) | + +## Module Summary + +Sorted by uncovered lines (descending): + +| Module | Lines | Hits | Miss | Coverage | Priority | +|--------|------:|-----:|-----:|---------:|----------| +| `channels/` | 14,079 | 8,677 | 5,402 | 61.6% | P0 | +| `tools/` | 13,445 | 9,407 | 4,038 | 70.0% | P1 | +| `agent/` | 9,152 | 6,096 | 3,056 | 66.6% | P0 | +| `setup/` | 3,005 | 462 | 2,543 | 15.4% | P1 | +| `extensions/` | 3,540 | 1,298 | 2,242 | 36.7% | P0 | +| `cli/` | 2,834 | 697 | 2,137 | 24.6% | P1 | +| `history/` | 1,626 | 0 | 1,626 | 0.0% | P0 | +| `llm/` | 7,029 | 5,776 | 1,253 | 82.2% | P2 | +| `(root)` | 4,122 | 3,121 | 1,001 | 75.7% | P2 | +| `worker/` | 1,274 | 480 | 794 | 37.7% | P1 | +| `sandbox/` | 1,615 | 897 | 718 | 55.5% | P2 | +| `registry/` | 1,588 | 1,107 | 481 | 69.7% | P2 | +| `db/` | 921 | 441 | 480 | 47.9% | P1 | +| `workspace/` | 2,006 | 1,584 | 422 | 79.0% | P2 | +| `orchestrator/` | 1,199 | 795 | 404 | 66.3% | P2 | +| `config/` | 1,464 | 1,095 | 369 | 74.8% | P2 | +| `hooks/` | 1,379 | 1,081 | 298 | 78.4% | P2 | +| `secrets/` | 687 | 407 | 280 | 59.2% | P2 | +| `skills/` | 1,714 | 1,585 | 129 | 92.5% | P3 | +| `context/` | 693 | 586 | 107 | 84.6% | P3 | +| `estimation/` | 467 | 369 | 98 | 79.0% | P3 | +| `safety/` | 1,424 | 1,337 | 87 | 93.9% | P3 | +| `evaluation/` | 226 | 152 | 74 | 67.3% | P3 | +| `pairing/` | 498 | 446 | 52 | 89.6% | P3 | +| `tunnel/` | 391 | 368 | 23 | 94.1% | P3 | +| `observability/` | 316 | 307 | 9 | 97.2% | Done | + +## Top 40 Files by Uncovered Lines + +These files account for the vast majority of the coverage gap: + +| File | Lines | Miss | Coverage | Lines to 95% | +|------|------:|-----:|---------:|--------------:| +| `src/extensions/manager.rs` | 2,404 | 2,083 | 13.3% | 1,962 | +| `src/setup/wizard.rs` | 2,150 | 1,789 | 16.8% | 1,681 | +| `src/history/store.rs` | 1,486 | 1,486 | 0.0% | 1,411 | +| `src/channels/web/server.rs` | 1,985 | 993 | 50.0% | 893 | +| `src/channels/wasm/wrapper.rs` | 2,237 | 934 | 58.2% | 822 | +| `src/agent/thread_ops.rs` | 1,044 | 763 | 26.9% | 710 | +| `src/cli/tool.rs` | 757 | 735 | 2.9% | 697 | +| `src/setup/channels.rs` | 645 | 596 | 7.6% | 563 | +| `src/agent/commands.rs` | 587 | 587 | 0.0% | 557 | +| `src/main.rs` | 740 | 522 | 29.4% | 485 | +| `src/channels/web/handlers/jobs.rs` | 513 | 456 | 11.1% | 430 | +| `src/tools/builder/core.rs` | 524 | 456 | 13.0% | 429 | +| `src/agent/worker.rs` | 1,078 | 467 | 56.7% | 413 | +| `src/channels/web/handlers/chat.rs` | 564 | 417 | 26.1% | 388 | +| `src/tools/wasm/wrapper.rs` | 1,005 | 436 | 56.6% | 385 | +| `src/channels/signal.rs` | 1,814 | 472 | 74.0% | 381 | +| `src/tools/mcp/auth.rs` | 472 | 378 | 19.9% | 354 | +| `src/worker/runtime.rs` | 350 | 330 | 5.7% | 312 | +| `src/tools/builtin/job.rs` | 1,014 | 359 | 64.6% | 308 | +| `src/cli/mcp.rs` | 322 | 319 | 0.9% | 302 | +| `src/cli/oauth_defaults.rs` | 730 | 335 | 54.1% | 298 | +| `src/llm/nearai_chat.rs` | 854 | 340 | 60.2% | 297 | +| `src/sandbox/container.rs` | 407 | 317 | 22.1% | 296 | +| `src/tools/mcp/client.rs` | 341 | 291 | 14.7% | 273 | +| `src/registry/installer.rs` | 765 | 311 | 59.3% | 272 | +| `src/orchestrator/job_manager.rs` | 405 | 270 | 33.3% | 249 | +| `src/channels/web/handlers/routines.rs` | 249 | 249 | 0.0% | 236 | +| `src/agent/scheduler.rs` | 559 | 263 | 53.0% | 235 | +| `src/tools/wasm/storage.rs` | 296 | 243 | 17.9% | 228 | +| `src/channels/repl.rs` | 233 | 233 | 0.0% | 221 | +| `src/llm/session.rs` | 413 | 242 | 41.4% | 221 | +| `src/worker/claude_bridge.rs` | 629 | 247 | 60.7% | 215 | +| `src/agent/agent_loop.rs` | 523 | 234 | 55.2% | 207 | +| `src/worker/api.rs` | 258 | 207 | 19.8% | 194 | +| `src/sandbox/proxy/http.rs` | 307 | 192 | 37.5% | 176 | +| `src/channels/wasm/storage.rs` | 182 | 182 | 0.0% | 172 | +| `src/cli/registry.rs` | 177 | 177 | 0.0% | 168 | +| `src/llm/reasoning.rs` | 1,163 | 219 | 81.2% | 160 | +| `src/tools/builder/testing.rs` | 308 | 174 | 43.5% | 158 | +| `src/db/postgres.rs` | 166 | 166 | 0.0% | 157 | + +--- + +## Tier 1 -- High-Impact Unit Tests (~8,500 lines) + +Pure logic, serialization, and database queries testable in isolation without real +infrastructure. Highest coverage gain per unit of effort. + +### `src/history/store.rs` -- 0% -> 95% (+1,411 lines) + +PostgreSQL repository layer (conversations, jobs, actions, LLM calls, estimation +snapshots). Test query construction and result mapping. Can use the libSQL backend +as a real in-memory database or test doubles for the `Database` trait. + +**Tests to write:** +- `test_store_conversation_crud` -- create, read, update, delete conversations +- `test_store_job_lifecycle` -- insert job, update status through state machine +- `test_store_action_recording` -- record and query job actions +- `test_store_llm_call_tracking` -- insert and aggregate LLM call records +- `test_store_estimation_snapshots` -- save and retrieve estimation data + +### `src/history/analytics.rs` -- 0% -> 95% (+133 lines) + +Aggregation queries (JobStats, ToolStats). Test the query builders and result +deserialization. + +**Tests to write:** +- `test_job_stats_aggregation` -- verify counts, durations, success rates +- `test_tool_stats_ranking` -- verify tool usage frequency sorting +- `test_analytics_empty_db` -- graceful handling of no data + +### `src/extensions/manager.rs` -- 13.3% -> 95% (+1,962 lines) + +Largest single file gap. Extension lifecycle orchestration (install, auth, +activate, remove), config parsing, and state transitions. + +**Tests to write:** +- `test_extension_install_from_manifest` -- parse manifest, create extension record +- `test_extension_auth_flow` -- OAuth token setup, credential storage +- `test_extension_activate_deactivate` -- state transitions, tool registration +- `test_extension_remove_cleanup` -- remove extension, clean up artifacts +- `test_extension_config_validation` -- reject invalid configs, handle defaults +- `test_extension_list_filtering` -- filter by status, type, search query +- `test_extension_capability_check` -- verify required capabilities before activation + +### `src/extensions/discovery.rs` -- 27.8% -> 95% (+125 lines) + +Extension discovery from filesystem and registry. + +**Tests to write:** +- `test_discover_local_extensions` -- scan directory, parse manifests +- `test_discover_skip_invalid` -- gracefully skip malformed extension dirs +- `test_discover_dedup` -- handle duplicate extensions across paths + +### `src/tools/builder/core.rs` -- 13% -> 95% (+429 lines) + +`BuildRequirement`, `SoftwareType`, `Language` types and project scaffolding. + +**Tests to write:** +- `test_build_requirement_parsing` -- deserialize from JSON +- `test_scaffold_project_structure` -- verify generated file tree +- `test_language_detection` -- detect language from file extensions +- `test_software_type_constraints` -- validate type-specific requirements + +### `src/tools/builder/testing.rs` -- 43.5% -> 95% (+158 lines) + +Test harness integration for built tools. + +**Tests to write:** +- `test_harness_setup_teardown` -- lifecycle of test environment +- `test_harness_run_tests` -- execute tests and capture results +- `test_harness_failure_reporting` -- verify error details on test failure + +### `src/tools/mcp/auth.rs` -- 19.9% -> 95% (+354 lines) + +OAuth token management for MCP servers. + +**Tests to write:** +- `test_token_refresh_on_expiry` -- auto-refresh when token expires +- `test_token_header_injection` -- correct Authorization header format +- `test_token_persistence` -- save/load tokens across restarts +- `test_oauth_pkce_flow` -- code verifier/challenge generation +- `test_auth_config_parsing` -- parse various auth config formats + +### `src/tools/mcp/client.rs` -- 14.7% -> 95% (+273 lines) + +JSON-RPC client for MCP protocol. + +**Tests to write:** +- `test_jsonrpc_request_serialization` -- correct JSON-RPC 2.0 format +- `test_jsonrpc_response_parsing` -- handle success, error, and batch responses +- `test_jsonrpc_error_codes` -- map MCP error codes to ToolError +- `test_tool_list_discovery` -- parse tools/list response +- `test_tool_call_roundtrip` -- serialize call, parse result + +### `src/tools/wasm/storage.rs` -- 17.9% -> 95% (+228 lines) + +WASM tool persistence (store, load, delete, list). + +**Tests to write:** +- `test_wasm_tool_store_roundtrip` -- store and retrieve tool binary + metadata +- `test_wasm_tool_delete` -- remove tool and verify gone +- `test_wasm_tool_list_filtering` -- filter by name, capability +- `test_wasm_tool_update_metadata` -- update without re-uploading binary + +### `src/tools/wasm/wrapper.rs` -- 56.6% -> 95% (+385 lines) + +Tool trait wrapper for WASM modules. + +**Tests to write:** +- `test_wasm_param_marshalling` -- JSON params to WASM component model types +- `test_wasm_output_conversion` -- WASM return values to ToolOutput +- `test_wasm_error_propagation` -- WASM traps to ToolError +- `test_wasm_fuel_exhaustion` -- verify fuel limit enforcement +- `test_wasm_memory_limit` -- verify memory ceiling + +### `src/tools/wasm/loader.rs` -- 62.4% -> 95% (+156 lines) + +WASM tool discovery from filesystem. + +**Tests to write:** +- `test_loader_scan_directory` -- find .wasm files with capabilities.json +- `test_loader_skip_invalid` -- skip files without valid WIT exports +- `test_loader_cache_invalidation` -- reload when file changes + +### `src/tools/builtin/job.rs` -- 64.6% -> 95% (+308 lines) + +Job management tools (CreateJob, ListJobs, JobStatus, CancelJob). + +**Tests to write:** +- `test_create_job_params` -- validate required/optional parameters +- `test_list_jobs_formatting` -- verify output structure +- `test_job_status_transitions` -- query status at each state +- `test_cancel_job_running` -- cancel an in-progress job +- `test_cancel_job_completed` -- error on already-completed job + +### `src/secrets/store.rs` -- 48.1% -> 95% (+145 lines) + +Encrypted secret storage. + +**Tests to write:** +- `test_secret_store_roundtrip` -- store encrypted, retrieve decrypted +- `test_secret_update` -- overwrite existing secret +- `test_secret_delete` -- remove and verify inaccessible +- `test_secret_list_redacted` -- list shows names but not values + +### `src/llm/session.rs` -- 41.4% -> 95% (+221 lines) + +Session token management with auto-renewal. + +**Tests to write:** +- `test_session_token_parsing` -- parse `sess_xxx` format +- `test_session_expiry_detection` -- detect expired tokens +- `test_session_auto_renewal` -- trigger renewal before expiry +- `test_session_concurrent_renewal` -- only one renewal in flight + +### `src/llm/nearai_chat.rs` -- 60.2% -> 95% (+297 lines) + +NEAR AI Chat Completions provider. + +**Tests to write:** +- `test_nearai_request_building` -- correct endpoint, headers, body +- `test_nearai_response_parsing` -- parse streaming and non-streaming responses +- `test_nearai_tool_message_flattening` -- tool messages flattened to text +- `test_nearai_auth_modes` -- session token vs API key auth +- `test_nearai_error_handling` -- rate limits, auth failures, server errors + +### `src/llm/mod.rs` -- 53.7% -> 95% (+112 lines) + +Provider factory and backend selection. + +**Tests to write:** +- `test_provider_factory_nearai` -- select NEAR AI from config +- `test_provider_factory_openai` -- select OpenAI from config +- `test_provider_factory_ollama` -- select Ollama from config +- `test_provider_factory_invalid` -- error on unknown backend + +### `src/llm/reasoning.rs` -- 81.2% -> 95% (+160 lines) + +Planning, tool selection, evaluation logic. + +**Tests to write:** +- `test_reasoning_step_parsing` -- parse planning steps from LLM output +- `test_tool_selection_scoring` -- rank tools by relevance +- `test_evaluation_rubric` -- score completions against criteria +- `test_reasoning_with_no_tools` -- handle tool-less responses + +### `src/db/postgres.rs` -- 0% -> 95% (+157 lines) + +PostgreSQL backend delegation to Store + Repository. + +**Tests to write:** +- `test_postgres_backend_delegates` -- verify delegation pattern (trait-level) +- `test_postgres_connection_config` -- TLS, pool size, timeout parsing + +### `src/workspace/mod.rs` -- 75.9% -> 95% (+109 lines) + +Memory operations (write, read, search, tree). + +**Tests to write:** +- `test_workspace_write_read` -- write document, read it back +- `test_workspace_search_hybrid` -- FTS + vector search via RRF +- `test_workspace_tree` -- directory listing of memory filesystem +- `test_workspace_overwrite` -- update existing document + +### `src/workspace/embeddings.rs` -- 35.1% -> 95% (~100 lines) + +Embedding provider abstraction. + +**Tests to write:** +- `test_embedding_dimension_handling` -- verify dimension config +- `test_embedding_batch_processing` -- batch multiple chunks +- `test_embedding_provider_fallback` -- graceful degradation when unavailable + +--- + +## Tier 2 -- Trace Tests (~7,000 lines) + +End-to-end tests that exercise the agent loop, worker, scheduler, and dispatcher +by replaying LLM traces through `TestRig` (see `tests/support/test_rig.rs`). Each +trace test covers multiple modules simultaneously, making them high-leverage. + +Each trace test needs: +1. A JSON fixture in `tests/fixtures/llm_traces/` +2. A test file in `tests/` using `TestRigBuilder` + +### Trace: Thread Operations + +**Covers:** `agent/thread_ops.rs` (+710 lines) + +Test thread creation, listing, switching, and deletion via trace replay. + +**Fixture:** `thread_operations.json` +**Tests:** +- `test_thread_create_and_switch` -- create thread, switch to it, verify context +- `test_thread_list` -- list all threads, verify metadata +- `test_thread_delete` -- delete thread, verify removal +- `test_thread_switch_nonexistent` -- error handling for missing thread + +### Trace: Agent Commands + +**Covers:** `agent/commands.rs` (+557 lines) + +Test slash commands through the agent loop. + +**Fixture:** `agent_commands.json` +**Tests:** +- `test_command_help` -- /help returns command list +- `test_command_clear` -- /clear resets conversation +- `test_command_compact` -- /compact triggers summarization +- `test_command_undo_redo` -- /undo then /redo restores state +- `test_command_status` -- /status shows agent state + +### Trace: Worker Multi-Turn Execution + +**Covers:** `agent/worker.rs` (+413 lines), `agent/agent_loop.rs` (+207 lines) + +Test multi-turn tool calling, error recovery, and completion flows. + +**Fixture:** `worker_multi_turn.json` +**Tests:** +- `test_worker_sequential_tools` -- call tool A, then tool B based on A's result +- `test_worker_tool_error_recovery` -- tool fails, agent retries or adapts +- `test_worker_max_turns` -- verify turn limit enforcement + +### Trace: Scheduler Parallel Jobs + +**Covers:** `agent/scheduler.rs` (+235 lines) + +Test parallel job dispatch and completion tracking. + +**Fixture:** `scheduler_parallel.json` +**Tests:** +- `test_scheduler_parallel_dispatch` -- dispatch 3 jobs, all complete +- `test_scheduler_job_dependency` -- job B waits for job A +- `test_scheduler_stuck_detection` -- detect and recover stuck job + +### Trace: Dispatcher Skill Selection + +**Covers:** `agent/dispatcher.rs` (+153 lines) + +Test skill-aware routing and tool attenuation. + +**Fixture:** `dispatcher_skills.json` +**Tests:** +- `test_dispatcher_skill_match` -- match message to skill, inject prompt +- `test_dispatcher_tool_attenuation` -- installed skill loses dangerous tools +- `test_dispatcher_no_skill` -- fallback when no skill matches + +### Trace: Routine Execution + +**Covers:** `agent/routine_engine.rs` (~80 lines), `agent/routine.rs` (~40 lines) + +Test cron tick and event-triggered routine execution. + +**Fixture:** `routine_execution.json` +**Tests:** +- `test_routine_cron_trigger` -- routine fires on schedule +- `test_routine_event_trigger` -- routine fires on matching event +- `test_routine_guardrails` -- routine respects policy constraints + +### Trace: Compaction and Context Pressure + +**Covers:** `agent/compaction.rs` (~50 lines), `agent/context_monitor.rs` (~30 lines) + +Test turn summarization and memory pressure detection. + +**Fixture:** `compaction_flow.json` +**Tests:** +- `test_compaction_triggers_at_threshold` -- summarize when context exceeds limit +- `test_compaction_preserves_recent` -- keep recent turns intact +- `test_context_pressure_warning` -- emit warning at high usage + +### Trace: Job Tool Coverage + +**Covers:** `tools/builtin/job.rs` (+308 lines), `tools/builtin/skill_tools.rs` (+110 lines) + +Test job and skill management tools through agent execution. + +**Fixture:** `job_and_skill_tools.json` +**Tests:** +- `test_create_and_list_jobs` -- create job, list shows it +- `test_job_status_query` -- query status of running job +- `test_skill_list_and_search` -- list local skills, search registry + +### Trace: Memory Tools + +**Covers:** `tools/builtin/memory.rs` (~20 lines), `workspace/` (+109 lines) + +Test memory operations through agent tool calls. + +**Fixture:** `memory_tools.json` +**Tests:** +- `test_memory_write_and_search` -- write doc, search finds it +- `test_memory_read_by_path` -- read specific document +- `test_memory_tree` -- list memory filesystem structure + +### Trace: Extension Management + +**Covers:** `tools/builtin/extension_tools.rs` (~40 lines) + +Test extension lifecycle via agent tool calls. + +**Fixture:** `extension_management.json` +**Tests:** +- `test_extension_install_via_tool` -- agent installs an extension +- `test_extension_auth_via_tool` -- agent configures auth +- `test_extension_activate_via_tool` -- agent activates extension + +### Trace: Self-Repair + +**Covers:** `agent/self_repair.rs` (~40 lines) + +Test stuck job detection and recovery. + +**Fixture:** `self_repair.json` +**Tests:** +- `test_stuck_job_detected` -- job stuck for > threshold triggers repair +- `test_stuck_job_recovered` -- recovery restarts job successfully +- `test_stuck_job_fails_permanently` -- recovery fails, job marked failed + +### Trace: Heartbeat + +**Covers:** `agent/heartbeat.rs` (+80 lines) + +Test periodic proactive execution. + +**Fixture:** `heartbeat.json` +**Tests:** +- `test_heartbeat_periodic_fire` -- heartbeat triggers at interval +- `test_heartbeat_reads_checklist` -- reads HEARTBEAT.md, processes items +- `test_heartbeat_notification` -- sends notification on findings + +--- + +## Tier 3 -- Web/Channel Handler Tests (~4,500 lines) + +Test HTTP handlers and SSE/WS endpoints using `axum_test` or +`tower::ServiceExt::oneshot` with a real router and in-memory database. + +### `src/channels/web/server.rs` -- 50% -> 95% (+893 lines) + +The single biggest web gap. 40+ API endpoints. + +**Tests to write:** +- `test_api_health` -- GET /health returns 200 +- `test_api_chat_submit` -- POST /api/chat sends message +- `test_api_jobs_list` -- GET /api/jobs returns job list +- `test_api_jobs_create` -- POST /api/jobs creates job +- `test_api_routines_crud` -- full CRUD cycle for routines +- `test_api_settings_get_set` -- GET/PUT settings +- `test_api_memory_search` -- POST /api/memory/search +- `test_api_extensions_list` -- GET /api/extensions +- `test_api_skills_list` -- GET /api/skills +- `test_api_sse_connect` -- SSE stream connects and receives events +- `test_api_auth_required` -- endpoints reject missing/bad tokens +- `test_api_cors_headers` -- verify CORS configuration + +### `src/channels/web/handlers/chat.rs` -- 26.1% -> 95% (+388 lines) + +Chat message submission and SSE streaming. + +**Tests to write:** +- `test_chat_submit_message` -- submit message, receive response +- `test_chat_sse_stream` -- verify SSE event format +- `test_chat_thread_context` -- messages scoped to thread +- `test_chat_invalid_payload` -- reject malformed requests + +### `src/channels/web/handlers/jobs.rs` -- 11.1% -> 95% (+430 lines) + +Job CRUD endpoints. + +**Tests to write:** +- `test_jobs_list_empty` -- empty list returns [] +- `test_jobs_create_and_get` -- create, then GET by ID +- `test_jobs_cancel` -- cancel running job +- `test_jobs_filter_by_status` -- filter by pending/running/completed +- `test_jobs_pagination` -- limit/offset parameters + +### `src/channels/web/handlers/routines.rs` -- 0% -> 95% (+236 lines) + +Routine CRUD endpoints. + +**Tests to write:** +- `test_routines_create` -- POST creates routine +- `test_routines_list` -- GET lists all routines +- `test_routines_update` -- PUT updates routine config +- `test_routines_delete` -- DELETE removes routine +- `test_routines_history` -- GET history for a routine + +### `src/channels/web/handlers/extensions.rs` -- 0% -> 95% (+129 lines) + +Extension management endpoints. + +**Tests to write:** +- `test_extensions_list` -- list installed extensions +- `test_extensions_install` -- install from manifest URL +- `test_extensions_activate` -- activate/deactivate toggle +- `test_extensions_remove` -- remove installed extension + +### `src/channels/web/handlers/memory.rs` -- 0% -> 95% (+110 lines) + +Memory/workspace endpoints. + +**Tests to write:** +- `test_memory_search` -- search returns ranked results +- `test_memory_write` -- write a document +- `test_memory_read` -- read by path +- `test_memory_tree` -- tree returns filesystem structure + +### `src/channels/web/handlers/settings.rs` -- 0% -> 95% (+103 lines) + +Settings endpoints. + +**Tests to write:** +- `test_settings_get` -- retrieve current settings +- `test_settings_update` -- update individual setting +- `test_settings_validation` -- reject invalid setting values + +### `src/channels/web/handlers/static_files.rs` -- 0% -> 95% (+97 lines) + +Static file serving. + +**Tests to write:** +- `test_static_index_html` -- GET / serves index.html +- `test_static_css_js` -- serve CSS/JS with correct content types +- `test_static_404` -- missing file returns 404 + +### `src/channels/wasm/wrapper.rs` -- 58.2% -> 95% (+822 lines) + +WASM channel wrapper (message routing, lifecycle). + +**Tests to write:** +- `test_wasm_channel_start` -- initialize WASM channel module +- `test_wasm_channel_message_routing` -- route incoming message to WASM +- `test_wasm_channel_response` -- return WASM response to caller +- `test_wasm_channel_error_handling` -- handle WASM trap gracefully +- `test_wasm_channel_lifecycle` -- start, process, shutdown + +### `src/channels/wasm/loader.rs` -- 38.1% -> 95% (+141 lines) + +WASM channel discovery. + +**Tests to write:** +- `test_channel_loader_scan` -- find channel WASM modules +- `test_channel_loader_validation` -- reject invalid modules +- `test_channel_loader_manifest` -- parse channel capabilities + +### `src/channels/wasm/storage.rs` -- 0% -> 95% (+172 lines) + +WASM channel state persistence. + +**Tests to write:** +- `test_channel_storage_save_load` -- persist and restore channel state +- `test_channel_storage_isolation` -- per-channel state isolation +- `test_channel_storage_cleanup` -- remove state on channel uninstall + +### `src/channels/signal.rs` -- 74% -> 95% (+381 lines) + +Signal protocol channel. + +**Tests to write:** +- `test_signal_message_send` -- send encrypted message +- `test_signal_message_receive` -- decrypt incoming message +- `test_signal_attachment_handling` -- handle media attachments +- `test_signal_group_message` -- group chat routing +- `test_signal_error_handling` -- handle connection failures + +### `src/channels/repl.rs` -- 0% -> 95% (+221 lines) + +Simple REPL channel. + +**Tests to write:** +- `test_repl_input_parsing` -- parse user input lines +- `test_repl_output_formatting` -- format agent responses +- `test_repl_multiline` -- handle multi-line input +- `test_repl_special_commands` -- handle /quit, /help + +--- + +## Tier 4 -- CLI Tests (~2,100 lines) + +CLI subcommands can be tested by invoking clap-parsed command structs directly +or by calling the handler functions with constructed arguments. + +### `src/cli/tool.rs` -- 2.9% -> 95% (+697 lines) + +Tool CLI (install, list, remove, build). + +**Tests to write:** +- `test_cli_tool_list` -- list installed tools +- `test_cli_tool_install_local` -- install from local .wasm file +- `test_cli_tool_install_registry` -- install from registry +- `test_cli_tool_remove` -- remove installed tool +- `test_cli_tool_build` -- scaffold and build tool project +- `test_cli_tool_info` -- display tool details + +### `src/cli/mcp.rs` -- 0.9% -> 95% (+302 lines) + +MCP server management CLI. + +**Tests to write:** +- `test_cli_mcp_list` -- list configured MCP servers +- `test_cli_mcp_add` -- add MCP server config +- `test_cli_mcp_remove` -- remove MCP server config +- `test_cli_mcp_tools` -- list tools from MCP server +- `test_cli_mcp_test_connection` -- verify MCP server reachable + +### `src/cli/oauth_defaults.rs` -- 54.1% -> 95% (+298 lines) + +OAuth default configurations. + +**Tests to write:** +- `test_oauth_defaults_loading` -- load default OAuth configs +- `test_oauth_url_construction` -- build auth/token URLs +- `test_oauth_scope_merging` -- merge requested scopes with defaults +- `test_oauth_provider_lookup` -- lookup by provider name + +### `src/cli/registry.rs` -- 0% -> 95% (+168 lines) + +Registry CLI commands. + +**Tests to write:** +- `test_cli_registry_search` -- search for packages +- `test_cli_registry_install` -- install package from registry +- `test_cli_registry_info` -- display package details + +### `src/cli/status.rs` -- 0% -> 95% (+142 lines) + +Status display commands. + +**Tests to write:** +- `test_cli_status_gathering` -- collect system status info +- `test_cli_status_formatting` -- render status output +- `test_cli_status_components` -- check individual components + +### `src/cli/memory.rs` -- 15.5% -> 95% (+138 lines) + +Memory CLI subcommands. + +**Tests to write:** +- `test_cli_memory_search` -- search workspace from CLI +- `test_cli_memory_write` -- write document from CLI +- `test_cli_memory_read` -- read document from CLI +- `test_cli_memory_tree` -- display memory tree + +### `src/cli/doctor.rs` -- 28.7% -> 95% (+115 lines) + +Diagnostic checks. + +**Tests to write:** +- `test_doctor_check_database` -- verify DB connectivity check +- `test_doctor_check_llm` -- verify LLM provider check +- `test_doctor_check_tools` -- verify tool availability check +- `test_doctor_report_format` -- verify output format + +### `src/cli/config.rs` -- 36.5% -> 95% (~100 lines) + +Config CLI subcommands. + +**Tests to write:** +- `test_cli_config_get` -- read config value +- `test_cli_config_set` -- write config value +- `test_cli_config_list` -- list all config keys +- `test_cli_config_reset` -- reset to defaults + +--- + +## Tier 5 -- Setup/Infra Tests (~2,400 lines) + +Hardest to test: interactive wizards, Docker, process spawning. Strategy: extract +pure logic into testable functions, test the interactive parts by injecting mock +input. + +### `src/setup/wizard.rs` -- 16.8% -> 95% (+1,681 lines) + +7-step interactive onboarding wizard. Refactor to extract validation functions, +step logic, and config generation into testable units. + +**Tests to write:** +- `test_wizard_step_validation` -- each step validates input correctly +- `test_wizard_config_generation` -- generate config from wizard answers +- `test_wizard_default_values` -- verify sensible defaults +- `test_wizard_skip_completed` -- skip already-configured steps +- `test_wizard_llm_backend_selection` -- provider-specific config paths +- `test_wizard_channel_setup` -- channel configuration logic + +### `src/setup/channels.rs` -- 7.6% -> 95% (+563 lines) + +Channel setup helpers. + +**Tests to write:** +- `test_channel_setup_defaults` -- default channel configuration +- `test_channel_setup_validation` -- reject invalid channel configs +- `test_channel_setup_telegram` -- Telegram-specific setup logic +- `test_channel_setup_signal` -- Signal-specific setup logic +- `test_channel_setup_webhook` -- webhook URL validation + +### `src/setup/prompts.rs` -- 24.8% -> 95% (+147 lines) + +Terminal prompt utilities. + +**Tests to write:** +- `test_prompt_select` -- selection from list +- `test_prompt_confirm` -- yes/no confirmation +- `test_prompt_secret` -- masked input +- `test_prompt_validation` -- input validation rules + +### `src/sandbox/container.rs` -- 22.1% -> 95% (+296 lines) + +Docker container lifecycle. Test command construction without actual Docker. + +**Tests to write:** +- `test_container_config_to_docker_args` -- generate correct docker run args +- `test_container_volume_mounts` -- workspace mount configuration +- `test_container_env_scrubbing` -- sensitive env vars removed +- `test_container_resource_limits` -- CPU/memory limit args +- `test_container_network_config` -- proxy network setup + +### `src/sandbox/manager.rs` -- 59% -> 95% (+114 lines) + +Sandbox orchestration. + +**Tests to write:** +- `test_sandbox_policy_enforcement` -- policy to container config mapping +- `test_sandbox_cleanup` -- cleanup on job completion +- `test_sandbox_concurrent_limit` -- enforce max concurrent containers + +### `src/sandbox/proxy/http.rs` -- 37.5% -> 95% (+176 lines) + +HTTP proxy for container network access. + +**Tests to write:** +- `test_proxy_allowlist_enforcement` -- block disallowed domains +- `test_proxy_credential_injection` -- inject auth headers +- `test_proxy_connect_tunnel` -- HTTPS CONNECT method handling +- `test_proxy_logging` -- request/response logging + +### `src/worker/runtime.rs` -- 5.7% -> 95% (+312 lines) + +Worker execution loop (runs inside containers). + +**Tests to write:** +- `test_worker_tool_dispatch` -- dispatch tool call, return result +- `test_worker_llm_interaction` -- send prompt, receive response +- `test_worker_turn_limit` -- enforce max turns +- `test_worker_error_propagation` -- tool error surfaces to agent + +### `src/worker/claude_bridge.rs` -- 60.7% -> 95% (+215 lines) + +Claude CLI bridge. + +**Tests to write:** +- `test_claude_command_construction` -- build claude CLI command +- `test_claude_output_parsing` -- parse claude CLI JSON output +- `test_claude_error_handling` -- handle CLI crashes gracefully +- `test_claude_config_injection` -- inject config dir and model + +### `src/worker/api.rs` -- 19.8% -> 95% (+194 lines) + +Worker HTTP client to orchestrator. + +**Tests to write:** +- `test_worker_api_request_building` -- correct endpoint URLs and headers +- `test_worker_api_response_parsing` -- parse orchestrator responses +- `test_worker_api_auth_token` -- bearer token injection +- `test_worker_api_retry` -- retry on transient failures + +### `src/main.rs` -- 29.4% -> 95% (+485 lines) + +Entry point and startup. Extract startup logic into testable functions. + +**Tests to write:** +- `test_cli_arg_parsing` -- verify clap argument parsing +- `test_startup_config_loading` -- config from env + file +- `test_startup_channel_selection` -- select channels from config +- `test_startup_feature_flags` -- feature-gated code paths + +--- + +## Tier 6 -- Remaining Files to 95% (~2,000 lines) + +Smaller files that each need a handful of additional tests. + +| File | Lines Needed | Test Focus | +|------|-------------:|------------| +| `src/tools/builtin/skill_tools.rs` | 110 | skill_list, skill_search, skill_install, skill_remove | +| `src/hooks/bundled.rs` | 115 | bundled hook execution, hook discovery | +| `src/registry/installer.rs` | 272 | package download, verification, installation | +| `src/registry/artifacts.rs` | 72 | artifact packaging, checksums | +| `src/orchestrator/job_manager.rs` | 249 | container lifecycle, job routing | +| `src/orchestrator/api.rs` | 125 | LLM proxy, event dispatch endpoints | +| `src/app.rs` | 137 | AppBuilder configuration, startup sequence | +| `src/service.rs` | 120 | service lifecycle, signal handling | +| `src/config/channels.rs` | 55 | channel config parsing | +| `src/config/sandbox.rs` | 61 | sandbox config parsing | +| `src/config/tunnel.rs` | 43 | tunnel config parsing | +| `src/config/mod.rs` | 63 | config merging, env override | +| `src/config/database.rs` | 38 | database URL parsing | +| `src/evaluation/success.rs` | 34 | success evaluator logic | +| `src/evaluation/metrics.rs` | 40 | metrics collection | +| `src/context/manager.rs` | 57 | concurrent job context isolation | +| `src/context/memory.rs` | 36 | action recording, conversation memory | + +--- + +## Execution Priority + +Maximize coverage gain per unit of effort: + +| Order | Category | Lines Gained | Effort | +|------:|----------|-------------:|--------| +| 1 | Trace tests (Tier 2) | ~7,000 | Medium (high leverage, each test covers many modules) | +| 2 | Unit tests for 0% files (Tier 1 subset) | ~3,500 | Low (pure logic, no infrastructure) | +| 3 | Web handler tests (Tier 3) | ~4,500 | Medium (axum_test + in-memory DB) | +| 4 | Extension/MCP/WASM unit tests (Tier 1 remainder) | ~3,500 | Medium | +| 5 | CLI subcommand tests (Tier 4) | ~2,100 | Low-Medium | +| 6 | Setup wizard extraction + tests (Tier 5) | ~2,400 | High (requires refactoring) | +| 7 | LLM provider tests (Tier 1 subset) | ~800 | Medium | +| 8 | Remaining small files (Tier 6) | ~2,000 | Low | + +## Notes + +- All trace tests require `--features libsql` and use `TestRigBuilder` from `tests/support/` +- Web handler tests can use `axum::test` helpers or build the router directly +- CLI tests should call handler functions directly, not shell out to the binary +- Setup wizard tests require extracting pure logic from interactive prompts first +- Sandbox/container tests should verify command construction, not run Docker +- Worker tests can use `TraceLlm` for the LLM provider, same as trace tests diff --git a/src/config/channels.rs b/src/config/channels.rs index fb0caf30..90635c22 100644 --- a/src/config/channels.rs +++ b/src/config/channels.rs @@ -204,3 +204,162 @@ impl ChannelsConfig { fn default_channels_dir() -> PathBuf { ironclaw_base_dir().join("channels") } + +#[cfg(test)] +mod tests { + use crate::config::channels::*; + + #[test] + fn cli_config_fields() { + let cfg = CliConfig { enabled: true }; + assert!(cfg.enabled); + + let disabled = CliConfig { enabled: false }; + assert!(!disabled.enabled); + } + + #[test] + fn http_config_fields() { + let cfg = HttpConfig { + host: "0.0.0.0".to_string(), + port: 8080, + webhook_secret: None, + user_id: "http".to_string(), + }; + assert_eq!(cfg.host, "0.0.0.0"); + assert_eq!(cfg.port, 8080); + assert!(cfg.webhook_secret.is_none()); + assert_eq!(cfg.user_id, "http"); + } + + #[test] + fn http_config_with_secret() { + let cfg = HttpConfig { + host: "127.0.0.1".to_string(), + port: 9090, + webhook_secret: Some(secrecy::SecretString::from("s3cret".to_string())), + user_id: "webhook-bot".to_string(), + }; + assert!(cfg.webhook_secret.is_some()); + assert_eq!(cfg.port, 9090); + } + + #[test] + fn gateway_config_fields() { + let cfg = GatewayConfig { + host: "127.0.0.1".to_string(), + port: 3000, + auth_token: Some("tok-abc".to_string()), + user_id: "default".to_string(), + }; + assert_eq!(cfg.host, "127.0.0.1"); + assert_eq!(cfg.port, 3000); + assert_eq!(cfg.auth_token.as_deref(), Some("tok-abc")); + assert_eq!(cfg.user_id, "default"); + } + + #[test] + fn gateway_config_no_auth_token() { + let cfg = GatewayConfig { + host: "0.0.0.0".to_string(), + port: 3001, + auth_token: None, + user_id: "anon".to_string(), + }; + assert!(cfg.auth_token.is_none()); + } + + #[test] + fn signal_config_fields_and_defaults() { + let cfg = SignalConfig { + http_url: "http://127.0.0.1:8080".to_string(), + account: "+1234567890".to_string(), + allow_from: vec!["+1234567890".to_string()], + allow_from_groups: vec![], + dm_policy: "pairing".to_string(), + group_policy: "allowlist".to_string(), + group_allow_from: vec![], + ignore_attachments: false, + ignore_stories: true, + }; + assert_eq!(cfg.http_url, "http://127.0.0.1:8080"); + assert_eq!(cfg.account, "+1234567890"); + assert_eq!(cfg.allow_from, vec!["+1234567890"]); + assert!(cfg.allow_from_groups.is_empty()); + assert_eq!(cfg.dm_policy, "pairing"); + assert_eq!(cfg.group_policy, "allowlist"); + assert!(cfg.group_allow_from.is_empty()); + assert!(!cfg.ignore_attachments); + assert!(cfg.ignore_stories); + } + + #[test] + fn signal_config_open_policies() { + let cfg = SignalConfig { + http_url: "http://localhost:7583".to_string(), + account: "+0000000000".to_string(), + allow_from: vec!["*".to_string()], + allow_from_groups: vec!["*".to_string()], + dm_policy: "open".to_string(), + group_policy: "open".to_string(), + group_allow_from: vec![], + ignore_attachments: true, + ignore_stories: false, + }; + assert_eq!(cfg.allow_from, vec!["*"]); + assert_eq!(cfg.allow_from_groups, vec!["*"]); + assert_eq!(cfg.dm_policy, "open"); + assert_eq!(cfg.group_policy, "open"); + assert!(cfg.ignore_attachments); + assert!(!cfg.ignore_stories); + } + + #[test] + fn channels_config_fields() { + let cfg = ChannelsConfig { + cli: CliConfig { enabled: true }, + http: None, + gateway: None, + signal: None, + wasm_channels_dir: PathBuf::from("/tmp/channels"), + wasm_channels_enabled: true, + wasm_channel_owner_ids: HashMap::new(), + }; + assert!(cfg.cli.enabled); + assert!(cfg.http.is_none()); + assert!(cfg.gateway.is_none()); + assert!(cfg.signal.is_none()); + assert_eq!(cfg.wasm_channels_dir, PathBuf::from("/tmp/channels")); + assert!(cfg.wasm_channels_enabled); + assert!(cfg.wasm_channel_owner_ids.is_empty()); + } + + #[test] + fn channels_config_with_owner_ids() { + let mut ids = HashMap::new(); + ids.insert("telegram".to_string(), 12345_i64); + ids.insert("slack".to_string(), 67890_i64); + + let cfg = ChannelsConfig { + cli: CliConfig { enabled: false }, + http: None, + gateway: None, + signal: None, + wasm_channels_dir: PathBuf::from("/opt/channels"), + wasm_channels_enabled: false, + wasm_channel_owner_ids: ids, + }; + assert_eq!(cfg.wasm_channel_owner_ids.get("telegram"), Some(&12345)); + assert_eq!(cfg.wasm_channel_owner_ids.get("slack"), Some(&67890)); + assert!(!cfg.wasm_channels_enabled); + } + + #[test] + fn default_channels_dir_ends_with_channels() { + let dir = default_channels_dir(); + assert!( + dir.ends_with("channels"), + "expected path ending in 'channels', got: {dir:?}" + ); + } +} diff --git a/src/config/llm.rs b/src/config/llm.rs index b6699fd5..03275a08 100644 --- a/src/config/llm.rs +++ b/src/config/llm.rs @@ -91,7 +91,7 @@ impl LlmConfig { backend: "nearai".to_string(), session: SessionConfig { auth_base_url: "http://localhost:0".to_string(), - session_path: PathBuf::from("/tmp/ironclaw-test-session.json"), + session_path: std::env::temp_dir().join("ironclaw-test-session.json"), }, nearai: NearAiConfig { model: "test-model".to_string(), diff --git a/src/config/mod.rs b/src/config/mod.rs index 8bc93a3b..25f426e1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -108,7 +108,7 @@ impl Config { http: None, gateway: None, signal: None, - wasm_channels_dir: std::path::PathBuf::from("/tmp/ironclaw-test-channels"), + wasm_channels_dir: std::env::temp_dir().join("ironclaw-test-channels"), wasm_channels_enabled: false, wasm_channel_owner_ids: HashMap::new(), }, diff --git a/src/config/sandbox.rs b/src/config/sandbox.rs index 85c9c4b2..e70a4447 100644 --- a/src/config/sandbox.rs +++ b/src/config/sandbox.rs @@ -237,3 +237,204 @@ fn parse_oauth_access_token(json: &str) -> Option { .as_str() .map(String::from) } + +#[cfg(test)] +mod tests { + use crate::config::sandbox::*; + + // ── SandboxModeConfig defaults ────────────────────────────────── + + #[test] + fn sandbox_mode_config_default_values() { + let cfg = SandboxModeConfig::default(); + assert!(cfg.enabled); + assert_eq!(cfg.policy, "readonly"); + assert_eq!(cfg.timeout_secs, 120); + assert_eq!(cfg.memory_limit_mb, 2048); + assert_eq!(cfg.cpu_shares, 1024); + assert_eq!(cfg.image, "ironclaw-worker:latest"); + assert!(cfg.auto_pull_image); + assert!(cfg.extra_allowed_domains.is_empty()); + } + + #[test] + fn sandbox_mode_config_custom_values() { + let cfg = SandboxModeConfig { + enabled: false, + policy: "full_access".to_string(), + timeout_secs: 600, + memory_limit_mb: 4096, + cpu_shares: 512, + image: "custom-worker:v2".to_string(), + auto_pull_image: false, + extra_allowed_domains: vec!["example.com".to_string()], + }; + assert!(!cfg.enabled); + assert_eq!(cfg.policy, "full_access"); + assert_eq!(cfg.timeout_secs, 600); + assert_eq!(cfg.memory_limit_mb, 4096); + assert_eq!(cfg.cpu_shares, 512); + assert_eq!(cfg.image, "custom-worker:v2"); + assert!(!cfg.auto_pull_image); + assert_eq!(cfg.extra_allowed_domains, vec!["example.com"]); + } + + #[test] + fn sandbox_mode_to_sandbox_config_propagates_fields() { + let mode = SandboxModeConfig { + enabled: true, + policy: "workspace_write".to_string(), + timeout_secs: 300, + memory_limit_mb: 1024, + cpu_shares: 2048, + image: "test:latest".to_string(), + auto_pull_image: false, + extra_allowed_domains: vec!["custom.example.com".to_string()], + }; + let sc = mode.to_sandbox_config(); + assert!(sc.enabled); + assert_eq!(sc.policy, crate::sandbox::SandboxPolicy::WorkspaceWrite); + assert_eq!(sc.timeout, std::time::Duration::from_secs(300)); + assert_eq!(sc.memory_limit_mb, 1024); + assert_eq!(sc.cpu_shares, 2048); + assert_eq!(sc.image, "test:latest"); + assert!(!sc.auto_pull_image); + // extra domain should be in the allowlist + assert!( + sc.network_allowlist + .contains(&"custom.example.com".to_string()), + "expected custom domain in allowlist" + ); + } + + #[test] + fn sandbox_mode_to_sandbox_config_invalid_policy_falls_back_to_readonly() { + let mode = SandboxModeConfig { + policy: "garbage_value".to_string(), + ..SandboxModeConfig::default() + }; + let sc = mode.to_sandbox_config(); + assert_eq!(sc.policy, crate::sandbox::SandboxPolicy::ReadOnly); + } + + #[test] + fn sandbox_mode_to_sandbox_config_includes_default_allowlist() { + let mode = SandboxModeConfig::default(); + let sc = mode.to_sandbox_config(); + // The default allowlist from sandbox module should be non-empty + assert!( + !sc.network_allowlist.is_empty(), + "default allowlist should not be empty" + ); + } + + // ── ClaudeCodeConfig defaults ─────────────────────────────────── + + #[test] + fn claude_code_config_default_values() { + let cfg = ClaudeCodeConfig::default(); + assert!(!cfg.enabled); + assert_eq!(cfg.model, "sonnet"); + assert_eq!(cfg.max_turns, 50); + assert_eq!(cfg.memory_limit_mb, 4096); + assert!(cfg.config_dir.ends_with(".claude")); + // Should have all the standard tools + assert!(!cfg.allowed_tools.is_empty()); + assert!(cfg.allowed_tools.contains(&"Bash(*)".to_string())); + assert!(cfg.allowed_tools.contains(&"Read(*)".to_string())); + assert!(cfg.allowed_tools.contains(&"Edit(*)".to_string())); + assert!(cfg.allowed_tools.contains(&"Write(*)".to_string())); + assert!(cfg.allowed_tools.contains(&"Grep(*)".to_string())); + assert!(cfg.allowed_tools.contains(&"WebFetch(*)".to_string())); + } + + #[test] + fn claude_code_config_custom_values() { + let cfg = ClaudeCodeConfig { + enabled: true, + config_dir: std::path::PathBuf::from("/opt/claude"), + model: "opus".to_string(), + max_turns: 100, + memory_limit_mb: 8192, + allowed_tools: vec!["Read(*)".to_string(), "Bash(*)".to_string()], + }; + assert!(cfg.enabled); + assert_eq!(cfg.config_dir, std::path::PathBuf::from("/opt/claude")); + assert_eq!(cfg.model, "opus"); + assert_eq!(cfg.max_turns, 100); + assert_eq!(cfg.memory_limit_mb, 8192); + assert_eq!(cfg.allowed_tools.len(), 2); + } + + // ── parse_oauth_access_token ──────────────────────────────────── + + #[test] + fn parse_oauth_token_valid() { + let json = r#"{"claudeAiOauth": {"accessToken": "sk-ant-oat01-fake"}}"#; + let token = parse_oauth_access_token(json); + assert_eq!(token, Some("sk-ant-oat01-fake".to_string())); + } + + #[test] + fn parse_oauth_token_missing_access_token() { + let json = r#"{"claudeAiOauth": {}}"#; + assert_eq!(parse_oauth_access_token(json), None); + } + + #[test] + fn parse_oauth_token_missing_oauth_key() { + let json = r#"{"someOtherKey": {"accessToken": "tok"}}"#; + assert_eq!(parse_oauth_access_token(json), None); + } + + #[test] + fn parse_oauth_token_invalid_json() { + assert_eq!(parse_oauth_access_token("not json at all"), None); + } + + #[test] + fn parse_oauth_token_empty_string() { + assert_eq!(parse_oauth_access_token(""), None); + } + + #[test] + fn parse_oauth_token_nested_extra_fields() { + let json = r#"{ + "claudeAiOauth": { + "accessToken": "sk-ant-real-token", + "refreshToken": "rt-abc", + "expiresAt": 1700000000 + } + }"#; + assert_eq!( + parse_oauth_access_token(json), + Some("sk-ant-real-token".to_string()) + ); + } + + #[test] + fn parse_oauth_token_access_token_is_not_string() { + let json = r#"{"claudeAiOauth": {"accessToken": 12345}}"#; + assert_eq!(parse_oauth_access_token(json), None); + } + + // ── default_claude_code_allowed_tools ─────────────────────────── + + #[test] + fn default_allowed_tools_has_expected_count() { + let tools = default_claude_code_allowed_tools(); + // 10 tools: Read, Write, Edit, Glob, Grep, NotebookEdit, Bash, Task, WebFetch, WebSearch + assert_eq!(tools.len(), 10); + } + + #[test] + fn default_allowed_tools_all_have_glob_pattern() { + let tools = default_claude_code_allowed_tools(); + for tool in &tools { + assert!( + tool.ends_with("(*)"), + "tool '{tool}' should end with '(*)' glob pattern" + ); + } + } +} diff --git a/src/config/tunnel.rs b/src/config/tunnel.rs index 7c175753..1481a773 100644 --- a/src/config/tunnel.rs +++ b/src/config/tunnel.rs @@ -104,3 +104,215 @@ impl TunnelConfig { }) } } + +#[cfg(test)] +mod tests { + use crate::config::tunnel::TunnelConfig; + use crate::tunnel::{ + CloudflareTunnelConfig, CustomTunnelConfig, NgrokTunnelConfig, TailscaleTunnelConfig, + TunnelProviderConfig, + }; + + // ── Default ───────────────────────────────────────────────────── + + #[test] + fn default_is_disabled() { + let cfg = TunnelConfig::default(); + assert!(cfg.public_url.is_none()); + assert!(cfg.provider.is_none()); + assert!(!cfg.is_enabled()); + } + + // ── is_enabled ────────────────────────────────────────────────── + + #[test] + fn is_enabled_with_static_url() { + let cfg = TunnelConfig { + public_url: Some("https://tunnel.example.com".to_string()), + provider: None, + }; + assert!(cfg.is_enabled()); + } + + #[test] + fn is_enabled_with_provider() { + let cfg = TunnelConfig { + public_url: None, + provider: Some(TunnelProviderConfig { + provider: "cloudflare".to_string(), + cloudflare: Some(CloudflareTunnelConfig { + token: "cf-tok".to_string(), + }), + tailscale: None, + ngrok: None, + custom: None, + }), + }; + assert!(cfg.is_enabled()); + } + + #[test] + fn is_enabled_with_both() { + let cfg = TunnelConfig { + public_url: Some("https://example.com".to_string()), + provider: Some(TunnelProviderConfig { + provider: "ngrok".to_string(), + cloudflare: None, + tailscale: None, + ngrok: Some(NgrokTunnelConfig { + auth_token: "ngrok-tok".to_string(), + domain: None, + }), + custom: None, + }), + }; + assert!(cfg.is_enabled()); + } + + // ── webhook_url ───────────────────────────────────────────────── + + #[test] + fn webhook_url_none_when_no_public_url() { + let cfg = TunnelConfig::default(); + assert!(cfg.webhook_url("/hook").is_none()); + } + + #[test] + fn webhook_url_basic() { + let cfg = TunnelConfig { + public_url: Some("https://abc.ngrok.io".to_string()), + provider: None, + }; + assert_eq!( + cfg.webhook_url("/webhook/telegram"), + Some("https://abc.ngrok.io/webhook/telegram".to_string()) + ); + } + + #[test] + fn webhook_url_trims_trailing_slash_on_base() { + let cfg = TunnelConfig { + public_url: Some("https://abc.ngrok.io/".to_string()), + provider: None, + }; + assert_eq!( + cfg.webhook_url("/hook"), + Some("https://abc.ngrok.io/hook".to_string()) + ); + } + + #[test] + fn webhook_url_trims_leading_slash_on_path() { + let cfg = TunnelConfig { + public_url: Some("https://abc.ngrok.io".to_string()), + provider: None, + }; + // Path without leading slash should also work + assert_eq!( + cfg.webhook_url("hook"), + Some("https://abc.ngrok.io/hook".to_string()) + ); + } + + #[test] + fn webhook_url_double_slash_normalization() { + let cfg = TunnelConfig { + public_url: Some("https://abc.ngrok.io/".to_string()), + provider: None, + }; + // Both base trailing and path leading slashes trimmed + assert_eq!( + cfg.webhook_url("/api/webhook"), + Some("https://abc.ngrok.io/api/webhook".to_string()) + ); + } + + #[test] + fn webhook_url_empty_path() { + let cfg = TunnelConfig { + public_url: Some("https://abc.ngrok.io".to_string()), + provider: None, + }; + assert_eq!( + cfg.webhook_url(""), + Some("https://abc.ngrok.io/".to_string()) + ); + } + + // ── TunnelProviderConfig field coverage ───────────────────────── + + #[test] + fn provider_config_cloudflare() { + let p = TunnelProviderConfig { + provider: "cloudflare".to_string(), + cloudflare: Some(CloudflareTunnelConfig { + token: "cf-secret".to_string(), + }), + tailscale: None, + ngrok: None, + custom: None, + }; + assert_eq!(p.provider, "cloudflare"); + assert_eq!(p.cloudflare.as_ref().unwrap().token, "cf-secret"); + } + + #[test] + fn provider_config_tailscale() { + let ts = TailscaleTunnelConfig { + funnel: true, + hostname: Some("my-host".to_string()), + }; + assert!(ts.funnel); + assert_eq!(ts.hostname.as_deref(), Some("my-host")); + } + + #[test] + fn provider_config_tailscale_defaults() { + let ts = TailscaleTunnelConfig::default(); + assert!(!ts.funnel); + assert!(ts.hostname.is_none()); + } + + #[test] + fn provider_config_ngrok() { + let ng = NgrokTunnelConfig { + auth_token: "ng-tok".to_string(), + domain: Some("custom.ngrok.dev".to_string()), + }; + assert_eq!(ng.auth_token, "ng-tok"); + assert_eq!(ng.domain.as_deref(), Some("custom.ngrok.dev")); + } + + #[test] + fn provider_config_ngrok_defaults() { + let ng = NgrokTunnelConfig::default(); + assert!(ng.auth_token.is_empty()); + assert!(ng.domain.is_none()); + } + + #[test] + fn provider_config_custom() { + let c = CustomTunnelConfig { + start_command: "bore local {port}".to_string(), + health_url: Some("http://localhost:8080/health".to_string()), + url_pattern: Some("https://bore.pub".to_string()), + }; + assert_eq!(c.start_command, "bore local {port}"); + assert!(c.health_url.is_some()); + assert!(c.url_pattern.is_some()); + } + + #[test] + fn provider_config_custom_defaults() { + let c = CustomTunnelConfig::default(); + assert!(c.start_command.is_empty()); + assert!(c.health_url.is_none()); + assert!(c.url_pattern.is_none()); + } + + #[test] + fn cloudflare_config_defaults() { + let cf = CloudflareTunnelConfig::default(); + assert!(cf.token.is_empty()); + } +} diff --git a/src/context/manager.rs b/src/context/manager.rs index ff3aa5d2..407a0eea 100644 --- a/src/context/manager.rs +++ b/src/context/manager.rs @@ -490,4 +490,391 @@ mod tests { assert_eq!(ctx.state, crate::context::JobState::InProgress); } } + + #[tokio::test] + async fn get_context_not_found() { + let manager = ContextManager::new(5); + let bogus_id = Uuid::new_v4(); + let result = manager.get_context(bogus_id).await; + assert!(matches!(result, Err(JobError::NotFound { id }) if id == bogus_id)); + } + + #[tokio::test] + async fn update_context_not_found() { + let manager = ContextManager::new(5); + let bogus_id = Uuid::new_v4(); + let result = manager.update_context(bogus_id, |_ctx| {}).await; + assert!(matches!(result, Err(JobError::NotFound { id }) if id == bogus_id)); + } + + #[tokio::test] + async fn remove_job_returns_context_and_memory() { + let manager = ContextManager::new(5); + let job_id = manager.create_job("Removable", "bye bye").await.unwrap(); + + let (ctx, mem) = manager.remove_job(job_id).await.unwrap(); + assert_eq!(ctx.title, "Removable"); + assert_eq!(mem.job_id, job_id); + + // After removal, get should fail + assert!(matches!( + manager.get_context(job_id).await, + Err(JobError::NotFound { .. }) + )); + assert!(matches!( + manager.get_memory(job_id).await, + Err(JobError::NotFound { .. }) + )); + } + + #[tokio::test] + async fn remove_job_not_found() { + let manager = ContextManager::new(5); + let result = manager.remove_job(Uuid::new_v4()).await; + assert!(matches!(result, Err(JobError::NotFound { .. }))); + } + + #[tokio::test] + async fn get_memory_and_update_memory() { + let manager = ContextManager::new(5); + let job_id = manager.create_job("Mem test", "desc").await.unwrap(); + + // Fresh memory should be empty + let mem = manager.get_memory(job_id).await.unwrap(); + assert_eq!(mem.job_id, job_id); + assert!(mem.actions.is_empty()); + assert!(mem.conversation.is_empty()); + + // Update memory by adding a message + manager + .update_memory(job_id, |m| { + m.add_message(crate::llm::ChatMessage::user("hello from test")); + }) + .await + .unwrap(); + + let mem = manager.get_memory(job_id).await.unwrap(); + assert_eq!(mem.conversation.len(), 1); + assert_eq!(mem.conversation.messages()[0].content, "hello from test"); + } + + #[tokio::test] + async fn update_memory_not_found() { + let manager = ContextManager::new(5); + let result = manager.update_memory(Uuid::new_v4(), |_| {}).await; + assert!(matches!(result, Err(JobError::NotFound { .. }))); + } + + #[tokio::test] + async fn get_memory_not_found() { + let manager = ContextManager::new(5); + let result = manager.get_memory(Uuid::new_v4()).await; + assert!(matches!(result, Err(JobError::NotFound { .. }))); + } + + #[tokio::test] + async fn find_stuck_jobs_returns_only_stuck() { + let manager = ContextManager::new(10); + + let id1 = manager.create_job("Job 1", "desc").await.unwrap(); + let id2 = manager.create_job("Job 2", "desc").await.unwrap(); + let id3 = manager.create_job("Job 3", "desc").await.unwrap(); + + // Transition id1 and id2 to InProgress, then mark id2 as stuck + for id in [id1, id2, id3] { + manager + .update_context(id, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + } + manager + .update_context(id2, |ctx| ctx.mark_stuck("timed out")) + .await + .unwrap() + .unwrap(); + + let stuck = manager.find_stuck_jobs().await; + assert_eq!(stuck.len(), 1); + assert_eq!(stuck[0], id2); + } + + #[tokio::test] + async fn active_count_tracks_non_terminal_jobs() { + let manager = ContextManager::new(10); + + let id1 = manager.create_job("J1", "d").await.unwrap(); + let id2 = manager.create_job("J2", "d").await.unwrap(); + + // Both pending (active) + assert_eq!(manager.active_count().await, 2); + + // Transition id1 through to Failed (terminal) + manager + .update_context(id1, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + manager + .update_context(id1, |ctx| { + ctx.transition_to(crate::context::JobState::Failed, None) + }) + .await + .unwrap() + .unwrap(); + + // id1 is terminal, id2 still pending + assert_eq!(manager.active_count().await, 1); + + // Transition id2 to cancelled + manager + .update_context(id2, |ctx| { + ctx.transition_to(crate::context::JobState::Cancelled, None) + }) + .await + .unwrap() + .unwrap(); + + assert_eq!(manager.active_count().await, 0); + } + + #[tokio::test] + async fn active_jobs_for_filters_by_user() { + let manager = ContextManager::new(10); + + manager + .create_job_for_user("alice", "A1", "d") + .await + .unwrap(); + manager + .create_job_for_user("alice", "A2", "d") + .await + .unwrap(); + let bob_id = manager.create_job_for_user("bob", "B1", "d").await.unwrap(); + + assert_eq!(manager.active_jobs_for("alice").await.len(), 2); + assert_eq!(manager.active_jobs_for("bob").await.len(), 1); + assert_eq!(manager.active_jobs_for("nobody").await.len(), 0); + + // Make bob's job terminal + manager + .update_context(bob_id, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + manager + .update_context(bob_id, |ctx| { + ctx.transition_to(crate::context::JobState::Failed, None) + }) + .await + .unwrap() + .unwrap(); + + assert_eq!(manager.active_jobs_for("bob").await.len(), 0); + // But all_jobs_for still shows it + assert_eq!(manager.all_jobs_for("bob").await.len(), 1); + } + + #[tokio::test] + async fn summary_counts_states_correctly() { + let manager = ContextManager::new(10); + + let id1 = manager.create_job("J1", "d").await.unwrap(); + let id2 = manager.create_job("J2", "d").await.unwrap(); + let id3 = manager.create_job("J3", "d").await.unwrap(); + + // id1: Pending -> InProgress -> Completed + manager + .update_context(id1, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + manager + .update_context(id1, |ctx| { + ctx.transition_to(crate::context::JobState::Completed, None) + }) + .await + .unwrap() + .unwrap(); + + // id2: Pending -> InProgress -> Failed + manager + .update_context(id2, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + manager + .update_context(id2, |ctx| { + ctx.transition_to(crate::context::JobState::Failed, None) + }) + .await + .unwrap() + .unwrap(); + + // id3: stays Pending + + let s = manager.summary().await; + assert_eq!(s.total, 3); + assert_eq!(s.pending, 1); + assert_eq!(s.completed, 1); + assert_eq!(s.failed, 1); + assert_eq!(s.in_progress, 0); + assert_eq!(s.stuck, 0); + assert_eq!(s.cancelled, 0); + assert_eq!(s.submitted, 0); + assert_eq!(s.accepted, 0); + + // Suppress unused field warning + let _ = id3; + } + + #[tokio::test] + async fn summary_for_scopes_to_user() { + let manager = ContextManager::new(10); + + manager + .create_job_for_user("alice", "A1", "d") + .await + .unwrap(); + let bob_id = manager.create_job_for_user("bob", "B1", "d").await.unwrap(); + + // Transition bob's job to InProgress + manager + .update_context(bob_id, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + + let alice_summary = manager.summary_for("alice").await; + assert_eq!(alice_summary.total, 1); + assert_eq!(alice_summary.pending, 1); + assert_eq!(alice_summary.in_progress, 0); + + let bob_summary = manager.summary_for("bob").await; + assert_eq!(bob_summary.total, 1); + assert_eq!(bob_summary.pending, 0); + assert_eq!(bob_summary.in_progress, 1); + + let nobody_summary = manager.summary_for("nobody").await; + assert_eq!(nobody_summary.total, 0); + } + + #[tokio::test] + async fn default_context_manager_has_max_10() { + let manager = ContextManager::default(); + // Create 10 jobs and make them active + for i in 0..10 { + let id = manager + .create_job(format!("Job {i}"), "desc") + .await + .unwrap(); + manager + .update_context(id, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + } + // 11th should fail + let result = manager.create_job("overflow", "d").await; + assert!(matches!(result, Err(JobError::MaxJobsExceeded { max: 10 }))); + } + + #[tokio::test] + async fn all_jobs_returns_all_regardless_of_state() { + let manager = ContextManager::new(10); + + let id1 = manager.create_job("J1", "d").await.unwrap(); + manager.create_job("J2", "d").await.unwrap(); + + // Make id1 terminal + manager + .update_context(id1, |ctx| { + ctx.transition_to(crate::context::JobState::InProgress, None) + }) + .await + .unwrap() + .unwrap(); + manager + .update_context(id1, |ctx| { + ctx.transition_to(crate::context::JobState::Failed, None) + }) + .await + .unwrap() + .unwrap(); + + // all_jobs includes terminal, active_jobs does not + assert_eq!(manager.all_jobs().await.len(), 2); + assert_eq!(manager.active_jobs().await.len(), 1); + } + + #[tokio::test] + async fn create_job_uses_default_user() { + let manager = ContextManager::new(5); + let job_id = manager.create_job("Test", "desc").await.unwrap(); + let ctx = manager.get_context(job_id).await.unwrap(); + assert_eq!(ctx.user_id, "default"); + } + + #[tokio::test] + async fn concurrent_remove_and_read() { + let manager = std::sync::Arc::new(ContextManager::new(100)); + + // Create 20 jobs + let mut job_ids = Vec::new(); + for i in 0..20 { + let id = manager + .create_job(format!("Job {i}"), "desc") + .await + .unwrap(); + job_ids.push(id); + } + + // Concurrently remove the first 10 while reading the last 10 + let remove_handles: Vec<_> = job_ids[..10] + .iter() + .map(|&id| { + let mgr = std::sync::Arc::clone(&manager); + tokio::spawn(async move { mgr.remove_job(id).await }) + }) + .collect(); + + let read_handles: Vec<_> = job_ids[10..] + .iter() + .map(|&id| { + let mgr = std::sync::Arc::clone(&manager); + tokio::spawn(async move { mgr.get_context(id).await }) + }) + .collect(); + + for handle in remove_handles { + handle + .await + .expect("remove task should not panic") + .expect("remove should succeed"); + } + + for handle in read_handles { + let ctx = handle + .await + .expect("read task should not panic") + .expect("read should succeed"); + assert!(job_ids[10..].contains(&ctx.job_id)); + } + + assert_eq!(manager.all_jobs().await.len(), 10); + } } diff --git a/src/context/memory.rs b/src/context/memory.rs index 9009267f..9452c649 100644 --- a/src/context/memory.rs +++ b/src/context/memory.rs @@ -290,4 +290,276 @@ mod tests { assert_eq!(memory.total_duration(), Duration::from_secs(3)); assert_eq!(memory.successful_actions(), 2); } + + #[test] + fn test_action_record_fail() { + let action = ActionRecord::new(1, "broken_tool", serde_json::json!({"x": 1})); + let action = action.fail("something went wrong", Duration::from_millis(50)); + + assert!(!action.success); + assert_eq!(action.error.as_deref(), Some("something went wrong")); + assert_eq!(action.duration, Duration::from_millis(50)); + assert!(action.output_raw.is_none()); + assert!(action.output_sanitized.is_none()); + } + + #[test] + fn test_action_record_with_warnings() { + let action = ActionRecord::new(0, "risky_tool", serde_json::json!({})); + let action = action.with_warnings(vec!["suspicious pattern".into(), "possible xss".into()]); + + assert_eq!(action.sanitization_warnings.len(), 2); + assert_eq!(action.sanitization_warnings[0], "suspicious pattern"); + assert_eq!(action.sanitization_warnings[1], "possible xss"); + } + + #[test] + fn test_action_record_with_cost() { + let action = ActionRecord::new(0, "expensive_tool", serde_json::json!({})); + let cost = Decimal::new(42, 2); // 0.42 + let action = action.with_cost(cost); + + assert_eq!(action.cost, Some(Decimal::new(42, 2))); + } + + #[test] + fn test_action_record_new_defaults() { + let action = ActionRecord::new(5, "my_tool", serde_json::json!({"key": "val"})); + + assert_eq!(action.sequence, 5); + assert_eq!(action.tool_name, "my_tool"); + assert_eq!(action.input, serde_json::json!({"key": "val"})); + assert!(!action.success); + assert!(action.output_raw.is_none()); + assert!(action.output_sanitized.is_none()); + assert!(action.sanitization_warnings.is_empty()); + assert!(action.cost.is_none()); + assert_eq!(action.duration, Duration::ZERO); + assert!(action.error.is_none()); + } + + #[test] + fn test_action_record_succeed_sets_fields() { + let action = ActionRecord::new(0, "tool", serde_json::json!({})); + let action = action.succeed( + Some("raw output here".into()), + serde_json::json!({"clean": true}), + Duration::from_secs(7), + ); + + assert!(action.success); + assert_eq!(action.output_raw.as_deref(), Some("raw output here")); + assert_eq!( + action.output_sanitized, + Some(serde_json::json!({"clean": true})) + ); + assert_eq!(action.duration, Duration::from_secs(7)); + } + + #[test] + fn test_conversation_memory_clear() { + let mut mem = ConversationMemory::new(10); + mem.add(ChatMessage::user("hello")); + mem.add(ChatMessage::assistant("hi")); + assert_eq!(mem.len(), 2); + assert!(!mem.is_empty()); + + mem.clear(); + assert_eq!(mem.len(), 0); + assert!(mem.is_empty()); + assert!(mem.messages().is_empty()); + } + + #[test] + fn test_conversation_memory_last_n() { + let mut mem = ConversationMemory::new(10); + mem.add(ChatMessage::user("one")); + mem.add(ChatMessage::assistant("two")); + mem.add(ChatMessage::user("three")); + mem.add(ChatMessage::assistant("four")); + + let last_2 = mem.last_n(2); + assert_eq!(last_2.len(), 2); + assert_eq!(last_2[0].content, "three"); + assert_eq!(last_2[1].content, "four"); + + // Requesting more than available returns all + let last_100 = mem.last_n(100); + assert_eq!(last_100.len(), 4); + } + + #[test] + fn test_conversation_memory_last_n_empty() { + let mem = ConversationMemory::new(10); + let result = mem.last_n(5); + assert!(result.is_empty()); + } + + #[test] + fn test_conversation_memory_preserves_system_message_on_trim() { + let mut mem = ConversationMemory::new(3); + mem.add(ChatMessage::system("You are helpful")); + mem.add(ChatMessage::user("msg1")); + mem.add(ChatMessage::user("msg2")); + + // At capacity (3). Adding one more should trim, but keep system. + mem.add(ChatMessage::user("msg3")); + + assert_eq!(mem.len(), 3); + // System message must survive + assert_eq!(mem.messages()[0].role, crate::llm::Role::System); + assert_eq!(mem.messages()[0].content, "You are helpful"); + // Oldest non-system message (msg1) should be gone + assert_eq!(mem.messages()[1].content, "msg2"); + assert_eq!(mem.messages()[2].content, "msg3"); + } + + #[test] + fn test_conversation_memory_trims_non_system_first() { + let mut mem = ConversationMemory::new(2); + mem.add(ChatMessage::system("sys")); + mem.add(ChatMessage::user("a")); + // Now at capacity. Add another. + mem.add(ChatMessage::user("b")); + + assert_eq!(mem.len(), 2); + assert_eq!(mem.messages()[0].role, crate::llm::Role::System); + assert_eq!(mem.messages()[1].content, "b"); + } + + #[test] + fn test_conversation_memory_max_one_with_system_does_not_loop() { + // Edge case: max_messages = 1 and only a system message. + // Adding another message would try to trim but should not + // remove the system message and get stuck. + let mut mem = ConversationMemory::new(1); + mem.add(ChatMessage::system("sys")); + // The system message is already at capacity. Adding another + // cannot trim the system message, so we end up with 2 (graceful). + // The important thing is we don't infinite-loop. + mem.add(ChatMessage::user("hello")); + // Should have broken out rather than looping forever. + // The system message is protected, so len may exceed max. + assert!(mem.len() <= 2); + } + + #[test] + fn test_memory_failed_actions() { + let mut memory = Memory::new(Uuid::new_v4()); + + let ok = memory.create_action("good", serde_json::json!({})).succeed( + None, + serde_json::json!({}), + Duration::from_millis(1), + ); + memory.record_action(ok); + + let err = memory + .create_action("bad", serde_json::json!({})) + .fail("oops", Duration::from_millis(2)); + memory.record_action(err); + + assert_eq!(memory.successful_actions(), 1); + assert_eq!(memory.failed_actions(), 1); + } + + #[test] + fn test_memory_last_action() { + let mut memory = Memory::new(Uuid::new_v4()); + assert!(memory.last_action().is_none()); + + let a1 = memory + .create_action("first", serde_json::json!({})) + .succeed(None, serde_json::json!({}), Duration::ZERO); + memory.record_action(a1); + + let a2 = memory + .create_action("second", serde_json::json!({})) + .fail("nope", Duration::ZERO); + memory.record_action(a2); + + let last = memory.last_action().unwrap(); + assert_eq!(last.tool_name, "second"); + } + + #[test] + fn test_memory_actions_by_tool() { + let mut memory = Memory::new(Uuid::new_v4()); + + for _ in 0..3 { + let a = memory + .create_action("shell", serde_json::json!({})) + .succeed(None, serde_json::json!({}), Duration::ZERO); + memory.record_action(a); + } + let a = memory.create_action("http", serde_json::json!({})).succeed( + None, + serde_json::json!({}), + Duration::ZERO, + ); + memory.record_action(a); + + assert_eq!(memory.actions_by_tool("shell").len(), 3); + assert_eq!(memory.actions_by_tool("http").len(), 1); + assert_eq!(memory.actions_by_tool("nonexistent").len(), 0); + } + + #[test] + fn test_memory_create_action_increments_sequence() { + let mut memory = Memory::new(Uuid::new_v4()); + + let a0 = memory.create_action("t", serde_json::json!({})); + assert_eq!(a0.sequence, 0); + + let a1 = memory.create_action("t", serde_json::json!({})); + assert_eq!(a1.sequence, 1); + + let a2 = memory.create_action("t", serde_json::json!({})); + assert_eq!(a2.sequence, 2); + } + + #[test] + fn test_memory_add_message_delegates_to_conversation() { + let mut memory = Memory::new(Uuid::new_v4()); + assert!(memory.conversation.is_empty()); + + memory.add_message(ChatMessage::user("hello")); + memory.add_message(ChatMessage::assistant("hi")); + + assert_eq!(memory.conversation.len(), 2); + assert_eq!(memory.conversation.messages()[0].content, "hello"); + } + + #[test] + fn test_memory_total_cost_with_no_cost_actions() { + let mut memory = Memory::new(Uuid::new_v4()); + + // Actions without cost should contribute zero + let a = memory + .create_action("free_tool", serde_json::json!({})) + .succeed(None, serde_json::json!({}), Duration::ZERO); + memory.record_action(a); + + assert_eq!(memory.total_cost(), Decimal::ZERO); + } + + #[test] + fn test_memory_total_duration_mixed() { + let mut memory = Memory::new(Uuid::new_v4()); + + let a1 = memory.create_action("t1", serde_json::json!({})).succeed( + None, + serde_json::json!({}), + Duration::from_millis(100), + ); + memory.record_action(a1); + + let a2 = memory + .create_action("t2", serde_json::json!({})) + .fail("err", Duration::from_millis(200)); + memory.record_action(a2); + + // Both successful and failed actions contribute to total duration + assert_eq!(memory.total_duration(), Duration::from_millis(300)); + } } diff --git a/src/evaluation/metrics.rs b/src/evaluation/metrics.rs index 4bf7cc91..0e2e4b7f 100644 --- a/src/evaluation/metrics.rs +++ b/src/evaluation/metrics.rs @@ -238,4 +238,220 @@ mod tests { let rate = collector.success_rate(); assert!((rate - 0.666).abs() < 0.01); } + + // --- QualityMetrics default --- + + #[test] + fn test_quality_metrics_default() { + let m = QualityMetrics::default(); + assert_eq!(m.total_actions, 0); + assert_eq!(m.successful_actions, 0); + assert_eq!(m.failed_actions, 0); + assert_eq!(m.total_time, Duration::ZERO); + assert_eq!(m.total_cost, Decimal::ZERO); + assert!(m.tool_metrics.is_empty()); + assert!(m.error_types.is_empty()); + } + + // --- ToolMetrics::success_rate --- + + #[test] + fn test_tool_metrics_success_rate_zero_calls() { + let tm = ToolMetrics::default(); + assert_eq!(tm.success_rate(), 0.0); + } + + #[test] + fn test_tool_metrics_success_rate_mixed() { + let tm = ToolMetrics { + calls: 4, + successes: 3, + failures: 1, + ..Default::default() + }; + assert!((tm.success_rate() - 0.75).abs() < f64::EPSILON); + } + + #[test] + fn test_tool_metrics_success_rate_all_failures() { + let tm = ToolMetrics { + calls: 5, + successes: 0, + failures: 5, + ..Default::default() + }; + assert_eq!(tm.success_rate(), 0.0); + } + + // --- MetricsCollector --- + + #[test] + fn test_collector_default_is_new() { + let a = MetricsCollector::new(); + let b = MetricsCollector::default(); + assert_eq!(a.metrics().total_actions, b.metrics().total_actions); + assert_eq!(a.success_rate(), b.success_rate()); + } + + #[test] + fn test_success_rate_empty_collector() { + let collector = MetricsCollector::new(); + assert_eq!(collector.success_rate(), 0.0); + } + + #[test] + fn test_record_success_accumulates_cost() { + let mut c = MetricsCollector::new(); + c.record_success("a", Duration::from_millis(100), Some(dec!(1.50))); + c.record_success("a", Duration::from_millis(200), Some(dec!(2.50))); + assert_eq!(c.metrics().total_cost, dec!(4.00)); + let tool = c.tool_metrics("a").unwrap(); + assert_eq!(tool.total_cost, dec!(4.00)); + } + + #[test] + fn test_record_success_none_cost_does_not_change_total() { + let mut c = MetricsCollector::new(); + c.record_success("x", Duration::from_secs(1), Some(dec!(1.00))); + c.record_success("x", Duration::from_secs(1), None); + assert_eq!(c.metrics().total_cost, dec!(1.00)); + } + + #[test] + fn test_record_failure_does_not_add_cost() { + let mut c = MetricsCollector::new(); + c.record_failure("t", "oops", Duration::from_secs(1)); + assert_eq!(c.metrics().total_cost, Decimal::ZERO); + } + + #[test] + fn test_tool_avg_time_updates() { + let mut c = MetricsCollector::new(); + c.record_success("t", Duration::from_secs(2), None); + c.record_success("t", Duration::from_secs(4), None); + let tool = c.tool_metrics("t").unwrap(); + // total 6s / 2 calls = 3s avg + assert_eq!(tool.avg_time, Duration::from_secs(3)); + } + + #[test] + fn test_total_time_across_success_and_failure() { + let mut c = MetricsCollector::new(); + c.record_success("a", Duration::from_secs(3), None); + c.record_failure("b", "err", Duration::from_secs(7)); + assert_eq!(c.metrics().total_time, Duration::from_secs(10)); + } + + #[test] + fn test_tool_metrics_returns_none_for_unknown() { + let c = MetricsCollector::new(); + assert!(c.tool_metrics("nonexistent").is_none()); + } + + #[test] + fn test_reset_clears_everything() { + let mut c = MetricsCollector::new(); + c.record_success("t", Duration::from_secs(1), Some(dec!(5.00))); + c.record_failure("t", "error", Duration::from_secs(1)); + c.reset(); + assert_eq!(c.metrics().total_actions, 0); + assert_eq!(c.metrics().successful_actions, 0); + assert_eq!(c.metrics().failed_actions, 0); + assert_eq!(c.metrics().total_cost, Decimal::ZERO); + assert!(c.metrics().tool_metrics.is_empty()); + assert!(c.metrics().error_types.is_empty()); + assert_eq!(c.success_rate(), 0.0); + } + + #[test] + fn test_multiple_tools_tracked_independently() { + let mut c = MetricsCollector::new(); + c.record_success("alpha", Duration::from_secs(1), None); + c.record_success("alpha", Duration::from_secs(1), None); + c.record_failure("beta", "bad", Duration::from_secs(1)); + c.record_success("beta", Duration::from_secs(1), None); + + let alpha = c.tool_metrics("alpha").unwrap(); + assert_eq!(alpha.calls, 2); + assert_eq!(alpha.successes, 2); + assert_eq!(alpha.failures, 0); + + let beta = c.tool_metrics("beta").unwrap(); + assert_eq!(beta.calls, 2); + assert_eq!(beta.successes, 1); + assert_eq!(beta.failures, 1); + } + + // --- categorize_error --- + + #[test] + fn test_categorize_error_all_types() { + assert_eq!(categorize_error("Connection timeout"), "timeout"); + assert_eq!(categorize_error("TIMEOUT exceeded"), "timeout"); + assert_eq!(categorize_error("rate limit hit"), "rate_limit"); + assert_eq!(categorize_error("Rate Limit 429"), "rate_limit"); + assert_eq!(categorize_error("auth failure"), "auth"); + assert_eq!(categorize_error("Unauthorized"), "auth"); + assert_eq!(categorize_error("resource not found"), "not_found"); + assert_eq!(categorize_error("HTTP 404"), "not_found"); + assert_eq!(categorize_error("invalid parameter X"), "invalid_input"); + assert_eq!(categorize_error("bad parameter"), "invalid_input"); + assert_eq!(categorize_error("Invalid JSON"), "invalid_input"); + assert_eq!(categorize_error("network error"), "network"); + assert_eq!(categorize_error("connection refused"), "network"); + assert_eq!(categorize_error("something else entirely"), "unknown"); + assert_eq!(categorize_error(""), "unknown"); + } + + #[test] + fn test_error_types_accumulated_in_collector() { + let mut c = MetricsCollector::new(); + c.record_failure("t", "timeout!", Duration::from_secs(1)); + c.record_failure("t", "another timeout", Duration::from_secs(1)); + c.record_failure("t", "auth denied", Duration::from_secs(1)); + + assert_eq!(c.metrics().error_types.get("timeout"), Some(&2)); + assert_eq!(c.metrics().error_types.get("auth"), Some(&1)); + } + + // --- MetricsSummary --- + + #[test] + fn test_summary_empty_collector() { + let c = MetricsCollector::new(); + let s = c.summary(); + assert_eq!(s.total_actions, 0); + assert_eq!(s.success_rate, 0.0); + assert_eq!(s.total_cost, Decimal::ZERO); + assert!(s.most_used_tool.is_none()); + assert!(s.most_failed_tool.is_none()); + assert!(s.top_errors.is_empty()); + } + + #[test] + fn test_summary_most_used_and_most_failed() { + let mut c = MetricsCollector::new(); + // "alpha" gets 3 calls (all success) + c.record_success("alpha", Duration::from_secs(1), None); + c.record_success("alpha", Duration::from_secs(1), None); + c.record_success("alpha", Duration::from_secs(1), None); + // "beta" gets 2 calls (both failures) + c.record_failure("beta", "err", Duration::from_secs(1)); + c.record_failure("beta", "err", Duration::from_secs(1)); + + let s = c.summary(); + assert_eq!(s.most_used_tool.as_deref(), Some("alpha")); + assert_eq!(s.most_failed_tool.as_deref(), Some("beta")); + assert_eq!(s.total_actions, 5); + } + + #[test] + fn test_summary_top_errors_populated() { + let mut c = MetricsCollector::new(); + c.record_failure("t", "timeout", Duration::from_secs(1)); + c.record_failure("t", "auth error", Duration::from_secs(1)); + let s = c.summary(); + assert!(!s.top_errors.is_empty()); + assert!(s.top_errors.len() <= 3); + } } diff --git a/src/evaluation/success.rs b/src/evaluation/success.rs index 31c78146..2d1e4470 100644 --- a/src/evaluation/success.rs +++ b/src/evaluation/success.rs @@ -331,6 +331,10 @@ mod tests { } fn create_action(success: bool) -> ActionRecord { + create_action_with_error(success, "Test error") + } + + fn create_action_with_error(success: bool, error_msg: &str) -> ActionRecord { let mut action = ActionRecord::new(0, "test", serde_json::json!({})); if success { action = action.succeed( @@ -339,8 +343,257 @@ mod tests { std::time::Duration::from_secs(1), ); } else { - action = action.fail("Test error", std::time::Duration::from_secs(1)); + action = action.fail(error_msg, std::time::Duration::from_secs(1)); } action } + + fn completed_job(title: &str) -> JobContext { + let mut job = JobContext::new(title, "test job"); + job.transition_to(crate::context::JobState::InProgress, None) + .unwrap(); + job.transition_to(crate::context::JobState::Completed, None) + .unwrap(); + job + } + + // --- EvaluationResult construction --- + + #[test] + fn test_evaluation_result_success_defaults() { + let result = EvaluationResult::success("all good", 85); + assert!(result.success); + assert_eq!(result.confidence, 0.9); + assert_eq!(result.reasoning, "all good"); + assert!(result.issues.is_empty()); + assert!(result.suggestions.is_empty()); + assert_eq!(result.quality_score, 85); + } + + #[test] + fn test_evaluation_result_failure_defaults() { + let issues = vec!["bad thing".to_string(), "worse thing".to_string()]; + let result = EvaluationResult::failure("went wrong", issues.clone()); + assert!(!result.success); + assert_eq!(result.confidence, 0.9); + assert_eq!(result.reasoning, "went wrong"); + assert_eq!(result.issues, issues); + assert_eq!(result.quality_score, 0); + } + + #[test] + fn test_evaluation_result_serde_roundtrip() { + let result = EvaluationResult { + success: true, + confidence: 0.75, + reasoning: "looks fine".to_string(), + issues: vec!["minor".to_string()], + suggestions: vec!["try harder".to_string()], + quality_score: 60, + }; + let json = serde_json::to_string(&result).unwrap(); + let deserialized: EvaluationResult = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.success, result.success); + assert_eq!(deserialized.confidence, result.confidence); + assert_eq!(deserialized.reasoning, result.reasoning); + assert_eq!(deserialized.issues, result.issues); + assert_eq!(deserialized.suggestions, result.suggestions); + assert_eq!(deserialized.quality_score, result.quality_score); + } + + // --- RuleBasedEvaluator builder --- + + #[test] + fn test_rule_based_evaluator_default() { + let eval = RuleBasedEvaluator::default(); + assert_eq!(eval.min_action_success_rate, 0.8); + assert_eq!(eval.max_failures, 3); + } + + #[test] + fn test_rule_based_evaluator_builder_methods() { + let eval = RuleBasedEvaluator::new() + .with_min_success_rate(0.5) + .with_max_failures(10); + assert_eq!(eval.min_action_success_rate, 0.5); + assert_eq!(eval.max_failures, 10); + } + + // --- RuleBasedEvaluator::evaluate edge cases --- + + #[tokio::test] + async fn test_empty_actions_fails() { + let eval = RuleBasedEvaluator::new(); + let job = completed_job("empty"); + let result = eval.evaluate(&job, &[], None).await.unwrap(); + assert!(!result.success); + assert!(result.issues.iter().any(|i| i.contains("No actions"))); + } + + #[tokio::test] + async fn test_all_actions_succeed_completed_job_gets_100() { + let eval = RuleBasedEvaluator::new(); + let job = completed_job("perfect"); + let actions = vec![ + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action(true), + ]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + assert!(result.success); + // 100% success rate -> base 80, completion bonus 20 -> 100 + assert_eq!(result.quality_score, 100); + } + + #[tokio::test] + async fn test_quality_score_no_completion_bonus_for_pending_job() { + // Even if all actions succeed, a non-completed job gets flagged + let eval = RuleBasedEvaluator::new(); + let job = JobContext::new("pending", "still pending"); + let actions = vec![create_action(true)]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + // Job not in completed state => issues present + assert!(!result.success); + assert!( + result + .issues + .iter() + .any(|i| i.contains("not in completed state")) + ); + } + + #[tokio::test] + async fn test_submitted_state_counts_as_completed() { + let eval = RuleBasedEvaluator::new(); + let mut job = JobContext::new("submitted", "test"); + job.transition_to(crate::context::JobState::InProgress, None) + .unwrap(); + job.transition_to(crate::context::JobState::Completed, None) + .unwrap(); + job.transition_to(crate::context::JobState::Submitted, None) + .unwrap(); + let actions = vec![create_action(true)]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + // Submitted is treated like completed for state check (no issue), + // but completion bonus only applies for Completed state + assert!(result.success); + } + + #[tokio::test] + async fn test_success_rate_below_threshold_fails() { + let eval = RuleBasedEvaluator::new().with_min_success_rate(0.9); + let job = completed_job("threshold"); + // 4 out of 5 = 80%, below 90% threshold + let actions = vec![ + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action(false), + ]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + assert!(!result.success); + assert!( + result + .issues + .iter() + .any(|i| i.contains("success rate") && i.contains("below threshold")) + ); + } + + #[tokio::test] + async fn test_too_many_failures_flagged() { + let eval = RuleBasedEvaluator::new().with_max_failures(1); + let job = completed_job("failures"); + // 8 successes, 2 failures: rate is 80% (passes default 0.8) but failures > max 1 + let actions = vec![ + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action(false), + create_action(false), + ]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + assert!(!result.success); + assert!( + result + .issues + .iter() + .any(|i| i.contains("Too many failures")) + ); + } + + #[tokio::test] + async fn test_critical_error_detected() { + let eval = RuleBasedEvaluator::new().with_max_failures(10); + let job = completed_job("critical"); + let actions = vec![ + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action_with_error(false, "A CRITICAL system failure occurred"), + ]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + assert!(!result.success); + assert!(result.issues.iter().any(|i| i.contains("Critical error"))); + } + + #[tokio::test] + async fn test_fatal_error_detected() { + let eval = RuleBasedEvaluator::new().with_max_failures(10); + let job = completed_job("fatal"); + let actions = vec![ + create_action(true), + create_action(true), + create_action(true), + create_action(true), + create_action_with_error(false, "Fatal: disk full"), + ]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + assert!(result.issues.iter().any(|i| i.contains("Critical error"))); + } + + #[tokio::test] + async fn test_quality_score_capped_at_50_with_issues() { + let eval = RuleBasedEvaluator::new() + .with_min_success_rate(0.0) + .with_max_failures(100); + // Job not completed => issues present, quality capped + let job = JobContext::new("capped", "test"); + let actions = vec![create_action(true)]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + assert!(!result.success); + assert!(result.quality_score <= 50); + } + + #[tokio::test] + async fn test_failed_result_includes_suggestions() { + let eval = RuleBasedEvaluator::new().with_max_failures(0); + let job = completed_job("suggestions"); + let actions = vec![create_action(false)]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + assert!(!result.success); + assert!(!result.suggestions.is_empty()); + assert_eq!(result.confidence, 0.85); + } + + #[tokio::test] + async fn test_single_successful_action_completed_job() { + let eval = RuleBasedEvaluator::new(); + let job = completed_job("single"); + let actions = vec![create_action(true)]; + let result = eval.evaluate(&job, &actions, None).await.unwrap(); + assert!(result.success); + // 100% rate -> base 80, + 20 completion = 100 + assert_eq!(result.quality_score, 100); + assert!(result.reasoning.contains("1/1")); + } } diff --git a/src/extensions/discovery.rs b/src/extensions/discovery.rs index 721c8ed7..a9c625d7 100644 --- a/src/extensions/discovery.rs +++ b/src/extensions/discovery.rs @@ -325,4 +325,180 @@ mod tests { // Just make sure it constructs without panicking let _discovery = OnlineDiscovery::new(); } + + #[test] + fn test_titlecase_single_char() { + assert_eq!(titlecase("a"), "A"); + assert_eq!(titlecase("Z"), "Z"); + } + + #[test] + fn test_titlecase_mixed_case() { + assert_eq!(titlecase("hELLO wORLD"), "HELLO WORLD"); + // Only first char is uppercased, rest is left as-is + assert_eq!(titlecase("alREADY weird"), "AlREADY Weird"); + } + + #[test] + fn test_titlecase_multiple_spaces() { + // split_whitespace collapses multiple spaces + assert_eq!(titlecase("hello world"), "Hello World"); + assert_eq!(titlecase(" leading trailing "), "Leading Trailing"); + } + + #[test] + fn test_titlecase_punctuation() { + assert_eq!(titlecase("hello-world"), "Hello-world"); + assert_eq!(titlecase("it's fine"), "It's Fine"); + assert_eq!(titlecase("one. two"), "One. Two"); + } + + #[test] + fn test_extract_source_wasm_download() { + let src = ExtensionSource::WasmDownload { + wasm_url: "https://example.com/tool.wasm".to_string(), + capabilities_url: Some("https://example.com/caps.json".to_string()), + }; + assert_eq!(extract_source(&src), "https://example.com/tool.wasm"); + + let src_no_caps = ExtensionSource::WasmDownload { + wasm_url: "https://other.com/bin.wasm".to_string(), + capabilities_url: None, + }; + assert_eq!(extract_source(&src_no_caps), "https://other.com/bin.wasm"); + } + + #[test] + fn test_extract_source_wasm_buildable() { + let src = ExtensionSource::WasmBuildable { + source_dir: "/home/user/my-tool".to_string(), + build_dir: Some("/home/user/my-tool/target".to_string()), + crate_name: Some("my_tool".to_string()), + }; + assert_eq!(extract_source(&src), "/home/user/my-tool"); + + let src_minimal = ExtensionSource::WasmBuildable { + source_dir: "./src".to_string(), + build_dir: None, + crate_name: None, + }; + assert_eq!(extract_source(&src_minimal), "./src"); + } + + #[test] + fn test_online_discovery_default() { + let d = OnlineDiscovery::default(); + // Verify it constructed (no panic) and the client is usable + let _ = d.http_client; + } + + #[test] + fn test_github_search_response_empty_items() { + let json = r#"{"total_count": 0, "items": []}"#; + let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap(); + assert!(resp.items.is_empty()); + } + + #[test] + fn test_github_search_response_missing_items_field() { + // items has #[serde(default)], so missing field should give empty vec + let json = r#"{"total_count": 0}"#; + let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap(); + assert!(resp.items.is_empty()); + } + + #[test] + fn test_github_search_response_multiple_items() { + let json = r#"{ + "items": [ + { + "name": "mcp-server-a", + "full_name": "org/mcp-server-a", + "html_url": "https://github.com/org/mcp-server-a", + "description": "First server", + "topics": ["mcp"] + }, + { + "name": "mcp-server-b", + "full_name": "org/mcp-server-b", + "html_url": "https://github.com/org/mcp-server-b", + "description": null, + "topics": ["mcp", "tools"] + } + ] + }"#; + let resp: super::GitHubSearchResponse = serde_json::from_str(json).unwrap(); + assert_eq!(resp.items.len(), 2); + assert_eq!(resp.items[0].name, "mcp-server-a"); + assert_eq!(resp.items[1].name, "mcp-server-b"); + assert_eq!(resp.items[0].description, Some("First server".to_string())); + assert!(resp.items[1].description.is_none()); + } + + #[test] + fn test_github_repo_all_fields() { + let json = r#"{ + "name": "cool-mcp", + "full_name": "user/cool-mcp", + "html_url": "https://github.com/user/cool-mcp", + "description": "A cool MCP server", + "homepage": "https://cool-mcp.dev", + "topics": ["mcp-server", "model-context-protocol", "rust"] + }"#; + let repo: super::GitHubRepo = serde_json::from_str(json).unwrap(); + assert_eq!(repo.name, "cool-mcp"); + assert_eq!(repo.full_name, "user/cool-mcp"); + assert_eq!(repo.html_url, "https://github.com/user/cool-mcp"); + assert_eq!(repo.description.as_deref(), Some("A cool MCP server")); + assert_eq!(repo.homepage.as_deref(), Some("https://cool-mcp.dev")); + assert_eq!(repo.topics.len(), 3); + } + + #[test] + fn test_github_repo_missing_optional_fields() { + let json = r#"{ + "name": "bare-repo", + "full_name": "user/bare-repo", + "html_url": "https://github.com/user/bare-repo" + }"#; + let repo: super::GitHubRepo = serde_json::from_str(json).unwrap(); + assert_eq!(repo.name, "bare-repo"); + assert!(repo.description.is_none()); + assert!(repo.homepage.is_none()); + assert!(repo.topics.is_empty()); + } + + #[tokio::test] + async fn test_with_timeout_completes() { + use crate::extensions::discovery::with_timeout; + + let result = with_timeout(async { 42 }, std::time::Duration::from_secs(1)).await; + assert_eq!(result, Some(42)); + } + + #[tokio::test] + async fn test_with_timeout_expires() { + use crate::extensions::discovery::with_timeout; + + let result = with_timeout( + tokio::time::sleep(std::time::Duration::from_secs(5)), + std::time::Duration::from_millis(10), + ) + .await; + assert!(result.is_none()); + } + + #[tokio::test] + async fn test_discover_empty_query() { + let discovery = OnlineDiscovery::new(); + let results = discovery.discover("").await; + assert!(results.is_empty()); + } + + #[tokio::test] + async fn test_discover_whitespace_only_query() { + let discovery = OnlineDiscovery::new(); + let results = discovery.discover(" \t\n ").await; + assert!(results.is_empty()); + } } diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 51a173f7..1f0375e4 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -617,4 +617,418 @@ mod tests { assert!(result.instructions().is_none()); assert!(result.setup_url().is_none()); } + + // ── ExtensionKind ──────────────────────────────────────────────── + + #[test] + fn extension_kind_display() { + assert_eq!(ExtensionKind::McpServer.to_string(), "mcp_server"); + assert_eq!(ExtensionKind::WasmTool.to_string(), "wasm_tool"); + assert_eq!(ExtensionKind::WasmChannel.to_string(), "wasm_channel"); + } + + #[test] + fn extension_kind_serde_roundtrip() { + for kind in [ + ExtensionKind::McpServer, + ExtensionKind::WasmTool, + ExtensionKind::WasmChannel, + ] { + let json = serde_json::to_value(kind).unwrap(); + let back: ExtensionKind = serde_json::from_value(json).unwrap(); + assert_eq!(back, kind); + } + // Verify the serialized strings match rename_all = "snake_case" + assert_eq!( + serde_json::to_value(ExtensionKind::McpServer).unwrap(), + "mcp_server" + ); + assert_eq!( + serde_json::to_value(ExtensionKind::WasmTool).unwrap(), + "wasm_tool" + ); + assert_eq!( + serde_json::to_value(ExtensionKind::WasmChannel).unwrap(), + "wasm_channel" + ); + } + + // ── ExtensionSource ────────────────────────────────────────────── + + #[test] + fn extension_source_serde_mcp_url() { + let src = ExtensionSource::McpUrl { + url: "https://mcp.example.com".to_string(), + }; + let json = serde_json::to_value(&src).unwrap(); + assert_eq!(json["type"], "mcp_url"); + assert_eq!(json["url"], "https://mcp.example.com"); + let back: ExtensionSource = serde_json::from_value(json).unwrap(); + assert!( + matches!(back, ExtensionSource::McpUrl { url } if url == "https://mcp.example.com") + ); + } + + #[test] + fn extension_source_serde_wasm_download() { + let src = ExtensionSource::WasmDownload { + wasm_url: "https://cdn.example.com/tool.wasm".to_string(), + capabilities_url: Some("https://cdn.example.com/caps.json".to_string()), + }; + let json = serde_json::to_value(&src).unwrap(); + assert_eq!(json["type"], "wasm_download"); + assert_eq!(json["wasm_url"], "https://cdn.example.com/tool.wasm"); + assert_eq!( + json["capabilities_url"], + "https://cdn.example.com/caps.json" + ); + let back: ExtensionSource = serde_json::from_value(json).unwrap(); + assert!( + matches!(back, ExtensionSource::WasmDownload { capabilities_url: Some(c), .. } if c.contains("caps.json")) + ); + } + + #[test] + fn extension_source_serde_wasm_buildable() { + let src = ExtensionSource::WasmBuildable { + source_dir: "/home/user/tools/my-tool".to_string(), + build_dir: Some("target/wasm32-wasip2/release".to_string()), + crate_name: Some("my_tool".to_string()), + }; + let json = serde_json::to_value(&src).unwrap(); + assert_eq!(json["type"], "wasm_buildable"); + assert_eq!(json["source_dir"], "/home/user/tools/my-tool"); + let back: ExtensionSource = serde_json::from_value(json).unwrap(); + assert!( + matches!(back, ExtensionSource::WasmBuildable { source_dir, .. } if source_dir.contains("my-tool")) + ); + } + + #[test] + fn extension_source_serde_discovered() { + let src = ExtensionSource::Discovered { + url: "https://discovered.example.com".to_string(), + }; + let json = serde_json::to_value(&src).unwrap(); + assert_eq!(json["type"], "discovered"); + let back: ExtensionSource = serde_json::from_value(json).unwrap(); + assert!(matches!(back, ExtensionSource::Discovered { url } if url.contains("discovered"))); + } + + // ── AuthHint ───────────────────────────────────────────────────── + + #[test] + fn auth_hint_serde_all_variants() { + // Dcr + let json = serde_json::to_value(&AuthHint::Dcr).unwrap(); + assert_eq!(json["type"], "dcr"); + let back: AuthHint = serde_json::from_value(json).unwrap(); + assert!(matches!(back, AuthHint::Dcr)); + + // OAuthPreConfigured + let hint = AuthHint::OAuthPreConfigured { + setup_url: "https://dev.example.com/apps".to_string(), + }; + let json = serde_json::to_value(&hint).unwrap(); + assert_eq!(json["type"], "o_auth_pre_configured"); + assert_eq!(json["setup_url"], "https://dev.example.com/apps"); + let back: AuthHint = serde_json::from_value(json).unwrap(); + assert!( + matches!(back, AuthHint::OAuthPreConfigured { setup_url } if setup_url.contains("dev.example")) + ); + + // CapabilitiesAuth + let json = serde_json::to_value(&AuthHint::CapabilitiesAuth).unwrap(); + assert_eq!(json["type"], "capabilities_auth"); + let back: AuthHint = serde_json::from_value(json).unwrap(); + assert!(matches!(back, AuthHint::CapabilitiesAuth)); + + // None + let json = serde_json::to_value(&AuthHint::None).unwrap(); + assert_eq!(json["type"], "none"); + let back: AuthHint = serde_json::from_value(json).unwrap(); + assert!(matches!(back, AuthHint::None)); + } + + // ── SearchResult ───────────────────────────────────────────────── + + #[test] + fn search_result_serde_registry_source() { + // SearchResult uses #[serde(flatten)] on entry, which means + // RegistryEntry.source (ExtensionSource) and SearchResult.source + // (ResultSource) collide on the "source" key. The last writer wins + // during serialization, so we test serialize-only (no roundtrip). + let entry = RegistryEntry { + name: "notion".to_string(), + display_name: "Notion".to_string(), + kind: ExtensionKind::McpServer, + description: "Notion integration".to_string(), + keywords: vec!["notes".to_string(), "wiki".to_string()], + source: ExtensionSource::McpUrl { + url: "https://mcp.notion.so".to_string(), + }, + fallback_source: None, + auth_hint: AuthHint::Dcr, + }; + let sr = SearchResult { + entry, + source: ResultSource::Registry, + validated: false, + }; + let json = serde_json::to_value(&sr).unwrap(); + assert_eq!(json["name"], "notion"); + assert_eq!(json["kind"], "mcp_server"); + assert_eq!(json["description"], "Notion integration"); + assert_eq!(json["validated"], false); + // The flattened entry fields are present at the top level + assert!(json.get("auth_hint").is_some()); + assert_eq!(json["keywords"].as_array().unwrap().len(), 2); + } + + #[test] + fn search_result_serde_discovered_source() { + let entry = RegistryEntry { + name: "custom-api".to_string(), + display_name: "Custom API".to_string(), + kind: ExtensionKind::McpServer, + description: "Discovered MCP server".to_string(), + keywords: vec![], + source: ExtensionSource::Discovered { + url: "https://custom.example.com/.well-known/mcp".to_string(), + }, + fallback_source: None, + auth_hint: AuthHint::None, + }; + let sr = SearchResult { + entry, + source: ResultSource::Discovered, + validated: true, + }; + let json = serde_json::to_value(&sr).unwrap(); + assert_eq!(json["name"], "custom-api"); + assert_eq!(json["display_name"], "Custom API"); + assert_eq!(json["validated"], true); + assert!(json.get("keywords").is_some()); + } + + // ── InstallResult ──────────────────────────────────────────────── + + #[test] + fn install_result_serde_roundtrip() { + let ir = InstallResult { + name: "weather".to_string(), + kind: ExtensionKind::WasmTool, + message: "Installed successfully".to_string(), + }; + let json = serde_json::to_value(&ir).unwrap(); + assert_eq!(json["name"], "weather"); + assert_eq!(json["kind"], "wasm_tool"); + assert_eq!(json["message"], "Installed successfully"); + let back: InstallResult = serde_json::from_value(json).unwrap(); + assert_eq!(back.name, "weather"); + assert_eq!(back.kind, ExtensionKind::WasmTool); + } + + // ── ActivateResult ─────────────────────────────────────────────── + + #[test] + fn activate_result_serde_roundtrip() { + let ar = ActivateResult { + name: "slack".to_string(), + kind: ExtensionKind::WasmChannel, + tools_loaded: vec!["send_message".to_string(), "read_channel".to_string()], + message: "Activated with 2 tools".to_string(), + }; + let json = serde_json::to_value(&ar).unwrap(); + assert_eq!(json["name"], "slack"); + assert_eq!(json["kind"], "wasm_channel"); + assert_eq!(json["tools_loaded"].as_array().unwrap().len(), 2); + let back: ActivateResult = serde_json::from_value(json).unwrap(); + assert_eq!(back.tools_loaded, vec!["send_message", "read_channel"]); + } + + // ── InstalledExtension ─────────────────────────────────────────── + + #[test] + fn installed_extension_serde_defaults() { + // Minimal JSON: optional fields absent, defaults kick in + let json = serde_json::json!({ + "name": "echo", + "kind": "wasm_tool", + "authenticated": false, + "active": false, + }); + let ext: InstalledExtension = serde_json::from_value(json).unwrap(); + assert_eq!(ext.name, "echo"); + assert!(ext.installed, "installed should default to true"); + assert!(!ext.needs_setup, "needs_setup should default to false"); + assert!(!ext.has_auth); + assert!(ext.tools.is_empty()); + assert!(ext.display_name.is_none()); + assert!(ext.description.is_none()); + assert!(ext.url.is_none()); + assert!(ext.activation_error.is_none()); + } + + #[test] + fn installed_extension_serde_all_fields() { + let ext = InstalledExtension { + name: "gmail".to_string(), + kind: ExtensionKind::WasmTool, + display_name: Some("Gmail Tool".to_string()), + description: Some("Read and send emails".to_string()), + url: Some("https://gmail.example.com".to_string()), + authenticated: true, + active: true, + tools: vec!["send_email".to_string(), "read_inbox".to_string()], + needs_setup: true, + has_auth: true, + installed: false, + activation_error: Some("token expired".to_string()), + }; + let json = serde_json::to_value(&ext).unwrap(); + assert_eq!(json["display_name"], "Gmail Tool"); + assert_eq!(json["description"], "Read and send emails"); + assert_eq!(json["url"], "https://gmail.example.com"); + assert_eq!(json["needs_setup"], true); + assert_eq!(json["installed"], false); + assert_eq!(json["activation_error"], "token expired"); + + let back: InstalledExtension = serde_json::from_value(json).unwrap(); + assert_eq!(back.name, "gmail"); + assert_eq!(back.tools.len(), 2); + assert!(back.needs_setup); + assert!(!back.installed); + assert_eq!(back.activation_error.as_deref(), Some("token expired")); + } + + // ── ExtensionError Display ─────────────────────────────────────── + + #[test] + fn extension_error_display_all_variants() { + let cases: Vec<(ExtensionError, &str)> = vec![ + ( + ExtensionError::NotFound("foo".into()), + "Extension not found: foo", + ), + ( + ExtensionError::AlreadyInstalled("bar".into()), + "Extension already installed: bar", + ), + ( + ExtensionError::NotInstalled("baz".into()), + "Extension not installed: baz", + ), + ( + ExtensionError::AuthFailed("bad token".into()), + "Authentication failed: bad token", + ), + ( + ExtensionError::ActivationFailed("crash".into()), + "Activation failed: crash", + ), + ( + ExtensionError::InstallFailed("disk full".into()), + "Installation failed: disk full", + ), + ( + ExtensionError::DiscoveryFailed("timeout".into()), + "Discovery failed: timeout", + ), + ( + ExtensionError::InvalidUrl("not a url".into()), + "Invalid URL: not a url", + ), + ( + ExtensionError::DownloadFailed("404".into()), + "Download failed: 404", + ), + ( + ExtensionError::Config("missing key".into()), + "Config error: missing key", + ), + ( + ExtensionError::Other("something broke".into()), + "something broke", + ), + ( + ExtensionError::FallbackFailed { + primary: Box::new(ExtensionError::DownloadFailed("404".into())), + fallback: Box::new(ExtensionError::InstallFailed("no cargo".into())), + }, + "Primary install failed: Download failed: 404; fallback install also failed: Installation failed: no cargo", + ), + ]; + for (err, expected) in cases { + assert_eq!(err.to_string(), expected); + } + } + + // ── ToolAuthState ──────────────────────────────────────────────── + + #[test] + fn tool_auth_state_equality() { + assert_eq!(ToolAuthState::Ready, ToolAuthState::Ready); + assert_eq!(ToolAuthState::NeedsAuth, ToolAuthState::NeedsAuth); + assert_eq!(ToolAuthState::NeedsSetup, ToolAuthState::NeedsSetup); + assert_eq!(ToolAuthState::NoAuth, ToolAuthState::NoAuth); + + assert_ne!(ToolAuthState::Ready, ToolAuthState::NeedsAuth); + assert_ne!(ToolAuthState::NeedsSetup, ToolAuthState::NoAuth); + assert_ne!(ToolAuthState::Ready, ToolAuthState::NoAuth); + } + + // ── ResultSource ───────────────────────────────────────────────── + + #[test] + fn result_source_serde() { + let json = serde_json::to_value(ResultSource::Registry).unwrap(); + assert_eq!(json, "registry"); + let back: ResultSource = serde_json::from_value(json).unwrap(); + assert_eq!(back, ResultSource::Registry); + + let json = serde_json::to_value(ResultSource::Discovered).unwrap(); + assert_eq!(json, "discovered"); + let back: ResultSource = serde_json::from_value(json).unwrap(); + assert_eq!(back, ResultSource::Discovered); + } + + // ── AuthResult::status_str ─────────────────────────────────────── + + #[test] + fn auth_result_status_str_all_variants() { + assert_eq!( + AuthResult::authenticated("a", ExtensionKind::McpServer).status_str(), + "authenticated" + ); + assert_eq!( + AuthResult::no_auth_required("b", ExtensionKind::WasmTool).status_str(), + "no_auth_required" + ); + assert_eq!( + AuthResult::awaiting_authorization( + "c", + ExtensionKind::WasmChannel, + "https://x.com".into(), + "local".into(), + ) + .status_str(), + "awaiting_authorization" + ); + assert_eq!( + AuthResult::awaiting_token("d", ExtensionKind::WasmTool, "paste token".into(), None) + .status_str(), + "awaiting_token" + ); + assert_eq!( + AuthResult::needs_setup( + "e", + ExtensionKind::McpServer, + "configure oauth".into(), + Some("https://setup.example.com".into()), + ) + .status_str(), + "needs_setup" + ); + } } diff --git a/src/llm/nearai_chat.rs b/src/llm/nearai_chat.rs index a06a98b8..d1857807 100644 --- a/src/llm/nearai_chat.rs +++ b/src/llm/nearai_chat.rs @@ -1521,4 +1521,599 @@ mod tests { std::env::remove_var("NEARAI_API_KEY"); } } + + // -- ModelInfo serde alias tests ------------------------------------------ + + #[test] + fn test_model_info_deserialize_with_name_field() { + let json = r#"{"name": "claude-3-5-sonnet"}"#; + let info: ModelInfo = serde_json::from_str(json).unwrap(); + assert_eq!(info.name, "claude-3-5-sonnet"); + assert!(info.provider.is_none()); + } + + #[test] + fn test_model_info_deserialize_with_id_alias() { + let json = r#"{"id": "gpt-4o", "provider": "openai"}"#; + let info: ModelInfo = serde_json::from_str(json).unwrap(); + assert_eq!(info.name, "gpt-4o"); + assert_eq!(info.provider, Some("openai".to_string())); + } + + #[test] + fn test_model_info_deserialize_with_model_alias() { + let json = r#"{"model": "llama-3.1-70b"}"#; + let info: ModelInfo = serde_json::from_str(json).unwrap(); + assert_eq!(info.name, "llama-3.1-70b"); + } + + #[test] + fn test_model_info_roundtrip_serializes_as_name() { + let info = ModelInfo { + name: "test-model".to_string(), + provider: Some("nearai".to_string()), + }; + let json = serde_json::to_value(&info).unwrap(); + // Serialization always uses the field name "name", not the aliases + assert_eq!(json["name"], "test-model"); + assert_eq!(json["provider"], "nearai"); + assert!(json.get("id").is_none()); + assert!(json.get("model").is_none()); + } + + // -- ChatCompletionRequest serialization ---------------------------------- + + #[test] + fn test_request_serialization_minimal() { + let req = ChatCompletionRequest { + model: "gpt-4o".to_string(), + messages: vec![ChatCompletionMessage { + role: "user".to_string(), + content: Some("Hello".to_string()), + tool_call_id: None, + name: None, + tool_calls: None, + }], + temperature: None, + max_tokens: None, + tools: None, + tool_choice: None, + }; + let json = serde_json::to_value(&req).unwrap(); + assert_eq!(json["model"], "gpt-4o"); + assert_eq!(json["messages"][0]["role"], "user"); + assert_eq!(json["messages"][0]["content"], "Hello"); + // Optional fields should be absent, not null + assert!(json.get("temperature").is_none()); + assert!(json.get("max_tokens").is_none()); + assert!(json.get("tools").is_none()); + assert!(json.get("tool_choice").is_none()); + } + + #[test] + fn test_request_serialization_with_tools() { + let req = ChatCompletionRequest { + model: "gpt-4o".to_string(), + messages: vec![], + temperature: Some(0.7), + max_tokens: Some(1024), + tools: Some(vec![ChatCompletionTool { + tool_type: "function".to_string(), + function: ChatCompletionFunction { + name: "get_weather".to_string(), + description: Some("Get the weather".to_string()), + parameters: Some(serde_json::json!({ + "type": "object", + "properties": { + "city": {"type": "string"} + } + })), + }, + }]), + tool_choice: Some("auto".to_string()), + }; + let json = serde_json::to_value(&req).unwrap(); + // f32 precision: 0.7f32 serializes as 0.699999988... in JSON + let temp = json["temperature"].as_f64().unwrap(); + assert!( + (temp - 0.7).abs() < 0.001, + "temperature should be ~0.7, got {temp}" + ); + assert_eq!(json["max_tokens"], 1024); + assert_eq!(json["tool_choice"], "auto"); + // Tool uses "type" key (via rename), not "tool_type" + assert_eq!(json["tools"][0]["type"], "function"); + assert_eq!(json["tools"][0]["function"]["name"], "get_weather"); + } + + #[test] + fn test_request_omits_null_content_on_assistant_messages() { + // When an assistant message has tool_calls but no content, content + // should serialize as absent (skip_serializing_if) not "content": null. + let msg = ChatCompletionMessage { + role: "assistant".to_string(), + content: None, + tool_call_id: None, + name: None, + tool_calls: Some(vec![ChatCompletionToolCall { + id: "call_1".to_string(), + call_type: "function".to_string(), + function: ChatCompletionToolCallFunction { + name: "echo".to_string(), + arguments: "{}".to_string(), + }, + }]), + }; + let json = serde_json::to_value(&msg).unwrap(); + assert!( + json.get("content").is_none(), + "content should be omitted when None" + ); + assert!(json.get("tool_call_id").is_none()); + assert!(json.get("name").is_none()); + assert!(json["tool_calls"].is_array()); + } + + // -- ChatCompletionResponse deserialization ------------------------------- + + #[test] + fn test_response_deserialize_basic() { + let json = serde_json::json!({ + "id": "chatcmpl-abc123", + "object": "chat.completion", + "choices": [{ + "message": { + "role": "assistant", + "content": "Hello!" + }, + "finish_reason": "stop" + }], + "usage": { + "prompt_tokens": 10, + "completion_tokens": 5, + "total_tokens": 15 + } + }); + let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap(); + assert_eq!(resp.id, Some("chatcmpl-abc123".to_string())); + assert_eq!(resp.choices.len(), 1); + assert_eq!(resp.choices[0].message.content, Some("Hello!".to_string())); + assert_eq!(resp.choices[0].finish_reason, Some("stop".to_string())); + let usage = resp.usage.unwrap(); + assert_eq!(usage.prompt_tokens, Some(10)); + assert_eq!(usage.completion_tokens, Some(5)); + assert_eq!(usage.total_tokens, Some(15)); + } + + #[test] + fn test_response_deserialize_missing_optional_fields() { + // Minimal response: no id, no usage, no finish_reason + let json = serde_json::json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": "Hi" + }, + "finish_reason": null + }] + }); + let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap(); + assert!(resp.id.is_none()); + assert!(resp.usage.is_none()); + assert!(resp.choices[0].finish_reason.is_none()); + } + + #[test] + fn test_response_deserialize_with_tool_calls() { + let json = serde_json::json!({ + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [ + { + "id": "call_abc", + "type": "function", + "function": { + "name": "get_weather", + "arguments": "{\"city\":\"NYC\"}" + } + }, + { + "id": "call_def", + "type": "function", + "function": { + "name": "get_time", + "arguments": "{}" + } + } + ] + }, + "finish_reason": "tool_calls" + }] + }); + let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap(); + let tc = resp.choices[0].message.tool_calls.as_ref().unwrap(); + assert_eq!(tc.len(), 2); + assert_eq!(tc[0].id, "call_abc"); + assert_eq!(tc[0].function.name, "get_weather"); + assert_eq!(tc[0].function.arguments, "{\"city\":\"NYC\"}"); + assert_eq!(tc[1].id, "call_def"); + assert_eq!(tc[1].function.name, "get_time"); + } + + #[test] + fn test_response_deserialize_ignores_unknown_fields() { + // Real API responses have extra fields like "object", "created", "model" + let json = serde_json::json!({ + "id": "chatcmpl-xyz", + "object": "chat.completion", + "created": 1700000000, + "model": "gpt-4o", + "system_fingerprint": "fp_abc123", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", + "content": "ok" + }, + "finish_reason": "stop", + "logprobs": null + }], + "usage": { + "prompt_tokens": 5, + "completion_tokens": 1, + "total_tokens": 6 + } + }); + let resp: ChatCompletionResponse = serde_json::from_value(json).unwrap(); + assert_eq!(resp.choices[0].message.content, Some("ok".to_string())); + } + + // -- parse_usage and saturate_u32 ----------------------------------------- + + #[test] + fn test_parse_usage_with_all_fields() { + let usage = ChatCompletionUsage { + prompt_tokens: Some(100), + completion_tokens: Some(50), + total_tokens: Some(150), + }; + assert_eq!(parse_usage(Some(&usage)), (100, 50)); + } + + #[test] + fn test_parse_usage_none() { + assert_eq!(parse_usage(None), (0, 0)); + } + + #[test] + fn test_parse_usage_missing_completion_falls_back_to_total_minus_prompt() { + let usage = ChatCompletionUsage { + prompt_tokens: Some(100), + completion_tokens: None, + total_tokens: Some(180), + }; + // output = total - prompt = 80 + assert_eq!(parse_usage(Some(&usage)), (100, 80)); + } + + #[test] + fn test_parse_usage_missing_completion_and_prompt_uses_total() { + let usage = ChatCompletionUsage { + prompt_tokens: None, + completion_tokens: None, + total_tokens: Some(200), + }; + // input = 0 (no prompt), output = total = 200 + assert_eq!(parse_usage(Some(&usage)), (0, 200)); + } + + #[test] + fn test_parse_usage_all_none() { + let usage = ChatCompletionUsage { + prompt_tokens: None, + completion_tokens: None, + total_tokens: None, + }; + assert_eq!(parse_usage(Some(&usage)), (0, 0)); + } + + #[test] + fn test_saturate_u32_within_range() { + assert_eq!(saturate_u32(0), 0); + assert_eq!(saturate_u32(42), 42); + assert_eq!(saturate_u32(u32::MAX as u64), u32::MAX); + } + + #[test] + fn test_saturate_u32_overflow_clamps() { + assert_eq!(saturate_u32(u32::MAX as u64 + 1), u32::MAX); + assert_eq!(saturate_u32(u64::MAX), u32::MAX); + } + + // -- Pricing types deserialization ---------------------------------------- + + #[test] + fn test_model_cost_deserialize() { + let json = r#"{"amount": 3.0, "scale": 6}"#; + let mc: ModelCost = serde_json::from_str(json).unwrap(); + assert_eq!(mc.amount, 3.0); + assert_eq!(mc.scale, 6); + } + + #[test] + fn test_model_cost_scale_defaults_to_zero() { + let json = r#"{"amount": 0.5}"#; + let mc: ModelCost = serde_json::from_str(json).unwrap(); + assert_eq!(mc.scale, 0); + } + + #[test] + fn test_model_cost_to_decimal_negative_scale() { + // amount=2, scale=-3 → 2 * 10^3 = 2000 + let mc = ModelCost { + amount: 2.0, + scale: -3, + }; + let result = model_cost_to_decimal(&mc).unwrap(); + assert_eq!(result, dec!(2000)); + } + + #[test] + fn test_pricing_model_entry_deserialize_camel_case_aliases() { + let json = serde_json::json!({ + "modelId": "claude-3-5-sonnet", + "inputCostPerToken": {"amount": 3.0, "scale": 6}, + "outputCostPerToken": {"amount": 15.0, "scale": 6}, + "metadata": {"aliases": ["claude-sonnet", "claude-3.5-sonnet"]} + }); + let entry: PricingModelEntry = serde_json::from_value(json).unwrap(); + assert_eq!(entry.model_id, Some("claude-3-5-sonnet".to_string())); + let input = model_cost_to_decimal(entry.input_cost_per_token.as_ref().unwrap()).unwrap(); + assert_eq!(input, dec!(0.000003)); + let output = model_cost_to_decimal(entry.output_cost_per_token.as_ref().unwrap()).unwrap(); + assert_eq!(output, dec!(0.000015)); + assert_eq!( + entry.metadata.unwrap().aliases, + vec!["claude-sonnet", "claude-3.5-sonnet"] + ); + } + + #[test] + fn test_pricing_model_entry_deserialize_snake_case() { + let json = serde_json::json!({ + "model_id": "gpt-4o", + "input_cost_per_token": {"amount": 5.0, "scale": 6}, + "output_cost_per_token": {"amount": 15.0, "scale": 6} + }); + let entry: PricingModelEntry = serde_json::from_value(json).unwrap(); + assert_eq!(entry.model_id, Some("gpt-4o".to_string())); + assert!(entry.input_cost_per_token.is_some()); + assert!(entry.metadata.is_none()); + } + + #[test] + fn test_pricing_response_models_wrapper() { + let json = serde_json::json!({ + "models": [ + {"model_id": "m1", "input_cost_per_token": {"amount": 1.0, "scale": 6}, + "output_cost_per_token": {"amount": 2.0, "scale": 6}} + ] + }); + let resp: PricingResponse = serde_json::from_value(json).unwrap(); + assert!(resp.models.is_some()); + assert_eq!(resp.models.unwrap().len(), 1); + assert!(resp.data.is_none()); + } + + #[test] + fn test_pricing_response_data_wrapper() { + let json = serde_json::json!({ + "data": [ + {"model_id": "m1"}, + {"model_id": "m2"} + ] + }); + let resp: PricingResponse = serde_json::from_value(json).unwrap(); + assert!(resp.models.is_none()); + assert_eq!(resp.data.unwrap().len(), 2); + } + + // -- flatten_tool_messages edge cases ------------------------------------- + + #[test] + fn test_flatten_tool_result_missing_name_uses_unknown() { + let messages = vec![ChatCompletionMessage { + role: "tool".to_string(), + content: Some("result data".to_string()), + tool_call_id: Some("call_1".to_string()), + name: None, + tool_calls: None, + }]; + let result = flatten_tool_messages(messages); + assert_eq!(result[0].role, "user"); + assert!( + result[0] + .content + .as_ref() + .unwrap() + .contains("[Tool `unknown` returned:") + ); + } + + #[test] + fn test_flatten_tool_result_missing_content_uses_empty() { + let messages = vec![ChatCompletionMessage { + role: "tool".to_string(), + content: None, + tool_call_id: Some("call_1".to_string()), + name: Some("my_tool".to_string()), + tool_calls: None, + }]; + let result = flatten_tool_messages(messages); + assert_eq!(result[0].role, "user"); + assert!( + result[0] + .content + .as_ref() + .unwrap() + .contains("[Tool `my_tool` returned: ]") + ); + } + + #[test] + fn test_flatten_multiple_tool_calls_in_single_assistant_message() { + let messages = vec![ + ChatCompletionMessage { + role: "assistant".to_string(), + content: None, + tool_call_id: None, + name: None, + tool_calls: Some(vec![ + ChatCompletionToolCall { + id: "call_1".to_string(), + call_type: "function".to_string(), + function: ChatCompletionToolCallFunction { + name: "search".to_string(), + arguments: r#"{"q":"a"}"#.to_string(), + }, + }, + ChatCompletionToolCall { + id: "call_2".to_string(), + call_type: "function".to_string(), + function: ChatCompletionToolCallFunction { + name: "fetch".to_string(), + arguments: r#"{"url":"http://x"}"#.to_string(), + }, + }, + ]), + }, + ChatCompletionMessage { + role: "tool".to_string(), + content: Some("found".to_string()), + tool_call_id: Some("call_1".to_string()), + name: Some("search".to_string()), + tool_calls: None, + }, + ChatCompletionMessage { + role: "tool".to_string(), + content: Some("fetched".to_string()), + tool_call_id: Some("call_2".to_string()), + name: Some("fetch".to_string()), + tool_calls: None, + }, + ]; + let result = flatten_tool_messages(messages); + assert_eq!(result.len(), 3); + // Assistant message has both calls described + let assistant_text = result[0].content.as_ref().unwrap(); + assert!(assistant_text.contains("[Called tool `search`")); + assert!(assistant_text.contains("[Called tool `fetch`")); + assert!(result[0].tool_calls.is_none()); + // Both tool results become user messages + assert_eq!(result[1].role, "user"); + assert_eq!(result[2].role, "user"); + } + + // -- ChatMessage → ChatCompletionMessage edge cases ----------------------- + + #[test] + fn test_assistant_empty_content_with_tool_calls_becomes_none() { + // When content is empty string and tool_calls are present, content + // should be None to avoid sending `"content": ""` which some APIs reject. + let msg = ChatMessage::assistant_with_tool_calls( + None, + vec![ToolCall { + id: "call_1".to_string(), + name: "test".to_string(), + arguments: serde_json::json!({}), + }], + ); + let chat_msg: ChatCompletionMessage = msg.into(); + assert!( + chat_msg.content.is_none(), + "empty content with tool_calls should serialize as None" + ); + } + + #[test] + fn test_system_message_conversion() { + let msg = ChatMessage::system("You are a helpful assistant."); + let chat_msg: ChatCompletionMessage = msg.into(); + assert_eq!(chat_msg.role, "system"); + assert_eq!( + chat_msg.content, + Some("You are a helpful assistant.".to_string()) + ); + assert!(chat_msg.tool_calls.is_none()); + assert!(chat_msg.tool_call_id.is_none()); + } + + // -- ChatCompletionUsage deserialization ----------------------------------- + + #[test] + fn test_usage_deserialize_partial_fields() { + // Some providers only return total_tokens + let json = r#"{"total_tokens": 500}"#; + let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap(); + assert!(usage.prompt_tokens.is_none()); + assert!(usage.completion_tokens.is_none()); + assert_eq!(usage.total_tokens, Some(500)); + } + + #[test] + fn test_usage_deserialize_empty_object() { + let json = "{}"; + let usage: ChatCompletionUsage = serde_json::from_str(json).unwrap(); + assert!(usage.prompt_tokens.is_none()); + assert!(usage.completion_tokens.is_none()); + assert!(usage.total_tokens.is_none()); + } + + // -- ChatCompletionToolCall serde roundtrip -------------------------------- + + #[test] + fn test_tool_call_serde_roundtrip() { + let tc = ChatCompletionToolCall { + id: "call_abc".to_string(), + call_type: "function".to_string(), + function: ChatCompletionToolCallFunction { + name: "get_weather".to_string(), + arguments: r#"{"city":"London"}"#.to_string(), + }, + }; + let json = serde_json::to_value(&tc).unwrap(); + // "type" not "call_type" in serialized form + assert_eq!(json["type"], "function"); + assert!(json.get("call_type").is_none()); + assert_eq!(json["id"], "call_abc"); + + // Deserialize back + let deserialized: ChatCompletionToolCall = serde_json::from_value(json).unwrap(); + assert_eq!(deserialized.id, "call_abc"); + assert_eq!(deserialized.call_type, "function"); + assert_eq!(deserialized.function.name, "get_weather"); + assert_eq!(deserialized.function.arguments, r#"{"city":"London"}"#); + } + + // -- api_url edge cases --------------------------------------------------- + + #[test] + fn test_api_url_with_trailing_v1_slash() { + let cfg = test_nearai_config("http://example.com/v1/"); + let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider"); + // Trailing slash gets trimmed, then /v1 is detected + assert_eq!(provider.api_url("models"), "http://example.com/v1/models"); + } + + #[test] + fn test_api_url_with_deep_base_path() { + let cfg = test_nearai_config("http://example.com/api/proxy"); + let provider = NearAiChatProvider::new(cfg, test_session()).expect("provider"); + assert_eq!( + provider.api_url("chat/completions"), + "http://example.com/api/proxy/v1/chat/completions" + ); + } } diff --git a/src/llm/session.rs b/src/llm/session.rs index 7a410ef4..94b3f243 100644 --- a/src/llm/session.rs +++ b/src/llm/session.rs @@ -695,4 +695,154 @@ mod tests { assert!(path.ends_with("session.json")); assert!(path.to_string_lossy().contains(".ironclaw")); } + + #[test] + fn test_session_data_serde_roundtrip_with_auth_provider() { + let original = SessionData { + session_token: "sess_abc123".to_string(), + created_at: Utc::now(), + auth_provider: Some("github".to_string()), + }; + let json = serde_json::to_string(&original).unwrap(); + let deserialized: SessionData = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.session_token, original.session_token); + assert_eq!(deserialized.auth_provider, Some("github".to_string())); + assert_eq!(deserialized.created_at, original.created_at); + } + + #[test] + fn test_session_data_serde_roundtrip_without_auth_provider() { + let original = SessionData { + session_token: "sess_xyz789".to_string(), + created_at: Utc::now(), + auth_provider: None, + }; + let json = serde_json::to_string(&original).unwrap(); + let deserialized: SessionData = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.session_token, original.session_token); + assert_eq!(deserialized.auth_provider, None); + } + + #[test] + fn test_session_data_missing_auth_provider_defaults_to_none() { + let json = r#"{"session_token":"tok_legacy","created_at":"2025-01-01T00:00:00Z"}"#; + let data: SessionData = serde_json::from_str(json).unwrap(); + assert_eq!(data.session_token, "tok_legacy"); + assert_eq!(data.auth_provider, None); + } + + #[test] + fn test_session_config_default() { + let config = SessionConfig::default(); + assert_eq!(config.auth_base_url, "https://private.near.ai"); + assert!(config.session_path.ends_with("session.json")); + assert!(config.session_path.to_string_lossy().contains(".ironclaw")); + } + + #[tokio::test] + async fn test_new_with_nonexistent_session_file() { + let dir = tempdir().unwrap(); + let config = SessionConfig { + auth_base_url: "https://example.com".to_string(), + session_path: dir.path().join("does_not_exist.json"), + }; + let manager = SessionManager::new(config); + assert!(!manager.has_token().await); + } + + #[tokio::test] + async fn test_set_token_get_token_roundtrip() { + let dir = tempdir().unwrap(); + let config = SessionConfig { + auth_base_url: "https://example.com".to_string(), + session_path: dir.path().join("session.json"), + }; + let manager = SessionManager::new(config); + manager + .set_token(SecretString::from("my_secret_token")) + .await; + let token = manager.get_token().await.unwrap(); + assert_eq!(token.expose_secret(), "my_secret_token"); + } + + #[tokio::test] + async fn test_has_token_false_then_true() { + let dir = tempdir().unwrap(); + let config = SessionConfig { + auth_base_url: "https://example.com".to_string(), + session_path: dir.path().join("session.json"), + }; + let manager = SessionManager::new(config); + assert!(!manager.has_token().await); + manager.set_token(SecretString::from("tok_something")).await; + assert!(manager.has_token().await); + } + + #[tokio::test] + async fn test_save_session_then_load_in_new_manager() { + let dir = tempdir().unwrap(); + let session_path = dir.path().join("session.json"); + let config = SessionConfig { + auth_base_url: "https://example.com".to_string(), + session_path: session_path.clone(), + }; + + let manager = SessionManager::new_async(config.clone()).await; + manager + .save_session("persist_me", Some("google")) + .await + .unwrap(); + + // Load in a fresh manager + let manager2 = SessionManager::new_async(config).await; + assert!(manager2.has_token().await); + let token = manager2.get_token().await.unwrap(); + assert_eq!(token.expose_secret(), "persist_me"); + + // Verify auth_provider was persisted + let raw: SessionData = + serde_json::from_str(&std::fs::read_to_string(&session_path).unwrap()).unwrap(); + assert_eq!(raw.auth_provider, Some("google".to_string())); + } + + #[tokio::test] + async fn test_save_session_with_no_auth_provider() { + let dir = tempdir().unwrap(); + let session_path = dir.path().join("session.json"); + let config = SessionConfig { + auth_base_url: "https://example.com".to_string(), + session_path: session_path.clone(), + }; + + let manager = SessionManager::new_async(config).await; + manager.save_session("anon_tok", None).await.unwrap(); + + let raw: SessionData = + serde_json::from_str(&std::fs::read_to_string(&session_path).unwrap()).unwrap(); + assert_eq!(raw.session_token, "anon_tok"); + assert_eq!(raw.auth_provider, None); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_session_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = tempdir().unwrap(); + let session_path = dir.path().join("session.json"); + let config = SessionConfig { + auth_base_url: "https://example.com".to_string(), + session_path: session_path.clone(), + }; + + let manager = SessionManager::new_async(config).await; + manager + .save_session("secret_tok", Some("github")) + .await + .unwrap(); + + let metadata = std::fs::metadata(&session_path).unwrap(); + let mode = metadata.permissions().mode() & 0o777; + assert_eq!(mode, 0o600, "Session file should have 0600 permissions"); + } } diff --git a/src/secrets/crypto.rs b/src/secrets/crypto.rs index 1942ac3e..2f2de093 100644 --- a/src/secrets/crypto.rs +++ b/src/secrets/crypto.rs @@ -266,4 +266,108 @@ mod tests { let s2 = SecretsCrypto::generate_salt(); assert_ne!(s1, s2, "two generated salts should not be identical"); } + + #[test] + fn test_decrypt_truncated_ciphertext() { + let crypto = test_crypto(); + // Too short: less than NONCE_SIZE + TAG_SIZE (12 + 16 = 28) + let short = vec![0u8; 10]; + let salt = SecretsCrypto::generate_salt(); + let result = crypto.decrypt(&short, &salt); + assert!(result.is_err()); + match result.unwrap_err() { + crate::secrets::types::SecretError::DecryptionFailed(msg) => { + assert!(msg.contains("too short")); + } + other => panic!("expected DecryptionFailed, got {:?}", other), + } + } + + #[test] + fn test_different_master_keys_different_ciphertext() { + let key_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let key_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + let crypto_a = SecretsCrypto::new(SecretString::from(key_a.to_string())).unwrap(); + let crypto_b = SecretsCrypto::new(SecretString::from(key_b.to_string())).unwrap(); + + let plaintext = b"shared_secret"; + let (enc_a, salt_a) = crypto_a.encrypt(plaintext).unwrap(); + let (enc_b, salt_b) = crypto_b.encrypt(plaintext).unwrap(); + + // Each decrypts its own ciphertext + let dec_a = crypto_a.decrypt(&enc_a, &salt_a).unwrap(); + let dec_b = crypto_b.decrypt(&enc_b, &salt_b).unwrap(); + assert_eq!(dec_a.expose(), "shared_secret"); + assert_eq!(dec_b.expose(), "shared_secret"); + + // Cross-decryption fails + assert!(crypto_a.decrypt(&enc_b, &salt_b).is_err()); + assert!(crypto_b.decrypt(&enc_a, &salt_a).is_err()); + } + + #[test] + fn test_exact_minimum_key_length() { + // Exactly 32 bytes should work + let key = "a".repeat(super::KEY_SIZE); + assert!(SecretsCrypto::new(SecretString::from(key)).is_ok()); + + // 31 bytes should fail + let short = "a".repeat(super::KEY_SIZE - 1); + assert!(SecretsCrypto::new(SecretString::from(short)).is_err()); + } + + #[test] + fn test_longer_master_key_works() { + // Keys longer than 32 bytes are fine (HKDF handles it) + let long_key = "x".repeat(128); + let crypto = SecretsCrypto::new(SecretString::from(long_key)).unwrap(); + let plaintext = b"works with long key"; + let (encrypted, salt) = crypto.encrypt(plaintext).unwrap(); + let decrypted = crypto.decrypt(&encrypted, &salt).unwrap(); + assert_eq!(decrypted.expose(), "works with long key"); + } + + #[test] + fn test_debug_redacts_master_key() { + let crypto = test_crypto(); + let debug = format!("{:?}", crypto); + assert!(debug.contains("REDACTED")); + assert!(!debug.contains("0123456789abcdef")); + } + + #[test] + fn test_encrypted_output_structure() { + let crypto = test_crypto(); + let plaintext = b"hello"; + let (encrypted, salt) = crypto.encrypt(plaintext).unwrap(); + + // encrypted = nonce (12) + ciphertext (plaintext_len) + tag (16) + assert_eq!( + encrypted.len(), + super::NONCE_SIZE + plaintext.len() + super::TAG_SIZE + ); + assert_eq!(salt.len(), super::SALT_SIZE); + } + + #[test] + fn test_tampered_nonce_fails() { + let crypto = test_crypto(); + let plaintext = b"sensitive"; + let (mut encrypted, salt) = crypto.encrypt(plaintext).unwrap(); + + // Flip a bit in the nonce region (first 12 bytes) + encrypted[0] ^= 0x01; + + let result = crypto.decrypt(&encrypted, &salt); + assert!(result.is_err()); + } + + #[test] + fn test_unicode_plaintext_roundtrip() { + let crypto = test_crypto(); + let plaintext = "password: p@$$w0rd! 你好 🔑".as_bytes(); + let (encrypted, salt) = crypto.encrypt(plaintext).unwrap(); + let decrypted = crypto.decrypt(&encrypted, &salt).unwrap(); + assert_eq!(decrypted.expose(), "password: p@$$w0rd! 你好 🔑"); + } } diff --git a/src/secrets/types.rs b/src/secrets/types.rs index ae3edb60..77029c93 100644 --- a/src/secrets/types.rs +++ b/src/secrets/types.rs @@ -281,4 +281,226 @@ mod tests { assert_eq!(params.name, "key"); assert_eq!(params.provider, Some("stripe".to_string())); } + + #[test] + fn test_create_params_name_lowercased() { + let params = CreateSecretParams::new("SLACK_BOT_TOKEN", "val"); + assert_eq!(params.name, "slack_bot_token"); + } + + #[test] + fn test_create_params_with_expiry() { + use chrono::Utc; + let expiry = Utc::now(); + let params = CreateSecretParams::new("key", "val").with_expiry(expiry); + assert_eq!(params.expires_at, Some(expiry)); + } + + #[test] + fn test_secret_ref_without_provider() { + let r = SecretRef::new("token"); + assert_eq!(r.name, "token"); + assert!(r.provider.is_none()); + } + + #[test] + fn test_secret_ref_serde_roundtrip() { + let original = SecretRef::new("api_key").with_provider("openai"); + let json = serde_json::to_string(&original).unwrap(); + let deserialized: SecretRef = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, original.name); + assert_eq!(deserialized.provider, original.provider); + } + + #[test] + fn test_secret_ref_serde_without_provider() { + let original = SecretRef::new("bare_token"); + let json = serde_json::to_string(&original).unwrap(); + assert!(json.contains("\"provider\":null")); + let deserialized: SecretRef = serde_json::from_str(&json).unwrap(); + assert!(deserialized.provider.is_none()); + } + + #[test] + fn test_credential_location_serde_roundtrip_bearer() { + use crate::secrets::types::CredentialLocation; + let loc = CredentialLocation::AuthorizationBearer; + let json = serde_json::to_string(&loc).unwrap(); + let back: CredentialLocation = serde_json::from_str(&json).unwrap(); + assert!(matches!(back, CredentialLocation::AuthorizationBearer)); + } + + #[test] + fn test_credential_location_serde_roundtrip_basic() { + use crate::secrets::types::CredentialLocation; + let loc = CredentialLocation::AuthorizationBasic { + username: "admin".to_string(), + }; + let json = serde_json::to_string(&loc).unwrap(); + let back: CredentialLocation = serde_json::from_str(&json).unwrap(); + match back { + CredentialLocation::AuthorizationBasic { username } => { + assert_eq!(username, "admin"); + } + _ => panic!("expected AuthorizationBasic"), + } + } + + #[test] + fn test_credential_location_serde_roundtrip_header() { + use crate::secrets::types::CredentialLocation; + let loc = CredentialLocation::Header { + name: "X-Api-Key".to_string(), + prefix: Some("Token".to_string()), + }; + let json = serde_json::to_string(&loc).unwrap(); + let back: CredentialLocation = serde_json::from_str(&json).unwrap(); + match back { + CredentialLocation::Header { name, prefix } => { + assert_eq!(name, "X-Api-Key"); + assert_eq!(prefix, Some("Token".to_string())); + } + _ => panic!("expected Header"), + } + } + + #[test] + fn test_credential_location_serde_roundtrip_query_param() { + use crate::secrets::types::CredentialLocation; + let loc = CredentialLocation::QueryParam { + name: "access_token".to_string(), + }; + let json = serde_json::to_string(&loc).unwrap(); + let back: CredentialLocation = serde_json::from_str(&json).unwrap(); + match back { + CredentialLocation::QueryParam { name } => assert_eq!(name, "access_token"), + _ => panic!("expected QueryParam"), + } + } + + #[test] + fn test_credential_location_serde_roundtrip_url_path() { + use crate::secrets::types::CredentialLocation; + let loc = CredentialLocation::UrlPath { + placeholder: "{api_key}".to_string(), + }; + let json = serde_json::to_string(&loc).unwrap(); + let back: CredentialLocation = serde_json::from_str(&json).unwrap(); + match back { + CredentialLocation::UrlPath { placeholder } => assert_eq!(placeholder, "{api_key}"), + _ => panic!("expected UrlPath"), + } + } + + #[test] + fn test_credential_location_default_is_bearer() { + use crate::secrets::types::CredentialLocation; + let loc = CredentialLocation::default(); + assert!(matches!(loc, CredentialLocation::AuthorizationBearer)); + } + + #[test] + fn test_credential_mapping_bearer_constructor() { + use crate::secrets::types::CredentialMapping; + let m = CredentialMapping::bearer("my_token", "*.example.com"); + assert_eq!(m.secret_name, "my_token"); + assert!(matches!( + m.location, + crate::secrets::types::CredentialLocation::AuthorizationBearer + )); + assert_eq!(m.host_patterns, vec!["*.example.com".to_string()]); + } + + #[test] + fn test_credential_mapping_header_constructor() { + use crate::secrets::types::CredentialMapping; + let m = CredentialMapping::header("key", "X-Custom", "api.host.com"); + assert_eq!(m.secret_name, "key"); + match &m.location { + crate::secrets::types::CredentialLocation::Header { name, prefix } => { + assert_eq!(name, "X-Custom"); + assert!(prefix.is_none()); + } + _ => panic!("expected Header"), + } + assert_eq!(m.host_patterns, vec!["api.host.com".to_string()]); + } + + #[test] + fn test_credential_mapping_serde_roundtrip() { + use crate::secrets::types::CredentialMapping; + let original = CredentialMapping::bearer("tok", "*.api.com"); + let json = serde_json::to_string(&original).unwrap(); + let back: CredentialMapping = serde_json::from_str(&json).unwrap(); + assert_eq!(back.secret_name, "tok"); + assert_eq!(back.host_patterns, vec!["*.api.com".to_string()]); + } + + #[test] + fn test_decrypted_secret_invalid_utf8() { + let result = DecryptedSecret::from_bytes(vec![0xFF, 0xFE, 0x00]); + assert!(result.is_err()); + } + + #[test] + fn test_decrypted_secret_empty() { + let secret = DecryptedSecret::from_bytes(Vec::new()).unwrap(); + assert!(secret.is_empty()); + assert_eq!(secret.len(), 0); + assert_eq!(secret.expose(), ""); + } + + #[test] + fn test_decrypted_secret_clone() { + let original = DecryptedSecret::from_bytes(b"cloneable".to_vec()).unwrap(); + let cloned = original.clone(); + assert_eq!(cloned.expose(), "cloneable"); + assert_eq!(cloned.len(), original.len()); + } + + #[test] + fn test_secret_debug_redacts_fields() { + use chrono::Utc; + use uuid::Uuid; + let secret = crate::secrets::types::Secret { + id: Uuid::nil(), + user_id: "user1".to_string(), + name: "test_key".to_string(), + encrypted_value: vec![1, 2, 3], + key_salt: vec![4, 5, 6], + provider: Some("aws".to_string()), + expires_at: None, + last_used_at: None, + usage_count: 5, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + let debug = format!("{:?}", secret); + assert!(debug.contains("REDACTED")); + assert!(!debug.contains("[1, 2, 3]")); + assert!(!debug.contains("[4, 5, 6]")); + assert!(debug.contains("test_key")); + } + + #[test] + fn test_secret_error_display() { + use crate::secrets::types::SecretError; + assert_eq!( + SecretError::NotFound("foo".into()).to_string(), + "Secret not found: foo" + ); + assert_eq!(SecretError::Expired.to_string(), "Secret has expired"); + assert_eq!( + SecretError::InvalidMasterKey.to_string(), + "Invalid master key" + ); + assert_eq!( + SecretError::InvalidUtf8.to_string(), + "Secret value is not valid UTF-8" + ); + assert_eq!( + SecretError::AccessDenied.to_string(), + "Secret access denied for tool" + ); + } } diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index d9655be5..319db296 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -3258,8 +3258,10 @@ mod tests { #[tokio::test] async fn test_discover_wasm_channels_nonexistent_dir() { - let channels = - discover_wasm_channels(std::path::Path::new("/tmp/ironclaw_nonexistent_dir")).await; + let channels = discover_wasm_channels( + &std::env::temp_dir().join("ironclaw_nonexistent_dir_abcxyz123"), + ) + .await; assert!(channels.is_empty()); } diff --git a/src/tools/builder/core.rs b/src/tools/builder/core.rs index 415c0955..54a179a7 100644 --- a/src/tools/builder/core.rs +++ b/src/tools/builder/core.rs @@ -1026,24 +1026,384 @@ impl Tool for BuildSoftwareTool { #[cfg(test)] mod tests { - use super::*; + use crate::tools::builder::core::*; #[test] - fn test_language_extensions() { + fn test_language_extension_all_variants() { assert_eq!(Language::Rust.extension(), "rs"); assert_eq!(Language::Python.extension(), "py"); assert_eq!(Language::TypeScript.extension(), "ts"); + assert_eq!(Language::JavaScript.extension(), "js"); + assert_eq!(Language::Go.extension(), "go"); + assert_eq!(Language::Bash.extension(), "sh"); } #[test] - fn test_build_commands() { - assert!(Language::Rust.build_command("/tmp/project").is_some()); - assert!(Language::Python.build_command("/tmp/project").is_none()); + fn test_language_build_command_compiled_returns_some() { + let dir = "/tmp/project"; + let rust_cmd = Language::Rust.build_command(dir); + assert!(rust_cmd.is_some()); + assert!(rust_cmd.unwrap().contains("cargo build")); + + let ts_cmd = Language::TypeScript.build_command(dir); + assert!(ts_cmd.is_some()); + assert!(ts_cmd.unwrap().contains("npm run build")); + + let go_cmd = Language::Go.build_command(dir); + assert!(go_cmd.is_some()); + assert!(go_cmd.unwrap().contains("go build")); } #[test] - fn test_software_type_serialization() { - let json = serde_json::to_string(&SoftwareType::WasmTool).unwrap(); - assert_eq!(json, "\"wasm_tool\""); + fn test_language_build_command_interpreted_returns_none() { + let dir = "/tmp/project"; + assert!(Language::Python.build_command(dir).is_none()); + assert!(Language::JavaScript.build_command(dir).is_none()); + assert!(Language::Bash.build_command(dir).is_none()); + } + + #[test] + fn test_language_build_command_includes_project_dir() { + let dir = "/home/user/my_project"; + for lang in [Language::Rust, Language::TypeScript, Language::Go] { + let cmd = lang.build_command(dir); + assert!( + cmd.as_ref().unwrap().contains(dir), + "{:?} build command should contain project dir", + lang + ); + } + } + + #[test] + fn test_language_test_command_all_variants_non_empty() { + let dir = "/tmp/project"; + let all_languages = [ + Language::Rust, + Language::Python, + Language::TypeScript, + Language::JavaScript, + Language::Go, + Language::Bash, + ]; + for lang in all_languages { + let cmd = lang.test_command(dir); + assert!( + !cmd.is_empty(), + "{:?} test command should not be empty", + lang + ); + assert!( + cmd.contains(dir), + "{:?} test command should contain project dir", + lang + ); + } + } + + #[test] + fn test_language_test_command_specific_tools() { + let dir = "/tmp/p"; + assert!(Language::Rust.test_command(dir).contains("cargo test")); + assert!(Language::Python.test_command(dir).contains("pytest")); + assert!(Language::TypeScript.test_command(dir).contains("npm test")); + assert!(Language::JavaScript.test_command(dir).contains("npm test")); + assert!(Language::Go.test_command(dir).contains("go test")); + assert!(Language::Bash.test_command(dir).contains("shellcheck")); + } + + #[test] + fn test_software_type_serde_roundtrip() { + let variants = [ + SoftwareType::WasmTool, + SoftwareType::CliBinary, + SoftwareType::Library, + SoftwareType::Script, + SoftwareType::WebService, + ]; + let expected_strings = [ + "\"wasm_tool\"", + "\"cli_binary\"", + "\"library\"", + "\"script\"", + "\"web_service\"", + ]; + for (variant, expected) in variants.iter().zip(expected_strings.iter()) { + let json = serde_json::to_string(variant).unwrap(); + assert_eq!(&json, expected, "serialization mismatch for {:?}", variant); + let deserialized: SoftwareType = serde_json::from_str(&json).unwrap(); + assert_eq!( + &deserialized, variant, + "roundtrip mismatch for {:?}", + variant + ); + } + } + + #[test] + fn test_language_serde_roundtrip() { + let variants = [ + Language::Rust, + Language::Python, + Language::TypeScript, + Language::JavaScript, + Language::Go, + Language::Bash, + ]; + let expected_strings = [ + "\"rust\"", + "\"python\"", + "\"type_script\"", + "\"java_script\"", + "\"go\"", + "\"bash\"", + ]; + for (variant, expected) in variants.iter().zip(expected_strings.iter()) { + let json = serde_json::to_string(variant).unwrap(); + assert_eq!(&json, expected, "serialization mismatch for {:?}", variant); + let deserialized: Language = serde_json::from_str(&json).unwrap(); + assert_eq!( + &deserialized, variant, + "roundtrip mismatch for {:?}", + variant + ); + } + } + + #[test] + fn test_build_requirement_serde_roundtrip() { + let req = BuildRequirement { + name: "my_tool".into(), + description: "A tool that does stuff".into(), + software_type: SoftwareType::WasmTool, + language: Language::Rust, + input_spec: Some("JSON object with 'query' field".into()), + output_spec: Some("JSON object with 'result' field".into()), + dependencies: vec!["serde".into(), "reqwest".into()], + capabilities: vec!["http".into(), "workspace".into()], + }; + let json = serde_json::to_string(&req).unwrap(); + let deserialized: BuildRequirement = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.name, req.name); + assert_eq!(deserialized.description, req.description); + assert_eq!(deserialized.software_type, req.software_type); + assert_eq!(deserialized.language, req.language); + assert_eq!(deserialized.input_spec, req.input_spec); + assert_eq!(deserialized.output_spec, req.output_spec); + assert_eq!(deserialized.dependencies, req.dependencies); + assert_eq!(deserialized.capabilities, req.capabilities); + } + + #[test] + fn test_build_requirement_serde_optional_fields_none() { + let req = BuildRequirement { + name: "minimal".into(), + description: "Bare minimum".into(), + software_type: SoftwareType::Script, + language: Language::Bash, + input_spec: None, + output_spec: None, + dependencies: vec![], + capabilities: vec![], + }; + let json = serde_json::to_string(&req).unwrap(); + let deserialized: BuildRequirement = serde_json::from_str(&json).unwrap(); + assert!(deserialized.input_spec.is_none()); + assert!(deserialized.output_spec.is_none()); + assert!(deserialized.dependencies.is_empty()); + assert!(deserialized.capabilities.is_empty()); + } + + #[test] + fn test_builder_config_default_sensible_values() { + let config = BuilderConfig::default(); + assert!(config.max_iterations > 0, "max_iterations must be positive"); + assert!(!config.timeout.is_zero(), "timeout must be non-zero"); + assert!( + config.timeout.as_secs() >= 60, + "timeout should be at least 60 seconds" + ); + assert!(config.validate_wasm, "validate_wasm should default to true"); + assert!(config.run_tests, "run_tests should default to true"); + assert!(config.auto_register, "auto_register should default to true"); + assert!( + !config.cleanup_on_failure, + "cleanup_on_failure should default to false for debugging" + ); + assert!( + config.wasm_output_dir.is_none(), + "wasm_output_dir should default to None" + ); + assert!( + config + .build_dir + .to_string_lossy() + .contains("ironclaw-builds"), + "build_dir should contain 'ironclaw-builds'" + ); + } + + #[test] + fn test_build_phase_serde_roundtrip() { + let variants = [ + BuildPhase::Analyzing, + BuildPhase::Scaffolding, + BuildPhase::Implementing, + BuildPhase::Building, + BuildPhase::Testing, + BuildPhase::Fixing, + BuildPhase::Validating, + BuildPhase::Registering, + BuildPhase::Packaging, + BuildPhase::Complete, + BuildPhase::Failed, + ]; + for variant in &variants { + let json = serde_json::to_string(variant).unwrap(); + let deserialized: BuildPhase = serde_json::from_str(&json).unwrap(); + assert_eq!( + &deserialized, variant, + "roundtrip mismatch for {:?}", + variant + ); + } + } + + #[test] + fn test_build_result_serde_success() { + let result = BuildResult { + build_id: Uuid::nil(), + requirement: BuildRequirement { + name: "test_tool".into(), + description: "test".into(), + software_type: SoftwareType::WasmTool, + language: Language::Rust, + input_spec: None, + output_spec: None, + dependencies: vec![], + capabilities: vec![], + }, + artifact_path: PathBuf::from("/tmp/test.wasm"), + logs: vec![], + success: true, + error: None, + started_at: Utc::now(), + completed_at: Utc::now(), + iterations: 3, + validation_warnings: vec![], + tests_passed: 5, + tests_failed: 0, + registered: true, + }; + let json = serde_json::to_string(&result).unwrap(); + let deserialized: BuildResult = serde_json::from_str(&json).unwrap(); + assert!(deserialized.success); + assert!(deserialized.error.is_none()); + assert_eq!(deserialized.iterations, 3); + assert_eq!(deserialized.tests_passed, 5); + assert_eq!(deserialized.tests_failed, 0); + assert!(deserialized.registered); + } + + #[test] + fn test_build_result_serde_failure() { + let result = BuildResult { + build_id: Uuid::nil(), + requirement: BuildRequirement { + name: "broken".into(), + description: "fails".into(), + software_type: SoftwareType::CliBinary, + language: Language::Go, + input_spec: None, + output_spec: None, + dependencies: vec![], + capabilities: vec![], + }, + artifact_path: PathBuf::from("/tmp/broken"), + logs: vec![], + success: false, + error: Some("compilation error: undefined reference".into()), + started_at: Utc::now(), + completed_at: Utc::now(), + iterations: 10, + validation_warnings: vec!["missing export".into()], + tests_passed: 2, + tests_failed: 3, + registered: false, + }; + let json = serde_json::to_string(&result).unwrap(); + let deserialized: BuildResult = serde_json::from_str(&json).unwrap(); + assert!(!deserialized.success); + assert_eq!( + deserialized.error.as_deref(), + Some("compilation error: undefined reference") + ); + assert_eq!(deserialized.iterations, 10); + assert_eq!(deserialized.validation_warnings.len(), 1); + assert_eq!(deserialized.tests_passed, 2); + assert_eq!(deserialized.tests_failed, 3); + assert!(!deserialized.registered); + } + + #[test] + fn test_build_result_default_fields_from_json() { + // Verify #[serde(default)] fields can be omitted in JSON + let json = serde_json::json!({ + "build_id": "00000000-0000-0000-0000-000000000000", + "requirement": { + "name": "x", + "description": "y", + "software_type": "script", + "language": "bash", + "input_spec": null, + "output_spec": null, + "dependencies": [], + "capabilities": [] + }, + "artifact_path": "/tmp/x.sh", + "logs": [], + "success": true, + "error": null, + "started_at": "2025-01-01T00:00:00Z", + "completed_at": "2025-01-01T00:01:00Z", + "iterations": 1 + }); + let result: BuildResult = serde_json::from_value(json).unwrap(); + assert_eq!(result.validation_warnings, Vec::::new()); + assert_eq!(result.tests_passed, 0); + assert_eq!(result.tests_failed, 0); + assert!(!result.registered); + } + + #[test] + fn test_build_log_serde_roundtrip() { + let log = BuildLog { + timestamp: Utc::now(), + phase: BuildPhase::Building, + message: "Running cargo build".into(), + details: Some("cargo build --release 2>&1".into()), + }; + let json = serde_json::to_string(&log).unwrap(); + let deserialized: BuildLog = serde_json::from_str(&json).unwrap(); + assert_eq!(deserialized.phase, BuildPhase::Building); + assert_eq!(deserialized.message, "Running cargo build"); + assert_eq!( + deserialized.details.as_deref(), + Some("cargo build --release 2>&1") + ); + } + + #[test] + fn test_build_log_serde_details_none() { + let log = BuildLog { + timestamp: Utc::now(), + phase: BuildPhase::Complete, + message: "Done".into(), + details: None, + }; + let json = serde_json::to_string(&log).unwrap(); + let deserialized: BuildLog = serde_json::from_str(&json).unwrap(); + assert!(deserialized.details.is_none()); + assert_eq!(deserialized.phase, BuildPhase::Complete); } } diff --git a/src/tools/builder/templates.rs b/src/tools/builder/templates.rs index 347a8be1..67c2cb5b 100644 --- a/src/tools/builder/templates.rs +++ b/src/tools/builder/templates.rs @@ -498,4 +498,163 @@ mod tests { assert_eq!(template.name, "WASM HTTP Tool"); assert!(!template.files.is_empty()); } + + #[test] + fn test_render_no_variables() { + let engine = TemplateEngine::new(); + let input = "Hello, world! No placeholders here."; + assert_eq!(engine.render(input), input); + } + + #[test] + fn test_render_variable_not_found() { + let mut engine = TemplateEngine::new(); + engine.set("name", "ironclaw"); + let input = "Name: {{name}}, Missing: {{missing}}"; + assert_eq!(engine.render(input), "Name: ironclaw, Missing: {{missing}}"); + } + + #[test] + fn test_render_multiple_replacements_of_same_variable() { + let mut engine = TemplateEngine::new(); + engine.set("x", "42"); + assert_eq!(engine.render("{{x}} + {{x}} = 2*{{x}}"), "42 + 42 = 2*42"); + } + + #[test] + fn test_set_overwrites_existing_variable() { + let mut engine = TemplateEngine::new(); + engine.set("color", "red"); + assert_eq!(engine.render("{{color}}"), "red"); + engine.set("color", "blue"); + assert_eq!(engine.render("{{color}}"), "blue"); + } + + #[test] + fn test_render_template_all_files() { + let mut engine = TemplateEngine::new(); + engine.set("name", "my_tool"); + engine.set("description", "does stuff"); + + let template = Template::get(TemplateType::CliBinary); + let rendered = engine.render_template(&template); + + assert_eq!(rendered.len(), template.files.len()); + // Paths should have variables substituted + for (path, _content) in &rendered { + assert!(!path.contains("{{name}}")); + } + // Content should have variables substituted + for (_path, content) in &rendered { + assert!(!content.contains("{{name}}")); + assert!(!content.contains("{{description}}")); + } + } + + #[test] + fn test_all_template_types_return_non_empty() { + let all_types = [ + TemplateType::WasmHttpTool, + TemplateType::WasmTransformTool, + TemplateType::WasmComputeTool, + TemplateType::CliBinary, + TemplateType::PythonScript, + TemplateType::BashScript, + ]; + for tt in all_types { + let t = Template::get(tt); + assert!(!t.name.is_empty(), "{:?} has empty name", tt); + assert!(!t.description.is_empty(), "{:?} has empty description", tt); + assert!(!t.files.is_empty(), "{:?} has no files", tt); + for f in &t.files { + assert!( + !f.content.is_empty(), + "{:?} file {:?} has empty content", + tt, + f.path + ); + } + } + } + + #[test] + fn test_template_type_serde_roundtrip() { + let all_types = [ + TemplateType::WasmHttpTool, + TemplateType::WasmTransformTool, + TemplateType::WasmComputeTool, + TemplateType::CliBinary, + TemplateType::PythonScript, + TemplateType::BashScript, + ]; + for tt in all_types { + let json = serde_json::to_string(&tt).unwrap(); + let back: TemplateType = serde_json::from_str(&json).unwrap(); + assert_eq!(back, tt, "roundtrip failed for {:?} (json: {})", tt, json); + } + } + + #[test] + fn test_each_template_has_at_least_one_required_file() { + let all_types = [ + TemplateType::WasmHttpTool, + TemplateType::WasmTransformTool, + TemplateType::WasmComputeTool, + TemplateType::CliBinary, + TemplateType::PythonScript, + TemplateType::BashScript, + ]; + for tt in all_types { + let t = Template::get(tt); + let required_count = t.files.iter().filter(|f| f.is_required).count(); + assert!(required_count >= 1, "{:?} has no required files", tt); + } + } + + #[test] + fn test_template_file_extensions() { + // WASM and CLI templates should have Cargo.toml and .rs files + for tt in [ + TemplateType::WasmHttpTool, + TemplateType::WasmTransformTool, + TemplateType::WasmComputeTool, + TemplateType::CliBinary, + ] { + let t = Template::get(tt); + let paths: Vec<&str> = t.files.iter().map(|f| f.path).collect(); + assert!( + paths.iter().any(|p| p.ends_with("Cargo.toml")), + "{:?} missing Cargo.toml", + tt + ); + assert!( + paths.iter().any(|p| p.ends_with(".rs")), + "{:?} missing .rs file", + tt + ); + } + + // Python template should have a .py file + let py = Template::get(TemplateType::PythonScript); + assert!(py.files.iter().any(|f| f.path.ends_with(".py"))); + + // Bash template should have a .sh file + let bash = Template::get(TemplateType::BashScript); + assert!(bash.files.iter().any(|f| f.path.ends_with(".sh"))); + } + + #[test] + fn test_python_and_bash_templates_have_name_in_path() { + let py = Template::get(TemplateType::PythonScript); + assert!( + py.files.iter().any(|f| f.path.contains("{{name}}")), + "PythonScript template should have {{{{name}}}} in a file path" + ); + + let bash = Template::get(TemplateType::BashScript); + assert!( + bash.files.iter().any(|f| f.path.contains("{{name}}")), + "BashScript template should have {{{{name}}}} in a file path" + ); + } } diff --git a/src/tools/builder/validation.rs b/src/tools/builder/validation.rs index d27ea74a..e04b23b4 100644 --- a/src/tools/builder/validation.rs +++ b/src/tools/builder/validation.rs @@ -315,5 +315,163 @@ mod tests { ); } - // Note: Full WASM parsing tests would require actual WASM binaries + #[test] + fn test_validate_bytes_invalid_bytes() { + let validator = WasmValidator::new(); + let garbage = b"this is not a wasm module at all"; + let result = validator.validate_bytes(garbage).unwrap(); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::InvalidModule(_))) + ); + } + + #[test] + fn test_validate_bytes_empty() { + let validator = WasmValidator::new(); + let result = validator.validate_bytes(b"").unwrap(); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::InvalidModule(_))) + ); + } + + #[test] + fn test_validate_bytes_minimal_wasm_missing_run_export() { + let validator = WasmValidator::new(); + // Minimal valid WASM: magic number + version + let minimal_wasm = b"\x00asm\x01\x00\x00\x00"; + let result = validator.validate_bytes(minimal_wasm).unwrap(); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::MissingExport(name) if name == "run")) + ); + assert_eq!(result.size_bytes, 8); + } + + #[test] + fn test_validation_result_is_valid_when_no_errors() { + let result = ValidationResult { + is_valid: true, + errors: vec![], + warnings: vec!["some warning".to_string()], + exports: vec![], + imports: vec![], + size_bytes: 0, + }; + assert!(result.is_valid); + assert!(result.errors.is_empty()); + } + + #[test] + fn test_validation_result_is_invalid_when_errors_present() { + let result = ValidationResult { + is_valid: false, + errors: vec![ValidationError::MissingExport("run".to_string())], + warnings: vec![], + exports: vec![], + imports: vec![], + size_bytes: 0, + }; + assert!(!result.is_valid); + assert_eq!(result.errors.len(), 1); + } + + #[test] + fn test_validation_error_display() { + let io_err = + ValidationError::IoError(std::io::Error::new(std::io::ErrorKind::NotFound, "gone")); + assert!(io_err.to_string().contains("Failed to read WASM file")); + + let invalid = ValidationError::InvalidModule("bad magic".to_string()); + assert!(invalid.to_string().contains("Invalid WASM module")); + assert!(invalid.to_string().contains("bad magic")); + + let missing = ValidationError::MissingExport("run".to_string()); + assert!(missing.to_string().contains("Missing required export")); + assert!(missing.to_string().contains("run")); + + let sig = ValidationError::InvalidSignature { + name: "run".to_string(), + expected: "() -> i32".to_string(), + actual: "() -> ()".to_string(), + }; + assert!(sig.to_string().contains("Invalid export signature")); + assert!(sig.to_string().contains("run")); + + let disallowed = ValidationError::DisallowedImport { + module: "evil".to_string(), + name: "hack".to_string(), + }; + assert!(disallowed.to_string().contains("disallowed import")); + assert!(disallowed.to_string().contains("evil::hack")); + + let too_large = ValidationError::TooLarge { + size: 200, + max: 100, + }; + assert!(too_large.to_string().contains("200")); + assert!(too_large.to_string().contains("100")); + + let other = ValidationError::Other("something broke".to_string()); + assert!(other.to_string().contains("something broke")); + } + + #[test] + fn test_export_kind_equality() { + assert_eq!(ExportKind::Function, ExportKind::Function); + assert_eq!(ExportKind::Memory, ExportKind::Memory); + assert_eq!(ExportKind::Table, ExportKind::Table); + assert_eq!(ExportKind::Global, ExportKind::Global); + assert_ne!(ExportKind::Function, ExportKind::Memory); + assert_ne!(ExportKind::Table, ExportKind::Global); + } + + #[test] + fn test_import_kind_equality() { + assert_eq!(ImportKind::Function, ImportKind::Function); + assert_eq!(ImportKind::Memory, ImportKind::Memory); + assert_eq!(ImportKind::Table, ImportKind::Table); + assert_eq!(ImportKind::Global, ImportKind::Global); + assert_ne!(ImportKind::Function, ImportKind::Global); + assert_ne!(ImportKind::Memory, ImportKind::Table); + } + + #[test] + fn test_validate_bytes_exceeds_max_size() { + let validator = WasmValidator::new().with_max_size(4); + // 8 bytes, over the 4-byte limit + let minimal_wasm = b"\x00asm\x01\x00\x00\x00"; + let result = validator.validate_bytes(minimal_wasm).unwrap(); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::TooLarge { size: 8, max: 4 })) + ); + } + + #[test] + fn test_with_max_size_then_validate_over_limit() { + let validator = WasmValidator::new().with_max_size(16); + let oversized = vec![0u8; 32]; + let result = validator.validate_bytes(&oversized).unwrap(); + assert!(!result.is_valid); + assert!( + result + .errors + .iter() + .any(|e| matches!(e, ValidationError::TooLarge { size: 32, max: 16 })) + ); + } } diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs index 6943d935..bb0d9780 100644 --- a/src/tools/builtin/extension_tools.rs +++ b/src/tools/builtin/extension_tools.rs @@ -671,8 +671,8 @@ mod tests { Arc::new(ToolRegistry::new()), None, None, - std::path::PathBuf::from("/tmp/ironclaw-test-tools"), - std::path::PathBuf::from("/tmp/ironclaw-test-channels"), + std::env::temp_dir().join("ironclaw-test-tools"), + std::env::temp_dir().join("ironclaw-test-channels"), None, "test".to_string(), None, diff --git a/src/tools/mcp/auth.rs b/src/tools/mcp/auth.rs index 0b26b258..0f7cd3a5 100644 --- a/src/tools/mcp/auth.rs +++ b/src/tools/mcp/auth.rs @@ -858,4 +858,310 @@ mod tests { assert!(url.contains("owner=user")); assert!(url.contains("state=abc123")); } + + #[test] + fn test_pkce_challenge_s256_is_correct_sha256() { + let pkce = PkceChallenge::generate(); + + // Recompute the S256 challenge from scratch and compare. + let mut hasher = Sha256::new(); + hasher.update(pkce.verifier.as_bytes()); + let expected = URL_SAFE_NO_PAD.encode(hasher.finalize()); + + assert_eq!(pkce.challenge, expected); + } + + #[test] + fn test_build_authorization_url_empty_scopes_no_scope_param() { + let url = build_authorization_url( + "https://auth.example.com/authorize", + "client-123", + "http://localhost:9876/callback", + &[], + None, + &HashMap::new(), + ); + + // With no scopes, the URL must not contain a scope parameter at all. + assert!(!url.contains("scope=")); + } + + #[test] + fn test_build_authorization_url_special_characters_are_encoded() { + let url = build_authorization_url( + "https://auth.example.com/authorize", + "client id&evil=true", + "http://localhost:9876/call back?x=1", + &[], + None, + &HashMap::new(), + ); + + // Spaces and ampersands in client_id must be percent-encoded. + assert!(url.contains("client_id=client%20id%26evil%3Dtrue")); + // Spaces and question marks in redirect_uri must be percent-encoded. + assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A9876%2Fcall%20back%3Fx%3D1")); + } + + #[test] + fn test_protected_resource_metadata_serde_roundtrip_full() { + let meta = ProtectedResourceMetadata { + resource: "https://mcp.example.com".to_string(), + authorization_servers: vec![ + "https://auth1.example.com".to_string(), + "https://auth2.example.com".to_string(), + ], + scopes_supported: vec!["read".to_string(), "write".to_string()], + }; + + let json = serde_json::to_string(&meta).unwrap(); + let deserialized: ProtectedResourceMetadata = serde_json::from_str(&json).unwrap(); + + assert_eq!(deserialized.resource, meta.resource); + assert_eq!( + deserialized.authorization_servers, + meta.authorization_servers + ); + assert_eq!(deserialized.scopes_supported, meta.scopes_supported); + } + + #[test] + fn test_protected_resource_metadata_serde_roundtrip_minimal() { + // Only required field, optional vecs should default to empty. + let json = r#"{"resource": "https://mcp.example.com"}"#; + let meta: ProtectedResourceMetadata = serde_json::from_str(json).unwrap(); + + assert_eq!(meta.resource, "https://mcp.example.com"); + assert!(meta.authorization_servers.is_empty()); + assert!(meta.scopes_supported.is_empty()); + } + + #[test] + fn test_authorization_server_metadata_serde_roundtrip_all_fields() { + let meta = AuthorizationServerMetadata { + issuer: "https://auth.example.com".to_string(), + authorization_endpoint: "https://auth.example.com/authorize".to_string(), + token_endpoint: "https://auth.example.com/token".to_string(), + registration_endpoint: Some("https://auth.example.com/register".to_string()), + response_types_supported: vec!["code".to_string()], + grant_types_supported: vec![ + "authorization_code".to_string(), + "refresh_token".to_string(), + ], + code_challenge_methods_supported: vec!["S256".to_string()], + scopes_supported: vec!["openid".to_string(), "profile".to_string()], + }; + + let json = serde_json::to_string(&meta).unwrap(); + let rt: AuthorizationServerMetadata = serde_json::from_str(&json).unwrap(); + + assert_eq!(rt.issuer, meta.issuer); + assert_eq!(rt.authorization_endpoint, meta.authorization_endpoint); + assert_eq!(rt.token_endpoint, meta.token_endpoint); + assert_eq!(rt.registration_endpoint, meta.registration_endpoint); + assert_eq!(rt.response_types_supported, meta.response_types_supported); + assert_eq!(rt.grant_types_supported, meta.grant_types_supported); + assert_eq!( + rt.code_challenge_methods_supported, + meta.code_challenge_methods_supported + ); + assert_eq!(rt.scopes_supported, meta.scopes_supported); + } + + #[test] + fn test_authorization_server_metadata_serde_without_registration() { + let json = r#"{ + "issuer": "https://auth.example.com", + "authorization_endpoint": "https://auth.example.com/authorize", + "token_endpoint": "https://auth.example.com/token" + }"#; + + let meta: AuthorizationServerMetadata = serde_json::from_str(json).unwrap(); + assert_eq!(meta.issuer, "https://auth.example.com"); + assert!(meta.registration_endpoint.is_none()); + assert!(meta.response_types_supported.is_empty()); + assert!(meta.grant_types_supported.is_empty()); + } + + #[test] + fn test_client_registration_request_serialization() { + let req = ClientRegistrationRequest { + client_name: "IronClaw".to_string(), + redirect_uris: vec!["http://localhost:9876/callback".to_string()], + grant_types: vec![ + "authorization_code".to_string(), + "refresh_token".to_string(), + ], + response_types: vec!["code".to_string()], + token_endpoint_auth_method: "none".to_string(), + }; + + let value: serde_json::Value = serde_json::to_value(&req).unwrap(); + + assert_eq!(value["client_name"], "IronClaw"); + assert_eq!(value["redirect_uris"][0], "http://localhost:9876/callback"); + assert_eq!(value["grant_types"][0], "authorization_code"); + assert_eq!(value["grant_types"][1], "refresh_token"); + assert_eq!(value["response_types"][0], "code"); + assert_eq!(value["token_endpoint_auth_method"], "none"); + } + + #[test] + fn test_client_registration_response_deserialization_full() { + let json = r#"{ + "client_id": "abc-123", + "client_secret": "s3cret", + "client_secret_expires_at": 1700000000, + "registration_access_token": "reg-tok", + "registration_client_uri": "https://auth.example.com/register/abc-123" + }"#; + + let resp: ClientRegistrationResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(resp.client_id, "abc-123"); + assert_eq!(resp.client_secret.as_deref(), Some("s3cret")); + assert_eq!(resp.client_secret_expires_at, Some(1700000000)); + assert_eq!(resp.registration_access_token.as_deref(), Some("reg-tok")); + assert_eq!( + resp.registration_client_uri.as_deref(), + Some("https://auth.example.com/register/abc-123") + ); + } + + #[test] + fn test_client_registration_response_deserialization_minimal() { + let json = r#"{"client_id": "xyz-789"}"#; + + let resp: ClientRegistrationResponse = serde_json::from_str(json).unwrap(); + + assert_eq!(resp.client_id, "xyz-789"); + assert!(resp.client_secret.is_none()); + assert!(resp.client_secret_expires_at.is_none()); + assert!(resp.registration_access_token.is_none()); + assert!(resp.registration_client_uri.is_none()); + } + + #[test] + fn test_access_token_construction() { + let token = AccessToken { + access_token: "at-abc".to_string(), + token_type: "Bearer".to_string(), + expires_in: Some(3600), + refresh_token: Some("rt-xyz".to_string()), + scope: Some("read write".to_string()), + }; + + assert_eq!(token.access_token, "at-abc"); + assert_eq!(token.token_type, "Bearer"); + assert_eq!(token.expires_in, Some(3600)); + assert_eq!(token.refresh_token.as_deref(), Some("rt-xyz")); + assert_eq!(token.scope.as_deref(), Some("read write")); + + // Also test with no optional fields. + let minimal = AccessToken { + access_token: "tok".to_string(), + token_type: "bearer".to_string(), + expires_in: None, + refresh_token: None, + scope: None, + }; + assert!(minimal.expires_in.is_none()); + assert!(minimal.refresh_token.is_none()); + assert!(minimal.scope.is_none()); + } + + #[test] + fn test_token_response_to_access_token_pattern() { + // TokenResponse is private, but we can test the conversion pattern + // by deserializing JSON the same way exchange_code_for_token does. + let json = r#"{ + "access_token": "eyJ-token", + "token_type": "Bearer", + "expires_in": 7200, + "refresh_token": "refresh-me", + "scope": "openid profile" + }"#; + + // Deserialize via the same struct path the production code uses. + let resp: serde_json::Value = serde_json::from_str(json).unwrap(); + let token = AccessToken { + access_token: resp["access_token"].as_str().unwrap().to_string(), + token_type: resp["token_type"].as_str().unwrap().to_string(), + expires_in: resp["expires_in"].as_u64(), + refresh_token: resp["refresh_token"].as_str().map(String::from), + scope: resp["scope"].as_str().map(String::from), + }; + + assert_eq!(token.access_token, "eyJ-token"); + assert_eq!(token.token_type, "Bearer"); + assert_eq!(token.expires_in, Some(7200)); + assert_eq!(token.refresh_token.as_deref(), Some("refresh-me")); + assert_eq!(token.scope.as_deref(), Some("openid profile")); + + // Without optional fields. + let minimal_json = r#"{"access_token": "tok", "token_type": "bearer"}"#; + let resp: serde_json::Value = serde_json::from_str(minimal_json).unwrap(); + let token = AccessToken { + access_token: resp["access_token"].as_str().unwrap().to_string(), + token_type: resp["token_type"].as_str().unwrap().to_string(), + expires_in: resp["expires_in"].as_u64(), + refresh_token: resp["refresh_token"].as_str().map(String::from), + scope: resp["scope"].as_str().map(String::from), + }; + assert!(token.expires_in.is_none()); + assert!(token.refresh_token.is_none()); + assert!(token.scope.is_none()); + } + + #[test] + fn test_auth_error_display_strings() { + let cases: Vec<(AuthError, &str)> = vec![ + ( + AuthError::NotSupported, + "Server does not support OAuth authorization", + ), + ( + AuthError::DiscoveryFailed("timeout".to_string()), + "Failed to discover authorization endpoints: timeout", + ), + ( + AuthError::AuthorizationDenied, + "Authorization denied by user", + ), + ( + AuthError::TokenExchangeFailed("bad code".to_string()), + "Token exchange failed: bad code", + ), + ( + AuthError::RefreshFailed("expired".to_string()), + "Token expired and refresh failed: expired", + ), + (AuthError::NoToken, "No access token available"), + ( + AuthError::Timeout, + "Timeout waiting for authorization callback", + ), + ( + AuthError::PortUnavailable, + "Could not bind to callback port", + ), + ( + AuthError::Http("connection refused".to_string()), + "HTTP error: connection refused", + ), + ( + AuthError::Secrets("decrypt failed".to_string()), + "Secrets error: decrypt failed", + ), + ]; + + for (error, expected) in cases { + let display = error.to_string(); + assert_eq!( + display, expected, + "AuthError display mismatch for {:?}", + error + ); + } + } } diff --git a/src/tools/mcp/client.rs b/src/tools/mcp/client.rs index b1287611..316851fc 100644 --- a/src/tools/mcp/client.rs +++ b/src/tools/mcp/client.rs @@ -583,4 +583,161 @@ mod tests { assert!(client.session_manager.is_none()); assert!(client.secrets.is_none()); } + + #[test] + fn test_extract_server_name_with_port() { + assert_eq!( + extract_server_name("http://example.com:3000"), + "example_com" + ); + } + + #[test] + fn test_extract_server_name_with_path() { + assert_eq!( + extract_server_name("http://api.server.io/v2/mcp"), + "api_server_io" + ); + } + + #[test] + fn test_extract_server_name_with_query_params() { + assert_eq!( + extract_server_name("http://mcp.example.com/endpoint?token=abc&v=1"), + "mcp_example_com" + ); + } + + #[test] + fn test_extract_server_name_https() { + assert_eq!( + extract_server_name("https://secure.mcp.dev"), + "secure_mcp_dev" + ); + } + + #[test] + fn test_extract_server_name_ip_address() { + assert_eq!( + extract_server_name("http://192.168.1.100:9090/mcp"), + "192_168_1_100" + ); + } + + #[test] + fn test_new_defaults() { + let client = McpClient::new("http://localhost:9999"); + assert_eq!(client.server_url(), "http://localhost:9999"); + assert_eq!(client.server_name(), "localhost"); + assert!(client.session_manager.is_none()); + assert!(client.secrets.is_none()); + assert_eq!(client.user_id, "default"); + } + + #[test] + fn test_new_with_name_uses_custom_name() { + let client = McpClient::new_with_name("my-server", "http://localhost:8080"); + assert_eq!(client.server_name(), "my-server"); + assert_eq!(client.server_url(), "http://localhost:8080"); + assert_eq!(client.user_id, "default"); + assert!(client.session_manager.is_none()); + assert!(client.secrets.is_none()); + } + + #[test] + fn test_server_name_accessor() { + let client = McpClient::new("https://tools.example.org/mcp"); + assert_eq!(client.server_name(), "tools_example_org"); + } + + #[test] + fn test_server_url_accessor() { + let url = "https://tools.example.org/mcp?v=2"; + let client = McpClient::new(url); + assert_eq!(client.server_url(), url); + } + + #[test] + fn test_clone_preserves_fields() { + let client = McpClient::new_with_name("cloned-server", "http://localhost:5555"); + // Bump the request ID a few times + client.next_request_id(); + client.next_request_id(); + + let cloned = client.clone(); + assert_eq!(cloned.server_url(), "http://localhost:5555"); + assert_eq!(cloned.server_name(), "cloned-server"); + assert_eq!(cloned.user_id, "default"); + // The atomic counter value is copied + assert_eq!(cloned.next_id.load(Ordering::SeqCst), 3); + } + + #[tokio::test] + async fn test_clone_resets_tools_cache() { + let client = McpClient::new("http://localhost:5555"); + // The clone implementation resets tools_cache to None + let cloned = client.clone(); + let cache = cloned.tools_cache.read().await; + assert!(cache.is_none()); + } + + #[test] + fn test_next_request_id_monotonically_increasing() { + let client = McpClient::new("http://localhost:1234"); + let id1 = client.next_request_id(); + let id2 = client.next_request_id(); + let id3 = client.next_request_id(); + assert_eq!(id1, 1); + assert_eq!(id2, 2); + assert_eq!(id3, 3); + } + + #[test] + fn test_mcp_tool_requires_approval_destructive() { + use crate::tools::mcp::protocol::{McpTool, McpToolAnnotations}; + + let tool = McpTool { + name: "delete_all".to_string(), + description: "Deletes everything".to_string(), + input_schema: serde_json::json!({"type": "object"}), + annotations: Some(McpToolAnnotations { + destructive_hint: true, + side_effects_hint: false, + read_only_hint: false, + execution_time_hint: None, + }), + }; + assert!(tool.requires_approval()); + } + + #[test] + fn test_mcp_tool_no_approval_when_not_destructive() { + use crate::tools::mcp::protocol::{McpTool, McpToolAnnotations}; + + let tool = McpTool { + name: "read_data".to_string(), + description: "Reads data".to_string(), + input_schema: serde_json::json!({"type": "object"}), + annotations: Some(McpToolAnnotations { + destructive_hint: false, + side_effects_hint: true, + read_only_hint: false, + execution_time_hint: None, + }), + }; + assert!(!tool.requires_approval()); + } + + #[test] + fn test_mcp_tool_no_approval_when_no_annotations() { + use crate::tools::mcp::protocol::McpTool; + + let tool = McpTool { + name: "simple_tool".to_string(), + description: "A simple tool".to_string(), + input_schema: serde_json::json!({"type": "object"}), + annotations: None, + }; + assert!(!tool.requires_approval()); + } } diff --git a/src/tools/mcp/protocol.rs b/src/tools/mcp/protocol.rs index ec1d367d..d5d9f052 100644 --- a/src/tools/mcp/protocol.rs +++ b/src/tools/mcp/protocol.rs @@ -352,6 +352,279 @@ mod tests { assert!(tool.input_schema["properties"].is_object()); } + #[test] + fn test_initialize_request() { + let req = McpRequest::initialize(42); + assert_eq!(req.jsonrpc, "2.0"); + assert_eq!(req.id, 42); + assert_eq!(req.method, "initialize"); + + let params = req.params.expect("initialize must have params"); + assert_eq!(params["protocolVersion"], PROTOCOL_VERSION); + assert!(params["capabilities"].is_object()); + assert!(params["capabilities"]["roots"].is_object()); + assert!(params["capabilities"]["sampling"].is_object()); + assert_eq!(params["clientInfo"]["name"], "ironclaw"); + assert!(params["clientInfo"]["version"].is_string()); + } + + #[test] + fn test_initialized_notification() { + let req = McpRequest::initialized_notification(); + assert_eq!(req.jsonrpc, "2.0"); + assert_eq!(req.method, "notifications/initialized"); + assert!(req.params.is_none()); + } + + #[test] + fn test_call_tool_request() { + let args = serde_json::json!({"query": "rust async"}); + let req = McpRequest::call_tool(7, "search", args.clone()); + assert_eq!(req.id, 7); + assert_eq!(req.method, "tools/call"); + + let params = req.params.expect("call_tool must have params"); + assert_eq!(params["name"], "search"); + assert_eq!(params["arguments"], args); + } + + #[test] + fn test_mcp_response_deserialize_success() { + let json = serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { "tools": [] } + }); + let resp: McpResponse = serde_json::from_value(json).expect("deserialize"); + assert_eq!(resp.id, 1); + assert!(resp.result.is_some()); + assert!(resp.error.is_none()); + } + + #[test] + fn test_mcp_response_deserialize_error() { + let json = serde_json::json!({ + "jsonrpc": "2.0", + "id": 2, + "error": { + "code": -32601, + "message": "Method not found" + } + }); + let resp: McpResponse = serde_json::from_value(json).expect("deserialize"); + assert!(resp.result.is_none()); + let err = resp.error.expect("should have error"); + assert_eq!(err.code, -32601); + assert_eq!(err.message, "Method not found"); + assert!(err.data.is_none()); + } + + #[test] + fn test_mcp_error_roundtrip() { + let err = McpError { + code: -32600, + message: "Invalid Request".to_string(), + data: Some(serde_json::json!({"detail": "missing field"})), + }; + let serialized = serde_json::to_string(&err).expect("serialize"); + let deserialized: McpError = serde_json::from_str(&serialized).expect("deserialize"); + assert_eq!(deserialized.code, err.code); + assert_eq!(deserialized.message, err.message); + assert_eq!(deserialized.data, err.data); + } + + #[test] + fn test_initialize_result_full() { + let json = serde_json::json!({ + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": { "listChanged": true }, + "resources": { "subscribe": true, "listChanged": false }, + "prompts": { "listChanged": true }, + "logging": {} + }, + "serverInfo": { + "name": "test-server", + "version": "1.2.3" + }, + "instructions": "Use this server for testing." + }); + let result: InitializeResult = serde_json::from_value(json).expect("deserialize"); + assert_eq!(result.protocol_version.as_deref(), Some("2024-11-05")); + + let tools_cap = result.capabilities.tools.expect("has tools capability"); + assert!(tools_cap.list_changed); + + let res_cap = result + .capabilities + .resources + .expect("has resources capability"); + assert!(res_cap.subscribe); + assert!(!res_cap.list_changed); + + let prompts_cap = result.capabilities.prompts.expect("has prompts capability"); + assert!(prompts_cap.list_changed); + + assert!(result.capabilities.logging.is_some()); + + let info = result.server_info.expect("has server info"); + assert_eq!(info.name, "test-server"); + assert_eq!(info.version.as_deref(), Some("1.2.3")); + assert_eq!( + result.instructions.as_deref(), + Some("Use this server for testing.") + ); + } + + #[test] + fn test_content_block_as_text() { + let text_block = ContentBlock::Text { + text: "hello".to_string(), + }; + assert_eq!(text_block.as_text(), Some("hello")); + + let image_block = ContentBlock::Image { + data: "base64data".to_string(), + mime_type: "image/png".to_string(), + }; + assert!(image_block.as_text().is_none()); + + let resource_block = ContentBlock::Resource { + uri: "file:///tmp/a.txt".to_string(), + mime_type: Some("text/plain".to_string()), + text: Some("content".to_string()), + }; + assert!(resource_block.as_text().is_none()); + } + + #[test] + fn test_content_block_serde_tagged_union() { + let text_block = ContentBlock::Text { + text: "hi".to_string(), + }; + let json = serde_json::to_value(&text_block).expect("serialize"); + assert_eq!(json["type"], "text"); + assert_eq!(json["text"], "hi"); + + let image_block = ContentBlock::Image { + data: "abc".to_string(), + mime_type: "image/jpeg".to_string(), + }; + let json = serde_json::to_value(&image_block).expect("serialize"); + assert_eq!(json["type"], "image"); + assert_eq!(json["data"], "abc"); + assert_eq!(json["mime_type"], "image/jpeg"); + + let resource_block = ContentBlock::Resource { + uri: "file:///x".to_string(), + mime_type: None, + text: None, + }; + let json = serde_json::to_value(&resource_block).expect("serialize"); + assert_eq!(json["type"], "resource"); + assert_eq!(json["uri"], "file:///x"); + } + + #[test] + fn test_call_tool_result_is_error() { + let success: CallToolResult = serde_json::from_value(serde_json::json!({ + "content": [{"type": "text", "text": "done"}], + "is_error": false + })) + .expect("deserialize"); + assert!(!success.is_error); + assert_eq!(success.content.len(), 1); + + let failure: CallToolResult = serde_json::from_value(serde_json::json!({ + "content": [{"type": "text", "text": "boom"}], + "is_error": true + })) + .expect("deserialize"); + assert!(failure.is_error); + } + + #[test] + fn test_call_tool_result_is_error_defaults_false() { + let result: CallToolResult = serde_json::from_value(serde_json::json!({ + "content": [] + })) + .expect("deserialize"); + assert!(!result.is_error); + } + + #[test] + fn test_requires_approval_with_destructive_hint() { + let tool = McpTool { + name: "delete_all".to_string(), + description: "Deletes everything".to_string(), + input_schema: default_input_schema(), + annotations: Some(McpToolAnnotations { + destructive_hint: true, + ..Default::default() + }), + }; + assert!(tool.requires_approval()); + } + + #[test] + fn test_requires_approval_without_destructive_hint() { + let tool = McpTool { + name: "read_file".to_string(), + description: "Reads a file".to_string(), + input_schema: default_input_schema(), + annotations: Some(McpToolAnnotations { + destructive_hint: false, + read_only_hint: true, + ..Default::default() + }), + }; + assert!(!tool.requires_approval()); + } + + #[test] + fn test_requires_approval_no_annotations() { + let tool = McpTool { + name: "ping".to_string(), + description: "Ping".to_string(), + input_schema: default_input_schema(), + annotations: None, + }; + assert!(!tool.requires_approval()); + } + + #[test] + fn test_mcp_tool_annotations_defaults() { + let annotations = McpToolAnnotations::default(); + assert!(!annotations.destructive_hint); + assert!(!annotations.side_effects_hint); + assert!(!annotations.read_only_hint); + assert!(annotations.execution_time_hint.is_none()); + } + + #[test] + fn test_execution_time_hint_serde() { + // Fast + let json = serde_json::json!("fast"); + let hint: ExecutionTimeHint = serde_json::from_value(json).expect("deserialize fast"); + assert_eq!(hint, ExecutionTimeHint::Fast); + let serialized = serde_json::to_value(hint).expect("serialize fast"); + assert_eq!(serialized, "fast"); + + // Medium + let json = serde_json::json!("medium"); + let hint: ExecutionTimeHint = serde_json::from_value(json).expect("deserialize medium"); + assert_eq!(hint, ExecutionTimeHint::Medium); + let serialized = serde_json::to_value(hint).expect("serialize medium"); + assert_eq!(serialized, "medium"); + + // Slow + let json = serde_json::json!("slow"); + let hint: ExecutionTimeHint = serde_json::from_value(json).expect("deserialize slow"); + assert_eq!(hint, ExecutionTimeHint::Slow); + let serialized = serde_json::to_value(hint).expect("serialize slow"); + assert_eq!(serialized, "slow"); + } + #[test] fn test_mcp_tool_roundtrip_preserves_schema() { // Simulate what list_tools returns from a real MCP server diff --git a/src/tools/mcp/session.rs b/src/tools/mcp/session.rs index 6046b3c9..a59dc33f 100644 --- a/src/tools/mcp/session.rs +++ b/src/tools/mcp/session.rs @@ -283,4 +283,108 @@ mod tests { assert!(servers.contains(&"notion".to_string())); assert!(servers.contains(&"github".to_string())); } + + #[test] + fn test_update_session_id_none_leaves_id_unchanged() { + let mut session = McpSession::new("https://mcp.example.com"); + session.session_id = Some("existing-id".to_string()); + + session.update_session_id(None); + + assert_eq!(session.session_id, Some("existing-id".to_string())); + } + + #[test] + fn test_touch_updates_last_activity() { + let mut session = McpSession::new("https://mcp.example.com"); + // Push last_activity into the past so we can observe the change. + session.last_activity = std::time::Instant::now() - std::time::Duration::from_secs(60); + let before = session.last_activity; + + session.touch(); + + assert!(session.last_activity > before); + } + + #[test] + fn test_with_idle_timeout() { + let manager = McpSessionManager::with_idle_timeout(42); + assert_eq!(manager.max_idle_secs, 42); + } + + #[tokio::test] + async fn test_get_session_id_nonexistent_returns_none() { + let manager = McpSessionManager::new(); + assert!(manager.get_session_id("ghost").await.is_none()); + } + + #[tokio::test] + async fn test_update_session_id_nonexistent_is_noop() { + let manager = McpSessionManager::new(); + // Should not panic or create a session. + manager + .update_session_id("ghost", Some("id".to_string())) + .await; + assert!(manager.active_servers().await.is_empty()); + } + + #[tokio::test] + async fn test_mark_initialized_nonexistent_is_noop() { + let manager = McpSessionManager::new(); + manager.mark_initialized("ghost").await; + assert!(manager.active_servers().await.is_empty()); + } + + #[tokio::test] + async fn test_touch_nonexistent_is_noop() { + let manager = McpSessionManager::new(); + manager.touch("ghost").await; + assert!(manager.active_servers().await.is_empty()); + } + + #[tokio::test] + async fn test_cleanup_stale_removes_only_stale() { + // Use a 5-second idle timeout so we can fake staleness easily. + let manager = McpSessionManager::with_idle_timeout(5); + + manager + .get_or_create("fresh", "https://fresh.example.com") + .await; + manager + .get_or_create("stale1", "https://stale1.example.com") + .await; + manager + .get_or_create("stale2", "https://stale2.example.com") + .await; + + // Push the two stale sessions into the past. + { + let mut sessions = manager.sessions.write().await; + let past = std::time::Instant::now() - std::time::Duration::from_secs(60); + sessions.get_mut("stale1").unwrap().last_activity = past; + sessions.get_mut("stale2").unwrap().last_activity = past; + } + + let removed = manager.cleanup_stale().await; + assert_eq!(removed, 2); + + let remaining = manager.active_servers().await; + assert_eq!(remaining.len(), 1); + assert!(remaining.contains(&"fresh".to_string())); + } + + #[tokio::test] + async fn test_terminate_nonexistent_is_noop() { + let manager = McpSessionManager::new(); + // Should not panic. + manager.terminate("ghost").await; + assert!(manager.active_servers().await.is_empty()); + } + + #[test] + fn test_default_trait_impl() { + let manager = McpSessionManager::default(); + // Default should match new(), which uses 1800s idle timeout. + assert_eq!(manager.max_idle_secs, 1800); + } } diff --git a/tests/e2e_advanced_traces.rs b/tests/e2e_advanced_traces.rs index 54549382..fca097e3 100644 --- a/tests/e2e_advanced_traces.rs +++ b/tests/e2e_advanced_traces.rs @@ -9,7 +9,6 @@ mod support; mod advanced { use std::time::Duration; - use crate::support::cleanup::CleanupGuard; use crate::support::test_rig::TestRigBuilder; use crate::support::trace_llm::LlmTrace; @@ -52,10 +51,12 @@ mod advanced { #[tokio::test] async fn user_steering() { - let _cleanup = CleanupGuard::new().file("/tmp/ironclaw_steer_test.txt"); - let _ = std::fs::remove_file("/tmp/ironclaw_steer_test.txt"); + let tmp = tempfile::tempdir().expect("create temp dir"); + let test_file = tmp.path().join("ironclaw_steer_test.txt"); + + let mut trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap(); + trace.replace_paths("/tmp/ironclaw_steer_test.txt", test_file.to_str().unwrap()); - let trace = LlmTrace::from_file(format!("{FIXTURES}/steering.json")).unwrap(); let rig = TestRigBuilder::new() .with_trace(trace.clone()) .build() @@ -67,8 +68,7 @@ mod advanced { assert!(!all_responses[1].is_empty(), "Turn 2: no response"); // Extra: verify file on disk after steering. - let content = std::fs::read_to_string("/tmp/ironclaw_steer_test.txt") - .expect("steer test file should exist"); + let content = std::fs::read_to_string(&test_file).expect("steer test file should exist"); assert_eq!( content, "goodbye", "File should contain 'goodbye' after steering" @@ -91,10 +91,16 @@ mod advanced { #[tokio::test] async fn tool_error_recovery() { - let _cleanup = CleanupGuard::new().file("/tmp/ironclaw_recovery_test.txt"); - let _ = std::fs::remove_file("/tmp/ironclaw_recovery_test.txt"); + let tmp = tempfile::tempdir().expect("create temp dir"); + let test_file = tmp.path().join("ironclaw_recovery_test.txt"); + + let mut trace = + LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap(); + trace.replace_paths( + "/tmp/ironclaw_recovery_test.txt", + test_file.to_str().unwrap(), + ); - let trace = LlmTrace::from_file(format!("{FIXTURES}/tool_error_recovery.json")).unwrap(); let rig = TestRigBuilder::new().with_trace(trace).build().await; rig.send_message("Write 'recovered successfully' to a file for me.") @@ -112,8 +118,7 @@ mod advanced { ); // The second write should have succeeded on disk. - let content = std::fs::read_to_string("/tmp/ironclaw_recovery_test.txt") - .expect("recovery file should exist"); + let content = std::fs::read_to_string(&test_file).expect("recovery file should exist"); assert_eq!(content, "recovered successfully"); // At least one write should have completed with success=true. @@ -132,18 +137,18 @@ mod advanced { #[tokio::test] async fn long_tool_chain() { - let test_dir = "/tmp/ironclaw_chain_test"; - let _cleanup = CleanupGuard::new().dir(test_dir); - let _ = std::fs::remove_dir_all(test_dir); - std::fs::create_dir_all(test_dir).unwrap(); + let tmp = tempfile::tempdir().expect("create temp dir"); + let test_dir = tmp.path().join("ironclaw_chain_test"); + std::fs::create_dir_all(&test_dir).unwrap(); + + let mut trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap(); + trace.replace_paths("/tmp/ironclaw_chain_test", test_dir.to_str().unwrap()); - let trace = LlmTrace::from_file(format!("{FIXTURES}/long_tool_chain.json")).unwrap(); let rig = TestRigBuilder::new().with_trace(trace).build().await; rig.send_message( - "Create a daily log at /tmp/ironclaw_chain_test/log.md, \ - update it with afternoon activities, write an end-of-day summary, \ - then read both files and give me a report.", + "Create a daily log, update it with afternoon activities, \ + write an end-of-day summary, then read both files and give me a report.", ) .await; let responses = rig.wait_for_responses(1, TIMEOUT).await; @@ -159,16 +164,15 @@ mod advanced { ); // Verify files on disk. - let log = - std::fs::read_to_string(format!("{test_dir}/log.md")).expect("log.md should exist"); + let log = std::fs::read_to_string(test_dir.join("log.md")).expect("log.md should exist"); assert!( log.contains("Afternoon"), "log.md missing Afternoon section" ); assert!(log.contains("PR #42"), "log.md missing PR #42"); - let summary = std::fs::read_to_string(format!("{test_dir}/summary.md")) - .expect("summary.md should exist"); + let summary = + std::fs::read_to_string(test_dir.join("summary.md")).expect("summary.md should exist"); assert!( summary.contains("accomplishments"), "summary.md missing accomplishments" diff --git a/tests/e2e_metrics_test.rs b/tests/e2e_metrics_test.rs index 5af612c3..15786cc8 100644 --- a/tests/e2e_metrics_test.rs +++ b/tests/e2e_metrics_test.rs @@ -11,18 +11,10 @@ mod tests { use std::time::Duration; use crate::support::assertions::assert_all_tools_succeeded; - use crate::support::cleanup::CleanupGuard; use crate::support::metrics::{RunResult, ScenarioResult, compare_runs}; use crate::support::test_rig::TestRigBuilder; use crate::support::trace_llm::LlmTrace; - const TEST_DIR: &str = "/tmp/ironclaw_metrics_test"; - - fn setup_test_dir() { - let _ = std::fs::remove_dir_all(TEST_DIR); - std::fs::create_dir_all(TEST_DIR).expect("failed to create test directory"); - } - /// Verify that metrics are collected from a simple text-only trace. #[tokio::test] async fn test_metrics_collected_from_text_trace() { @@ -86,14 +78,14 @@ mod tests { /// Verify that metrics capture tool calls from a file write/read flow. #[tokio::test] async fn test_metrics_collected_from_tool_trace() { - setup_test_dir(); - let _cleanup = CleanupGuard::new().dir(TEST_DIR); + let tmp = tempfile::tempdir().expect("create temp dir"); - let trace = LlmTrace::from_file(concat!( + let mut trace = LlmTrace::from_file(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/llm_traces/file_write_read.json" )) .expect("failed to load file_write_read.json"); + trace.replace_paths("/tmp/ironclaw_e2e_test", tmp.path().to_str().unwrap()); let rig = TestRigBuilder::new().with_trace(trace).build().await; diff --git a/tests/e2e_spot_checks.rs b/tests/e2e_spot_checks.rs index 5723f73b..a6ef6dbb 100644 --- a/tests/e2e_spot_checks.rs +++ b/tests/e2e_spot_checks.rs @@ -11,7 +11,6 @@ mod support; mod spot_tests { use std::time::Duration; - use crate::support::cleanup::CleanupGuard; use crate::support::test_rig::TestRigBuilder; use crate::support::trace_llm::LlmTrace; @@ -98,10 +97,12 @@ mod spot_tests { #[tokio::test] async fn spot_chain_write_read() { - let _cleanup = CleanupGuard::new().file("/tmp/ironclaw_spot_test.txt"); - let _ = std::fs::remove_file("/tmp/ironclaw_spot_test.txt"); + let tmp = tempfile::tempdir().unwrap(); + let test_file = tmp.path().join("ironclaw_spot_test.txt"); + + let mut trace = LlmTrace::from_file(format!("{FIXTURES}/chain_write_read.json")).unwrap(); + trace.replace_paths("/tmp/ironclaw_spot_test.txt", test_file.to_str().unwrap()); - let trace = LlmTrace::from_file(format!("{FIXTURES}/chain_write_read.json")).unwrap(); let rig = TestRigBuilder::new() .with_trace(trace.clone()) .build() @@ -117,8 +118,7 @@ mod spot_tests { rig.verify_trace_expects(&trace, &responses); // Extra: verify file on disk (can't express in expects). - let content = - std::fs::read_to_string("/tmp/ironclaw_spot_test.txt").expect("file should exist"); + let content = std::fs::read_to_string(&test_file).expect("file should exist"); assert_eq!(content, "ironclaw spot check"); rig.shutdown(); @@ -166,10 +166,12 @@ mod spot_tests { #[tokio::test] async fn spot_memory_save_recall() { - let _cleanup = CleanupGuard::new().file("/tmp/bench-meeting.md"); - let _ = std::fs::remove_file("/tmp/bench-meeting.md"); + let tmp = tempfile::tempdir().unwrap(); + let test_file = tmp.path().join("bench-meeting.md"); + + let mut trace = LlmTrace::from_file(format!("{FIXTURES}/memory_save_recall.json")).unwrap(); + trace.replace_paths("/tmp/bench-meeting.md", test_file.to_str().unwrap()); - let trace = LlmTrace::from_file(format!("{FIXTURES}/memory_save_recall.json")).unwrap(); let rig = TestRigBuilder::new() .with_trace(trace.clone()) .build() diff --git a/tests/e2e_tool_coverage.rs b/tests/e2e_tool_coverage.rs index be460f3a..4d390916 100644 --- a/tests/e2e_tool_coverage.rs +++ b/tests/e2e_tool_coverage.rs @@ -10,19 +10,9 @@ mod support; mod tests { use std::time::Duration; - use crate::support::cleanup::CleanupGuard; use crate::support::test_rig::TestRigBuilder; use crate::support::trace_llm::LlmTrace; - const TEST_DIR_BASE: &str = "/tmp/ironclaw_coverage_test"; - - fn setup_test_dir(suffix: &str) -> String { - let dir = format!("{TEST_DIR_BASE}_{suffix}"); - let _ = std::fs::remove_dir_all(&dir); - std::fs::create_dir_all(&dir).expect("failed to create test directory"); - dir - } - // ----------------------------------------------------------------------- // json tool // ----------------------------------------------------------------------- @@ -94,16 +84,21 @@ mod tests { #[tokio::test] async fn test_list_dir() { - let test_dir = setup_test_dir("list_dir"); - let _cleanup = CleanupGuard::new().dir(&test_dir); - std::fs::write(format!("{test_dir}/file_a.txt"), "content a").unwrap(); - std::fs::write(format!("{test_dir}/file_b.txt"), "content b").unwrap(); + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let test_dir = tmp.path().join("test_dir"); + std::fs::create_dir_all(&test_dir).unwrap(); + std::fs::write(test_dir.join("file_a.txt"), "content a").unwrap(); + std::fs::write(test_dir.join("file_b.txt"), "content b").unwrap(); - let trace = LlmTrace::from_file(concat!( + let mut trace = LlmTrace::from_file(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/llm_traces/coverage/list_dir.json" )) .expect("failed to load list_dir.json"); + trace.replace_paths( + "/tmp/ironclaw_coverage_test_list_dir", + test_dir.to_str().unwrap(), + ); let rig = TestRigBuilder::new() .with_trace(trace.clone()) @@ -123,14 +118,19 @@ mod tests { #[tokio::test] async fn test_apply_patch_chain() { - let test_dir = setup_test_dir("apply_patch"); - let _cleanup = CleanupGuard::new().dir(&test_dir); + let tmp = tempfile::tempdir().expect("failed to create tempdir"); + let test_dir = tmp.path().join("test_dir"); + std::fs::create_dir_all(&test_dir).unwrap(); - let trace = LlmTrace::from_file(concat!( + let mut trace = LlmTrace::from_file(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/llm_traces/coverage/apply_patch_chain.json" )) .expect("failed to load apply_patch_chain.json"); + trace.replace_paths( + "/tmp/ironclaw_coverage_test_apply_patch", + test_dir.to_str().unwrap(), + ); let rig = TestRigBuilder::new() .with_trace(trace.clone()) @@ -143,7 +143,7 @@ mod tests { rig.verify_trace_expects(&trace, &responses); // Extra: verify the patch was applied on disk. - let content = std::fs::read_to_string(format!("{test_dir}/patch_target.txt")) + let content = std::fs::read_to_string(test_dir.join("patch_target.txt")) .expect("patch_target.txt should exist"); assert!( content.contains("PATCHED"), diff --git a/tests/e2e_trace_file_tools.rs b/tests/e2e_trace_file_tools.rs index f6f96b4e..2cf6bab7 100644 --- a/tests/e2e_trace_file_tools.rs +++ b/tests/e2e_trace_file_tools.rs @@ -8,29 +8,21 @@ mod support; mod tests { use std::time::Duration; - use crate::support::cleanup::CleanupGuard; use crate::support::test_rig::TestRigBuilder; use crate::support::trace_llm::LlmTrace; - const TEST_DIR: &str = "/tmp/ironclaw_e2e_test"; - const TEST_FILE: &str = "/tmp/ironclaw_e2e_test/hello.txt"; const EXPECTED_CONTENT: &str = "Hello, E2E test!"; - fn setup_test_dir() { - let _ = std::fs::remove_dir_all(TEST_DIR); - std::fs::create_dir_all(TEST_DIR).expect("failed to create test directory"); - } - #[tokio::test] async fn test_file_write_and_read_flow() { - setup_test_dir(); - let _cleanup = CleanupGuard::new().dir(TEST_DIR); + let tmp = tempfile::tempdir().expect("create temp dir"); let fixture_path = concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/llm_traces/file_write_read.json" ); - let trace = LlmTrace::from_file(fixture_path).expect("failed to load trace fixture"); + let mut trace = LlmTrace::from_file(fixture_path).expect("failed to load trace fixture"); + trace.replace_paths("/tmp/ironclaw_e2e_test", tmp.path().to_str().unwrap()); let rig = TestRigBuilder::new() .with_trace(trace.clone()) @@ -44,8 +36,8 @@ mod tests { rig.verify_trace_expects(&trace, &responses); // Extra: verify file on disk (can't express in expects). - let file_content = - std::fs::read_to_string(TEST_FILE).expect("hello.txt should exist after write_file"); + let file_content = std::fs::read_to_string(tmp.path().join("hello.txt")) + .expect("hello.txt should exist after write_file"); assert_eq!(file_content, EXPECTED_CONTENT); rig.shutdown(); diff --git a/tests/e2e_worker_coverage.rs b/tests/e2e_worker_coverage.rs index a2d3988c..005d21c3 100644 --- a/tests/e2e_worker_coverage.rs +++ b/tests/e2e_worker_coverage.rs @@ -91,22 +91,17 @@ mod tests { #[tokio::test] async fn tool_error_feedback() { - // Use a tempdir for the recovery file. The fixture's recovery path - // is updated to write here via the test_dir variable. let tmp = tempfile::tempdir().expect("create temp dir"); - let test_dir = tmp.path().to_str().expect("tempdir path"); - // Patch the fixture's recovery path to use our tempdir. - let fixture_str = std::fs::read_to_string(concat!( + let mut trace = LlmTrace::from_file(concat!( env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/llm_traces/worker/tool_error_feedback.json" )) - .expect("read fixture"); - let fixture_str = fixture_str.replace( - "/tmp/ironclaw_error_feedback_test/recovered.txt", - &format!("{test_dir}/recovered.txt"), + .expect("failed to load tool_error_feedback.json"); + trace.replace_paths( + "/tmp/ironclaw_error_feedback_test", + tmp.path().to_str().unwrap(), ); - let trace: LlmTrace = serde_json::from_str(&fixture_str).expect("parse patched fixture"); let rig = TestRigBuilder::new() .with_trace(trace.clone()) @@ -120,7 +115,7 @@ mod tests { rig.verify_trace_expects(&trace, &responses); // Verify the recovery file exists in the tempdir. - let content = std::fs::read_to_string(format!("{test_dir}/recovered.txt")) + let content = std::fs::read_to_string(tmp.path().join("recovered.txt")) .expect("recovered.txt should exist"); assert!( content.contains("recovered"), diff --git a/tests/support/trace_llm.rs b/tests/support/trace_llm.rs index 0d40a7c4..56f72494 100644 --- a/tests/support/trace_llm.rs +++ b/tests/support/trace_llm.rs @@ -236,6 +236,38 @@ impl LlmTrace { Ok(trace) } + /// Replace all occurrences of `old` with `new` in tool call arguments, + /// text content, and user input throughout the trace. + /// + /// Used to substitute hardcoded fixture paths (e.g. `/tmp/ironclaw_test`) + /// with dynamic `tempfile::tempdir()` paths so tests don't collide. + pub fn replace_paths(&mut self, old: &str, new: &str) { + for turn in &mut self.turns { + if turn.user_input.contains(old) { + turn.user_input = turn.user_input.replace(old, new); + } + for step in &mut turn.steps { + match &mut step.response { + TraceResponse::ToolCalls { tool_calls, .. } => { + for tc in tool_calls { + replace_in_json_value(&mut tc.arguments, old, new); + } + } + TraceResponse::Text { content, .. } => { + if content.contains(old) { + *content = content.replace(old, new); + } + } + TraceResponse::UserInput { content } => { + if content.contains(old) { + *content = content.replace(old, new); + } + } + } + } + } + } + /// Return only the playable steps from the raw steps (text + tool_calls), /// skipping `user_input` markers. Only meaningful for recorded traces that /// were deserialized from a flat `steps` array. @@ -248,6 +280,28 @@ impl LlmTrace { } } +/// Recursively replace `old` with `new` in all string values within a JSON tree. +fn replace_in_json_value(value: &mut serde_json::Value, old: &str, new: &str) { + match value { + serde_json::Value::String(s) => { + if s.contains(old) { + *s = s.replace(old, new); + } + } + serde_json::Value::Object(map) => { + for v in map.values_mut() { + replace_in_json_value(v, old, new); + } + } + serde_json::Value::Array(arr) => { + for v in arr { + replace_in_json_value(v, old, new); + } + } + _ => {} + } +} + // --------------------------------------------------------------------------- // TraceLlm provider // --------------------------------------------------------------------------- diff --git a/tests/support_unit_tests.rs b/tests/support_unit_tests.rs index 645746ea..76ef5d6d 100644 --- a/tests/support_unit_tests.rs +++ b/tests/support_unit_tests.rs @@ -96,42 +96,46 @@ mod cleanup_tests { #[test] fn cleanup_guard_removes_file() { - let path = "/tmp/ironclaw_cleanup_guard_test.txt"; - std::fs::write(path, "test").unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let path = tmp.path().join("cleanup_guard_test.txt"); + std::fs::write(&path, "test").unwrap(); + let path_str = path.to_str().unwrap().to_string(); { - let _guard = CleanupGuard::new().file(path); - assert!(std::path::Path::new(path).exists()); + let _guard = CleanupGuard::new().file(path_str); + assert!(path.exists()); } - assert!(!std::path::Path::new(path).exists()); + assert!(!path.exists()); } #[test] fn cleanup_guard_removes_dir() { - let dir = "/tmp/ironclaw_cleanup_guard_test_dir"; - std::fs::create_dir_all(dir).unwrap(); - std::fs::write(format!("{dir}/file.txt"), "test").unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("cleanup_guard_test_dir"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("file.txt"), "test").unwrap(); + let dir_str = dir.to_str().unwrap().to_string(); { - let _guard = CleanupGuard::new().dir(dir); - assert!(std::path::Path::new(dir).exists()); + let _guard = CleanupGuard::new().dir(dir_str); + assert!(dir.exists()); } - assert!(!std::path::Path::new(dir).exists()); + assert!(!dir.exists()); } #[test] fn cleanup_guard_file_does_not_remove_dir() { - let dir = "/tmp/ironclaw_cleanup_guard_file_not_dir"; - std::fs::create_dir_all(dir).unwrap(); + let tmp = tempfile::tempdir().unwrap(); + let dir = tmp.path().join("cleanup_guard_file_not_dir"); + std::fs::create_dir_all(&dir).unwrap(); + let dir_str = dir.to_str().unwrap().to_string(); { // Registering a directory path as .file() should not remove it // (remove_file fails on directories). - let _guard = CleanupGuard::new().file(dir); + let _guard = CleanupGuard::new().file(dir_str); } assert!( - std::path::Path::new(dir).exists(), + dir.exists(), "dir should still exist when registered as file" ); - // Clean up manually. - let _ = std::fs::remove_dir_all(dir); } }