amicus 4.3.0 → 4.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (94) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/CHANGELOG.md +64 -0
  3. package/README.md +6 -3
  4. package/docs/DISTRIBUTION.md +234 -0
  5. package/docs/ROADMAP.md +200 -0
  6. package/docs/SHIMS.md +62 -0
  7. package/docs/architecture.md +104 -0
  8. package/docs/configuration.md +371 -0
  9. package/docs/council.md +911 -0
  10. package/docs/doc-system.md +92 -0
  11. package/docs/electron-testing.md +471 -0
  12. package/docs/jsdoc-setup.md +75 -0
  13. package/docs/opencode-integration.md +114 -0
  14. package/docs/publishing.md +60 -0
  15. package/docs/schemas.md +55 -0
  16. package/docs/testing.md +589 -0
  17. package/docs/troubleshooting.md +298 -0
  18. package/docs/usage.md +699 -0
  19. package/electron/fold.js +1 -1
  20. package/electron/ipc-workspace.js +283 -0
  21. package/electron/main.js +31 -1
  22. package/electron/preload-workspace.js +40 -0
  23. package/electron/setup-ui-aliases.js +6 -6
  24. package/electron/workspace-shell.js +85 -0
  25. package/electron/workspace-ui/index.html +111 -0
  26. package/electron/workspace-ui/live-model.js +112 -0
  27. package/electron/workspace-ui/md-lite.js +163 -0
  28. package/electron/workspace-ui/workspace-app.js +240 -0
  29. package/electron/workspace-ui/workspace-matrix.js +249 -0
  30. package/electron/workspace-ui/workspace-panels.js +237 -0
  31. package/electron/workspace-ui/workspace-render.js +277 -0
  32. package/electron/workspace-ui/workspace-verbs.js +293 -0
  33. package/electron/workspace-ui/workspace.css +172 -0
  34. package/package.json +8 -3
  35. package/schemas/council-run-live.schema.json +25 -1
  36. package/schemas/council-run.schema.json +34 -0
  37. package/schemas/progress.schema.json +26 -1
  38. package/schemas/spend.schema.json +52 -4
  39. package/skills/second-opinion/MODEL-NOTES.md +53 -5
  40. package/src/cli-handlers-council-run.js +25 -3
  41. package/src/cli-handlers-spend.js +50 -5
  42. package/src/cli-handlers-watch.js +48 -10
  43. package/src/cli.js +4 -2
  44. package/src/council/briefings-debate.js +27 -7
  45. package/src/council/briefings-stage2.js +155 -25
  46. package/src/council/briefings.js +59 -3
  47. package/src/council/findings.js +236 -9
  48. package/src/council/parse-stage2.js +10 -2
  49. package/src/council/report.js +19 -8
  50. package/src/council/run-assemble.js +42 -1
  51. package/src/council/run-budget.js +277 -0
  52. package/src/council/run-chair.js +4 -1
  53. package/src/council/run-debate.js +4 -2
  54. package/src/council/run-finalize.js +102 -0
  55. package/src/council/run-launch.js +73 -7
  56. package/src/council/run-server.js +248 -0
  57. package/src/council/run-stage2.js +118 -0
  58. package/src/council/run-stages.js +148 -113
  59. package/src/council/run-state.js +23 -1
  60. package/src/council/run.js +52 -53
  61. package/src/council/tally.js +10 -0
  62. package/src/headless.js +519 -17
  63. package/src/mcp-council-awareness.js +53 -3
  64. package/src/observe/council-legs.js +240 -0
  65. package/src/observe/live-doc.js +39 -4
  66. package/src/observe/watch-render.js +23 -1
  67. package/src/opencode-client.js +15 -3
  68. package/src/sidecar/child-sessions.js +197 -0
  69. package/src/sidecar/conversation-mirror.js +111 -37
  70. package/src/sidecar/fanout-budget.js +71 -0
  71. package/src/sidecar/fanout-leg-fallback.js +69 -21
  72. package/src/sidecar/fanout-leg.js +29 -1
  73. package/src/sidecar/fanout-signals.js +61 -0
  74. package/src/sidecar/fanout-wave-io.js +75 -0
  75. package/src/sidecar/fanout.js +65 -81
  76. package/src/sidecar/progress-fields.js +26 -4
  77. package/src/sidecar/progress.js +8 -1
  78. package/src/sidecar/session-utils.js +23 -14
  79. package/src/sidecar/tool-part.js +196 -0
  80. package/src/sidecar/workspace-window.js +62 -0
  81. package/src/spend-query.js +33 -6
  82. package/src/utils/env-num.js +42 -0
  83. package/src/utils/lifecycle.js +37 -1
  84. package/src/utils/path-fence.js +120 -0
  85. package/src/utils/pricing.js +114 -9
  86. package/src/utils/server-setup.js +79 -1
  87. package/src/utils/spend-ledger.js +24 -3
  88. package/src/workspace/artifact-guard.js +208 -0
  89. package/src/workspace/blind-mode.js +32 -0
  90. package/src/workspace/fold-format.js +124 -0
  91. package/src/workspace/live-normalize.js +169 -0
  92. package/src/workspace/matrix-model.js +94 -0
  93. package/src/workspace/run-detail.js +229 -0
  94. package/src/workspace/run-scan.js +148 -0
