mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
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 <[email protected]> * 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 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
8fbb782090
commit
cf96a3253c
@@ -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
|
||||
@@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
|
||||
+1
-1
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -237,3 +237,204 @@ fn parse_oauth_access_token(json: &str) -> Option<String> {
|
||||
.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"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+254
-1
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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! 你好 🔑");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
+368
-8
@@ -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::<String>::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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
+19
-19
@@ -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"),
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
+21
-17
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user