@@ -0,0 +1,589 @@
1
+ # Testing Guide
2
+
3
+ Comprehensive guide to Amicus's test infrastructure, covering unit tests, integration tests, E2E tests, and the agentic eval system.
4
+
5
+ ## Test Architecture
6
+
7
+ Amicus uses a three-tier testing strategy plus an eval system:
8
+
9
+ ```
10
+ Tier 1: Unit Tests (mocked) ~1200 tests, <2 min
11
+ └─ Business logic, parsing, validation, session management
12
+
13
+ Tier 2: Integration Tests (source) ~30 tests, <5 sec
14
+ └─ Source-level verification, module wiring, config checks
15
+
16
+ Tier 3: E2E Tests (real LLM) ~15 tests, ~3 min each
17
+ └─ CLI headless, MCP headless, Electron CDP
18
+ └─ Requires OPENROUTER_API_KEY, skipped when missing
19
+
20
+ Eval System: Agentic Evals ~3 scenarios, ~5 min each
21
+ └─ Full Claude Code + Amicus interaction grading
22
+ └─ Programmatic checks + LLM-as-judge scoring
23
+ ```
24
+
25
+ ### Quick Reference
26
+
27
+ ```bash
28
+ npm test # Unit tests only (*.integration.test.js excluded by jest.config.js)
29
+ npm test tests/context.test.js # Single file (preferred during dev)
30
+ npm test -- --coverage # Coverage report
31
+ npm test -- -t "should extract" # Run tests matching pattern
32
+
33
+ npm run test:integration # Integration tier, KEYLESS — credentials scrubbed, paid suites skip (free, ~10s)
34
+ npm run test:integration:live # Integration tier with real keys — SPENDS MONEY (serial: --runInBand is baked in)
35
+ npm run test:all # Unit + integration with real keys — SPENDS MONEY (not a gate anywhere)
36
+ npm run test:e2e:mcp # MCP E2E with real repomix (requires OPENROUTER_API_KEY)
37
+ npm run lint # ESLint on src/
38
+
39
+ # Run individual E2E test files (require OPENROUTER_API_KEY)
40
+ npm test tests/cli-headless-e2e.integration.test.js
41
+ npm test tests/mcp-headless-e2e.integration.test.js
42
+ npm test tests/electron-toolbar-e2e.integration.test.js
43
+
44
+ # Evals
45
+ node evals/run_eval.js --eval-id 1
46
+ node evals/run_eval.js --all --dry-run
47
+ ```
48
+
49
+ ---
50
+
51
+ ## Tier 1: Unit Tests
52
+
53
+ Unit tests mock all external dependencies (OpenCode SDK, filesystem, network) and run fast. They form the bulk of the test suite.
54
+
55
+ ### What to Unit Test
56
+
57
+ | Area | Example Files | Focus |
58
+ |------|--------------|-------|
59
+ | CLI parsing | `cli.test.js` | Command validation, flag handling, error messages |
60
+ | Context filtering | `context.test.js` | Turn extraction, token estimation, JSONL parsing |
61
+ | Session management | `session-manager.test.js` | CRUD operations, metadata persistence |
62
+ | Prompt construction | `prompt-builder.test.js` | Template assembly, mode-specific prompts |
63
+ | Conflict detection | `conflict.test.js` | mtime comparison, warning formatting |
64
+ | Drift calculation | `drift.test.js` | Staleness scoring, turn counting |
65
+ | Headless mode | `headless.test.js` | Polling logic, fold marker detection, timeout |
66
+ | MCP tools | `mcp-tools.test.js`, `mcp-server.test.js` | Zod schemas, tool handlers |
67
+ | Session operations | `sidecar/*.test.js` | Start, resume, continue, read, context-builder |
68
+ | Config/utils | Various `utils/*.test.js` | Agent mapping, config loading, validation |
69
+
70
+ ### What NOT to Unit Test
71
+
72
+ Do not write unit tests for:
73
+ - DOM manipulation in `renderer.js`
74
+ - UI picker components (`model-picker.js`, `mode-picker.js`)
75
+ - Electron window configuration (`main.js`)
76
+ - CSS class assignments and styling
77
+
78
+ DOM mock tests are ineffective. They test mock behavior, not real rendering. Use CDP E2E tests for UI verification instead.
79
+
80
+ ### Mocking Patterns
81
+
82
+ The codebase uses Jest's `jest.mock()` for external dependencies:
83
+
84
+ ```javascript
85
+ // Mock the OpenCode SDK (used in headless.test.js, e2e.test.js)
86
+ jest.mock('../src/opencode-client', () => ({
87
+ startServer: jest.fn(),
88
+ createSession: jest.fn(),
89
+ sendPromptAsync: jest.fn(),
90
+ getMessages: jest.fn(),
91
+ checkHealth: jest.fn(),
92
+ }));
93
+ ```
94
+
95
+ **Key rule:** The OpenCode SDK uses ESM dynamic imports (`await import()`) which fail under Jest without `--experimental-vm-modules`. Always mock the SDK in unit tests. For E2E tests that need a real server, use `tests/helpers/start-server.js` (runs in a separate Node.js process).
96
+
97
+ ---
98
+
99
+ ## Tier 2: Integration Tests
100
+
101
+ Integration tests verify source-level invariants without mocking. They read actual source files to assert code structure, ensuring critical patterns aren't accidentally removed.
102
+
103
+ | Test File | What It Verifies |
104
+ |-----------|-----------------|
105
+ | `spawn-pipe-deadlock.integration.test.js` | `spawnSidecarProcess()` in `src/mcp-server.js` uses `ignore` (not `pipe`) for stdio, no `detached: true`, uses `child.unref()` |
106
+ | `electron-headless-mode.test.js` | `electron/main.js` gates `mainWindow.show()` behind `AMICUS_HEADLESS_TEST` env var |
107
+
108
+ These tests catch regressions in critical spawn/process configuration that would be hard to debug in production.
109
+
110
+ ---
111
+
112
+ ## Tier 3: E2E Tests
113
+
114
+ E2E tests spawn real processes, call real LLMs, and verify end-to-end behavior. They require `OPENROUTER_API_KEY` and are automatically skipped when the key is missing.
115
+
116
+ ### Skip Behavior
117
+
118
+ All E2E tests use this pattern:
119
+
120
+ ```javascript
121
+ const HAS_API_KEY = !!(
122
+ process.env.OPENROUTER_API_KEY ||
123
+ (() => {
124
+ try {
125
+ const envPath = path.join(os.homedir(), '.config', 'amicus', '.env');
126
+ return fs.readFileSync(envPath, 'utf-8').includes('OPENROUTER_API_KEY=');
127
+ } catch { return false; }
128
+ })()
129
+ );
130
+ const describeE2E = HAS_API_KEY ? describe : describe.skip;
131
+ ```
132
+
133
+ The key can be in the environment or in `~/.config/amicus/.env`.
134
+
135
+ ### CLI Headless E2E (`cli-headless-e2e.integration.test.js`)
136
+
137
+ Spawns the real `amicus` CLI binary with `start --no-ui` and verifies the full headless lifecycle.
138
+
139
+ **What it tests:**
140
+ 1. `start --no-ui` runs to completion with real LLM
141
+ 2. Session files created on disk (metadata.json, summary.md, initial_context.md)
142
+ 3. `list` command shows the completed session
143
+ 4. `read` command returns the summary
144
+ 5. `read --metadata` returns valid JSON metadata
145
+
146
+ **Architecture:**
147
+ ```
148
+ Test process
149
+ └─ spawn(node, [amicus.js, start, --no-ui, ...])
150
+ └─ OpenCode server (auto port)
151
+ └─ Real LLM call (gemini)
152
+ └─ Session files written to tmpDir
153
+ └─ spawn(node, [amicus.js, list, ...]) // verify list
154
+ └─ spawn(node, [amicus.js, read, ...]) // verify read
155
+ ```
156
+
157
+ ### MCP Headless E2E (`mcp-headless-e2e.integration.test.js`)
158
+
159
+ Spawns a real MCP server over stdio, sends JSON-RPC tool calls, and verifies the full MCP lifecycle.
160
+
161
+ **What it tests:**
162
+ 1. `amicus_start` with `noUi: true` launches a headless session
163
+ 2. `amicus_status` polling until completion
164
+ 3. `amicus_read` returns the summary
165
+ 4. `amicus_list` shows the completed session
166
+ 5. Session files exist on disk with correct metadata
167
+
168
+ **Architecture:**
169
+ ```
170
+ Test process
171
+ └─ spawn(node, [amicus.js, mcp]) // MCP server over stdio
172
+ ├─ JSON-RPC: initialize
173
+ ├─ JSON-RPC: tools/call (amicus_start)
174
+ │ └─ OpenCode server (auto port)
175
+ │ └─ Real LLM call
176
+ ├─ JSON-RPC: tools/call (amicus_status) // poll loop
177
+ ├─ JSON-RPC: tools/call (amicus_read)
178
+ └─ JSON-RPC: tools/call (amicus_list)
179
+ ```
180
+
181
+ ### Electron CDP E2E (`electron-toolbar-e2e.integration.test.js`)
182
+
183
+ Spawns a real Electron window (hidden) with a real OpenCode server, connects via Chrome DevTools Protocol, and asserts toolbar DOM state.
184
+
185
+ **What it tests:**
186
+ 1. Brand name renders ("Amicus")
187
+ 2. Task ID displayed in toolbar
188
+ 3. Timer ticks (changes after 2 seconds)
189
+ 4. Fold button exists with shortcut label
190
+ 5. Settings gear button exists
191
+ 6. Update banner hidden by default
192
+ 7. Update banner visible when `AMICUS_MOCK_UPDATE=available` (the legacy `SIDECAR_MOCK_UPDATE` name was removed in v2.0.0)
193
+ 8. Screenshots captured as PNG files
194
+
195
+ **Architecture:**
196
+ ```
197
+ Test process
198
+ ├─ spawn(node, [start-server.js]) // Real OpenCode server (separate process)
199
+ │ └─ Outputs { port, sessionId }
200
+ ├─ spawn(electron, [main.js]) // Hidden Electron window
201
+ │ ├─ BrowserView → http://localhost:<port> (OpenCode UI)
202
+ │ └─ Main window → data:text/html (toolbar)
203
+ └─ CdpClient.toolbar(9224) // CDP WebSocket connection
204
+ ├─ Runtime.evaluate(...) // DOM assertions
205
+ └─ Page.captureScreenshot(...) // Screenshot capture
206
+ ```
207
+
208
+ ---
209
+
210
+ ## CDP Helper (`tests/helpers/cdp-client.js`)
211
+
212
+ Thin class (~190 lines) wrapping `ws` + `http` for Chrome DevTools Protocol communication. No external dependencies beyond `ws` (already a project dependency).
213
+
214
+ ### API
215
+
216
+ ```javascript
217
+ const { CdpClient } = require('./helpers/cdp-client');
218
+
219
+ // Factory methods (with retry/polling)
220
+ const cdp = await CdpClient.toolbar(port, timeoutMs); // data: URL target
221
+ const cdp = await CdpClient.content(port, timeoutMs); // http://localhost target
222
+
223
+ // Core methods
224
+ const targets = await cdp.getTargets(); // GET /json
225
+ const target = await cdp.findTarget(t => t.url.startsWith('data:'));
226
+ await cdp.connect(targetId); // WebSocket
227
+ const value = await cdp.evaluate('document.title'); // Runtime.evaluate
228
+ await cdp.waitForSelector('.brand', 10000); // Poll until element exists
229
+ await cdp.screenshot('/tmp/toolbar.png'); // Page.captureScreenshot
230
+ cdp.close(); // Cleanup
231
+ ```
232
+
233
+ ### Electron Debug Targets
234
+
235
+ Electron creates two CDP targets per window:
236
+
237
+ | Target | URL Pattern | Contains |
238
+ |--------|-------------|----------|
239
+ | **Content** | `http://localhost:<port>` | OpenCode web UI (BrowserView) |
240
+ | **Toolbar** | `data:text/html,...` | Amicus toolbar (brand, timer, fold button) |
241
+
242
+ Use `CdpClient.toolbar()` or `CdpClient.content()` to connect to the right one.
243
+
244
+ ### Server Helper (`tests/helpers/start-server.js`)
245
+
246
+ Starts a real OpenCode server in a separate Node.js process, working around Jest's inability to handle ESM dynamic imports. Outputs `{ port, sessionId }` as JSON on stdout, then stays alive until killed.
247
+
248
+ ```javascript
249
+ const child = spawn(process.execPath, ['tests/helpers/start-server.js']);
250
+ // Parse JSON from stdout to get { port, sessionId }
251
+ // Kill child when done to stop the server
252
+ ```
253
+
254
+ ---
255
+
256
+ ## Environment Variables for Testing
257
+
258
+ ### Required for E2E Tests
259
+
260
+ | Variable | Purpose |
261
+ |----------|---------|
262
+ | `OPENROUTER_API_KEY` | API key for real LLM calls. Without it, E2E tests are skipped. Can also be in `~/.config/amicus/.env` |
263
+
264
+ ### Test Infrastructure Variables
265
+
266
+ | Variable | Default | Purpose |
267
+ |----------|---------|---------|
268
+ | `AMICUS_HEADLESS_TEST` | unset | Set to `1` to suppress `mainWindow.show()` in Electron. Window is created but never made visible. CDP screenshots still work (captures off-screen renderer). |
269
+ | `AMICUS_DEBUG_PORT` | `9222` | CDP remote debugging port (the legacy `SIDECAR_DEBUG_PORT` name was removed in v2.0.0 — see [docs/SHIMS.md](./SHIMS.md)). Use `9223`+ to avoid conflicts with Chrome browser. E2E tests use `9224`. |
270
+ | `AMICUS_MOCK_UPDATE` | unset | Mock update banner state: `available`, `updating`, `success`, `error`. The legacy `SIDECAR_MOCK_UPDATE` name was removed in v2.0.0. Used in Electron toolbar E2E tests. |
271
+
272
+ ---
273
+
274
+ ## Cross-Platform: macOS / Linux / Windows
275
+
276
+ ### macOS
277
+
278
+ No extra dependencies needed. Electron runs natively. The window is created with `show: false` when `AMICUS_HEADLESS_TEST=1`, so no visible window pops up. CDP screenshots capture the off-screen renderer via `Page.captureScreenshot`.
279
+
280
+ The visual testing docs reference `screencapture -x` and AppleScript window positioning — those are macOS-specific tools. See the Windows section for the cross-platform equivalent.
281
+
282
+ ### Linux (VPS / CI)
283
+
284
+ Electron requires an X server to create a renderer, even with `show: false`. The E2E tests auto-detect headless Linux and manage Xvfb:
285
+
286
+ ```javascript
287
+ function ensureDisplay() {
288
+ if (process.platform !== 'linux' || process.env.DISPLAY) {
289
+ return { display: process.env.DISPLAY, cleanup: () => {} };
290
+ }
291
+ // Auto-launch Xvfb on :99
292
+ const xvfbProcess = spawn('Xvfb', [':99', '-screen', '0', '1280x720x24']);
293
+ return { display: ':99', cleanup: () => xvfbProcess.kill() };
294
+ }
295
+ ```
296
+
297
+ **Prerequisites for Linux CI:**
298
+ ```bash
299
+ apt-get install -y xvfb libgtk-3-0 libnotify4 libnss3 libxss1 libasound2
300
+ ```
301
+
302
+ ### Windows 11
303
+
304
+ The unit suite runs fully green on Windows (verified F2). A few platform-specific details:
305
+
306
+ **Path encoding (`src/session.js`, `src/environment.js`):** `encodeProjectPath` / `encodePath` replace `/`, `\`, `:`, and `_` with dashes. On Windows, `C:\Users\x` encodes to `C--Users-x` — the drive-colon and both backslashes each become a dash. This matches Claude Code's directory naming behavior.
307
+
308
+ **OpenCode binary on PATH (`src/utils/path-setup.js`):** Node's `spawn()` without `shell: true` cannot run `.cmd` shims. `ensureNodeModulesBinInPath()` adds `opencode-windows-x64/bin` and `opencode-windows-x64-baseline/bin` to `PATH` before spawning, so `opencode.exe` resolves directly. The baseline variant supports pre-AVX2 CPUs; normal-before-baseline order in `PATH` is intentional.
309
+
310
+ **Screenshots:** `screencapture` is macOS-only. On Windows, screenshots in E2E tests still work because they go through CDP `Page.captureScreenshot` (rendered off-screen). For manual visual inspection use:
311
+ ```powershell
312
+ # Check window visibility
313
+ Get-Process electron | Select-Object MainWindowTitle, MainWindowHandle
314
+ # CDP screenshot (cross-platform — use the CdpClient helper)
315
+ const cdp = await CdpClient.toolbar(9223);
316
+ await cdp.screenshot('C:\\tmp\\amicus-test.png');
317
+ ```
318
+
319
+ **X server:** Not required. Electron on Windows creates a renderer natively without a display server. The `ensureDisplay()` / Xvfb logic in E2E tests is Linux-only and is skipped on `win32`.
320
+
321
+ ---
322
+
323
+ ## Screenshots
324
+
325
+ CDP E2E tests capture screenshots to `tests/screenshots/` (gitignored). Screenshots are PNG files generated via `Page.captureScreenshot`:
326
+
327
+ | Screenshot | Generated By | Shows |
328
+ |------------|-------------|-------|
329
+ | `toolbar-default.png` | Toolbar E2E default tests | Default toolbar state |
330
+ | `toolbar-update-banner.png` | Toolbar E2E update banner tests | Toolbar with update banner visible |
331
+
332
+ Screenshots are not committed to git. They're generated fresh on each test run for visual verification. Future work: pixel-diff comparison against committed baselines.
333
+
334
+ ---
335
+
336
+ ## Agentic Eval System
337
+
338
+ The eval system tests whether an LLM (Claude) can correctly use Amicus as a tool. Each eval spawns a real Claude Code process in an isolated sandbox.
339
+
340
+ See [evals/README.md](../evals/README.md) for full documentation.
341
+
342
+ ### Quick Start
343
+
344
+ ```bash
345
+ node evals/run_eval.js --eval-id 1 # Single eval
346
+ node evals/run_eval.js --all # All evals
347
+ node evals/run_eval.js --eval-id 1 --mode mcp # MCP mode only
348
+ node evals/run_eval.js --eval-id 1 --mode cli # CLI mode only
349
+ ```
350
+
351
+ ### Scoring
352
+
353
+ Two-stage: programmatic checks (gate) then LLM-as-judge (quality). All programmatic checks must pass before the LLM judge runs.
354
+
355
+ ---
356
+
357
+ ## Writing New Tests
358
+
359
+ ### Choosing the Right Tier
360
+
361
+ | Scenario | Tier | Example |
362
+ |----------|------|---------|
363
+ | New parsing logic | Unit | Mock inputs, assert outputs |
364
+ | New CLI flag | Unit | Test `parseArgs()` with the new flag |
365
+ | New MCP tool | Unit | Test Zod schema + handler with mocked SDK |
366
+ | Critical spawn config | Integration | Read source, assert pattern present |
367
+ | New headless workflow | E2E | Real LLM, verify session files |
368
+ | New toolbar UI element | E2E (CDP) | Real Electron, assert DOM via CDP |
369
+ | LLM decision quality | Eval | Claude + Amicus in sandbox |
370
+
371
+ ### Naming Conventions
372
+
373
+ ```
374
+ tests/
375
+ foo.test.js # Unit test for src/foo.js
376
+ foo.integration.test.js # Integration test (source-level)
377
+ foo-e2e.integration.test.js # E2E test (real processes/LLM)
378
+ sidecar/foo.test.js # Unit test for src/sidecar/foo.js
379
+ helpers/ # Test utilities (not test files)
380
+ ```
381
+
382
+ ### CDP E2E Test Pattern
383
+
384
+ When adding a new Electron E2E test:
385
+
386
+ 1. Reuse the `startRealServer()` + `spawnElectron()` + `CdpClient` pattern from `electron-toolbar-e2e.integration.test.js`
387
+ 2. Use `AMICUS_HEADLESS_TEST=1` to suppress the window
388
+ 3. Use a unique `AMICUS_DEBUG_PORT` (currently `9224` for toolbar tests)
389
+ 4. Always clean up in `afterAll`: kill Electron, kill server, kill Xvfb
390
+ 5. Save screenshots to `tests/screenshots/` with descriptive names
391
+
392
+ ```javascript
393
+ describeE2E('My New E2E Test', () => {
394
+ let serverInfo, electronProcess, cdp;
395
+
396
+ beforeAll(async () => {
397
+ serverInfo = await startRealServer();
398
+ electronProcess = spawnElectron({
399
+ opencodePort: serverInfo.port,
400
+ sessionId: serverInfo.sessionId,
401
+ taskId: 'my-test',
402
+ });
403
+ cdp = await CdpClient.toolbar(CDP_PORT, 20000);
404
+ }, 30000);
405
+
406
+ afterAll(async () => {
407
+ cdp?.close();
408
+ electronProcess?.kill('SIGTERM');
409
+ serverInfo?.cleanup();
410
+ });
411
+
412
+ it('verifies some DOM state', async () => {
413
+ await cdp.waitForSelector('.my-element');
414
+ const text = await cdp.evaluate(`document.querySelector('.my-element')?.textContent`);
415
+ expect(text).toContain('expected');
416
+ });
417
+ });
418
+ ```
419
+
420
+ ### Test File Location
421
+
422
+ All test files go in `tests/`. Test helpers go in `tests/helpers/`. The Jest config matches `**/tests/**/*.test.js`.
423
+
424
+ ---
425
+
426
+ ## Jest Configuration
427
+
428
+ ```javascript
429
+ // jest.config.js
430
+ module.exports = {
431
+ testEnvironment: 'node',
432
+ testMatch: ['**/tests/**/*.test.js'],
433
+ testPathIgnorePatterns: [
434
+ '/node_modules/',
435
+ '\\.integration\\.test\\.js$', // E2E/integration tests excluded from default gate
436
+ '\\.worktrees/'
437
+ ],
438
+ collectCoverageFrom: ['src/**/*.js', 'bin/**/*.js', 'electron/**/*.js'],
439
+ coverageDirectory: 'coverage',
440
+ coverageReporters: ['text', 'lcov'],
441
+ verbose: true
442
+ };
443
+ ```
444
+
445
+ `npm test` runs only `*.test.js` files that do NOT match `*.integration.test.js`. E2E and integration tests must be run explicitly via `npm run test:integration` (keyless), `npm run test:integration:live` (paid), `npm run test:all`, or by naming the file directly.
446
+
447
+ ### Which gate runs which tier
448
+
449
+ | Rail | Runs | Trigger | Cost |
450
+ |------|------|---------|------|
451
+ | `npm test` | unit suite only | every push (pre-push hook), `ci.yml` `test` job on the 3x2 OS/Node matrix | free |
452
+ | `npm run test:integration` | integration tier, keyless | `ci.yml` `integration` job, every push + PR (ubuntu only) | free |
453
+ | `npm run test:integration:live` | integration tier, real keys | `integration-live.yml`, `workflow_dispatch` only | **spends money** |
454
+
455
+ **Why the live rail runs serially.** `test:integration:live` carries `--runInBand`, and it is the only
456
+ rail that does. It is also the only configuration in the repo that spawns several **real OpenCode
457
+ servers** at once: the keyless rail is parallel-safe only because those suites skip there. Run in
458
+ parallel, the live rail flakes on `fanout-e2e` — real legs erroring in ~1.2 s under contention from
459
+ concurrent servers, which reads exactly like a dead model alias and is not one. A worker cap would
460
+ only narrow that window; the contention is over ports and processes, not CPU, so the flag closes it
461
+ instead. Do **not** pass `--maxWorkers` to "speed it up": a paid rail that lies costs more than the
462
+ ~2 minutes serialization spends.
463
+
464
+ The pre-push hook gates on **`npm test` only** — it does not run the integration tier. That is deliberate: `test:all` lets the paid suites see your real credentials, so wiring it into pre-push would bill you on every push. CI watches the tier instead.
465
+
466
+ **How the keyless run stays free.** `npm run test:integration` goes through `scripts/run-integration-keyless.js`, which builds a scrubbed environment before spawning jest: it deletes every name in `PROVIDER_ENV_MAP` (plus legacy aliases), drops `AMICUS_ENV_DIR`/`AMICUS_CONFIG_DIR`, and sandboxes **every credential-path root** — `HOME`, `USERPROFILE`, `XDG_DATA_HOME`, `XDG_CONFIG_HOME`, and `APPDATA` — by repointing each one inside an empty sandbox directory.
467
+
468
+ That last part is the one that matters, because the paid suites can find a credential through **three** doors, not one:
469
+
470
+ 1. the process env (`OPENROUTER_API_KEY` etc.);
471
+ 2. `~/.config/amicus/.env`, read directly by each suite's `HAS_API_KEY` check via `os.homedir()`;
472
+ 3. OpenCode's `auth.json`, reached by suites that call `loadCredentials()` at module scope (e.g. `fanout-e2e`), via `utils/auth-json.js`'s `resolveAuthJsonPath()`.
473
+
474
+ Door 2 resolves through `os.homedir()` alone, which is a libuv call against the **real** process environment — a jest `--setupFiles` shim cannot move it, since jest hands each test environment a *copy* of `process.env` while `os.homedir()` reads the real one. **Door 3 is not a single path either**: `resolveAuthJsonPath()` checks `XDG_DATA_HOME` FIRST, and falls back to `APPDATA` on win32, before it ever falls back to the `os.homedir()`-relative `~/.local/share/opencode/auth.json`. Repointing only `HOME`/`USERPROFILE` — the fix this wrapper originally shipped with — closes the home-relative fallback but leaves `XDG_DATA_HOME`/`APPDATA` open: anyone with either exported in their real environment (XDG_DATA_HOME is common on Nix/home-manager and several Linux distros; APPDATA is always set on Windows) escapes the sandbox entirely, regardless of `os.homedir()`, through exactly the shape of gap that billed the live 2-model wave described next. **This was tried and failed in production, not just in theory:** the `--setupFiles` shim version was tried first and produced two distinct wrong outcomes in a single run — suites gated on door 2 saw `HAS_API_KEY` true with no env credentials, so they ran and failed (17 spurious failures), while `fanout-e2e` pulled a real key through door 3 and billed for a live wave. A whole-branch review of the `HOME`/`USERPROFILE`-only wrapper fix later reproduced the same class of leak end to end with a planted fake credential: with `XDG_DATA_HOME` set, `resolveAuthJsonPath()` resolved OUTSIDE the sandbox and `loadCredentials()` picked the fake key straight up — confirming the gap without spending anything real. Scrubbing every root in the parent process before jest is spawned closes all three doors at once, because the workers — and the CLI/MCP subprocesses the tests themselves spawn — inherit the real, scrubbed environment.
475
+
476
+ The scrub is derived from the engine's own `PROVIDER_ENV_MAP` rather than a hand-maintained list of paid test files, so a provider added later is covered automatically, and a paid suite added later self-skips automatically as long as it follows the existing key-gate pattern.
477
+
478
+ **Timeouts:** E2E tests set per-test timeouts of 180 seconds (3 minutes) for real LLM calls. Unit tests use Jest's default 5-second timeout.
479
+
480
+ ---
481
+
482
+ ## Test File Index
483
+
484
+ Complete mapping of test files to their targets and focus areas.
485
+
486
+ | Test File | Target Module | Focus |
487
+ |-----------|--------------|-------|
488
+ | `cli.test.js` | Argument parsing | Command validation, flag handling |
489
+ | `context.test.js` | Context filtering | Turn extraction, token estimation |
490
+ | `session.test.js` | Session resolution | Primary/fallback paths |
491
+ | `session-manager.test.js` | Persistence layer | CRUD operations, metadata |
492
+ | `conflict.test.js` | File conflicts | mtime comparison, warning format |
493
+ | `drift.test.js` | Drift calculation | Age, turn count, significance |
494
+ | `headless.test.js` | OpenCode HTTP API | Spawn, polling, timeout |
495
+ | `prompt-builder.test.js` | System prompts | Template construction |
496
+ | `index.test.js` | Main API | Re-export smoke tests, generateTaskId |
497
+ | `e2e.test.js` | End-to-end | Full workflow |
498
+ | `sidecar/start.test.js` | Session starting | Task ID generation, metadata creation, MCP config |
499
+ | `sidecar/resume.test.js` | Session resumption | Drift detection, metadata loading |
500
+ | `sidecar/continue.test.js` | Session continuation | Previous session loading, context building |
501
+ | `read-json.test.js` | Session reading | `read --json` run/wave documents, wave-aware list |
502
+ | `sidecar/context-builder.test.js` | Context building | Session resolution, message filtering |
503
+ | `sidecar/session-utils.test.js` | Shared utilities | Session paths, finalization, heartbeat |
504
+ | `sidecar/progress.test.js` | Progress reader | Message counts, latest activity, last activity |
505
+ | `sidecar/exit-handler.test.js` | Crash handler | Metadata update on crash, status transitions |
506
+ | `mcp-headless-lifecycle.test.js` | MCP headless lifecycle | Start, poll, progress, crash, abort, read |
507
+ | `mcp-discovery.test.js` | MCP discovery | Plugin chain, `~/.claude.json` mcpServers, merge priority, sidecar exclusion |
508
+ | `mcp-discovery-integration.test.js` | buildMcpConfig merge | Discovery + file + CLI merge, --no-mcp, --exclude-mcp |
509
+ | `mcp-repomix-e2e.integration.test.js` | MCP E2E (real LLM + repomix) | Real discovery -> headless sidecar -> repomix tool call |
510
+ | `auth-json.test.js` | Auth JSON reader | Import discovery, provider mapping, smart delete check |
511
+ | `opencode-client-cowork.test.js` | OpenCode client config | Client-aware prompt, systemPrompt, port handling, provider model sync |
512
+ | `config.test.js` | Config core | Config I/O, aliases, getEffectiveAliases, tryResolveModel, buildProviderModels |
513
+ | `config-fallback.test.js` | Config fallback | Direct API fallback with persisted keys |
514
+ | `config-hash.test.js` | Config hashing | Config hashing, alias table, change detection |
515
+ | `config-null-alias.test.js` | Config null alias | Null alias protection and auto-repair |
516
+ | `config-resolve.test.js` | Config resolution | Model resolution, default aliases, direct API fallback, detectFallback |
517
+ | `model-validator.test.js` | Model validator | Validation, filtering, interactive prompting, headless errors |
518
+ | `model-fetcher.test.js` | Model fetcher | Provider API fetching, normalization, grouping, error handling |
519
+ | `updater.test.js` | Update checker | Mock states, performUpdate spawn, CLI integration |
520
+ | `evals/tests/transcript_parser.test.js` | Stream-json parsing | Tool call extraction, token usage, error capture |
521
+ | `evals/tests/evaluator.test.js` | Eval criteria | Programmatic checks (7 types), LLM-as-judge prompt/response |
522
+ | `evals/tests/claude_runner.test.js` | Claude runner | MCP config, sandbox creation, CLI command building |
523
+ | `evals/tests/result_writer.test.js` | Result output | Summary formatting, file writing |
524
+ | `scripts/check-secrets.test.js` | Secret detection | Pattern matching, allowlist, multi-secret |
525
+ | `scripts/check-file-sizes.test.js` | File size limits | Line counting, batch checking |
526
+ | `scripts/validate-docs.test.js` | Doc drift detection | Section extraction, drift comparison, staged file check |
527
+ | `helpers/cdp-client.test.js` | CDP helper | Mock HTTP+WebSocket CDP server, factory methods |
528
+ | `electron-headless-mode.test.js` | Electron headless | Source-level verify `AMICUS_HEADLESS_TEST` guard |
529
+ | `cli-headless-e2e.integration.test.js` | CLI E2E (real LLM) | `start --no-ui`, `list`, `read`, `read --metadata` |
530
+ | `electron-toolbar-e2e.integration.test.js` | Electron CDP E2E (real LLM) | Brand, task ID, timer, fold button, settings, update banner, screenshots |
531
+
532
+ ---
533
+
534
+ ## UI Testing Approach (Autonomous Verification Required)
535
+
536
+ **MANDATORY: Any UI feature change MUST be visually verified before considering it complete.** Do not rely solely on unit tests for UI work -- launch the Electron app, inspect via CDP, and take a screenshot.
537
+
538
+ For UI changes, follow this autonomous verification process:
539
+
540
+ 1. **Launch the app** with appropriate mock env vars (e.g., `AMICUS_MOCK_UPDATE=available`)
541
+ 2. **Use `AMICUS_DEBUG_PORT=9223`** to avoid port conflicts with Chrome
542
+ 3. **Inspect via Chrome DevTools Protocol**: Connect to `http://127.0.0.1:9223/json`, find the target page, query DOM state via WebSocket
543
+ 4. **Take a screenshot**: Use CDP `Page.captureScreenshot` (cross-platform) via the `CdpClient` helper. On macOS you can also use `screencapture -x /tmp/amicus-<feature>.png`. On Windows use the `CdpClient` approach (no `screencapture` binary available).
544
+ 5. **Check both targets**: The Electron window has two pages -- the OpenCode content (`http://localhost:...`) and the toolbar (`data:text/html`). Test each as needed.
545
+
546
+ **Key gotcha:** `contextBridge` does not work with `data:` URLs. The toolbar (`data:text/html`) cannot use `window.sidecar` IPC. Use `executeJavaScript()` polling from the main process instead.
547
+
548
+ See [electron-testing.md](electron-testing.md) for full CDP patterns, toolbar-specific testing, and known limitations.
549
+
550
+ ---
551
+
552
+ ## Image / Diagram QA (Mandatory Visual Loop)
553
+
554
+ **When creating or modifying any image (SVG, PNG, diagram, screenshot), you MUST:**
555
+
556
+ 1. Render / convert the image
557
+ 2. Read it back visually (use `Read` tool on the PNG) and inspect the output
558
+ 3. Check for: text clipping, alignment issues, correct labels, layout balance, readability
559
+ 4. Fix any issues found
560
+ 5. Re-render and re-inspect -- **loop until fully QA'd**
561
+
562
+ Never commit an image without completing visual verification. GitHub strips `<style>` and `<filter>` from SVGs, so always convert to PNG (use `sharp`) for any image referenced in README or docs.
563
+
564
+ ---
565
+
566
+ ## Update Banner Mock Testing
567
+
568
+ Use `AMICUS_MOCK_UPDATE` to test update UI states without real npm operations (the legacy `SIDECAR_MOCK_UPDATE` name was removed in v2.0.0):
569
+
570
+ ```bash
571
+ AMICUS_MOCK_UPDATE=available amicus start --model gemini --prompt "test" # Shows banner
572
+ AMICUS_MOCK_UPDATE=success amicus start --model gemini --prompt "test" # Update succeeds
573
+ AMICUS_MOCK_UPDATE=error amicus start --model gemini --prompt "test" # Update fails
574
+ ```
575
+
576
+ ---
577
+
578
+ ## Troubleshooting
579
+
580
+ | Problem | Cause | Solution |
581
+ |---------|-------|---------|
582
+ | E2E tests skipped | No `OPENROUTER_API_KEY` | Set the key in env or `~/.config/amicus/.env` |
583
+ | CDP connection refused | Wrong port or Chrome conflict | Use `AMICUS_DEBUG_PORT=9224` (not 9222/9223) |
584
+ | Electron E2E skipped | `electron` not installed | `npm install` (it's a devDependency) |
585
+ | Linux E2E crash | No X server | Install Xvfb: `apt-get install xvfb` |
586
+ | Jest ESM error | Dynamic import in test | Use `tests/helpers/start-server.js` child process |
587
+ | `waitForSelector` timeout | Element not rendered yet | Increase timeout or check selector name |
588
+ | Screenshot empty/small | Window not created | Verify `AMICUS_HEADLESS_TEST=1` is set |
589
+ | Stale CDP target ID | Electron restarted | Always use `CdpClient.toolbar()` factory (retries) |