opencode-swarm 7.195.0 → 7.197.0
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.
- package/.opencode/skills/brainstorm/SKILL.md +2 -1
- package/.opencode/skills/ci-failure-batching/SKILL.md +2 -1
- package/.opencode/skills/ci-fix-monitor/SKILL.md +2 -1
- package/.opencode/skills/clarify/SKILL.md +2 -1
- package/.opencode/skills/clarify-spec/SKILL.md +2 -1
- package/.opencode/skills/codebase-review-swarm/SKILL.md +1 -1
- package/.opencode/skills/commit-pr/SKILL.md +3 -2
- package/.opencode/skills/consult/SKILL.md +2 -1
- package/.opencode/skills/council/SKILL.md +2 -1
- package/.opencode/skills/critic-gate/SKILL.md +2 -1
- package/.opencode/skills/deep-dive/SKILL.md +2 -1
- package/.opencode/skills/deep-research/SKILL.md +2 -1
- package/.opencode/skills/design-docs/SKILL.md +2 -1
- package/.opencode/skills/discover/SKILL.md +2 -1
- package/.opencode/skills/durable-session-state/SKILL.md +2 -1
- package/.opencode/skills/engineering-conventions/SKILL.md +2 -1
- package/.opencode/skills/execute/SKILL.md +2 -1
- package/.opencode/skills/fork-pr-operations/SKILL.md +2 -1
- package/.opencode/skills/gate-attribution/SKILL.md +2 -1
- package/.opencode/skills/issue-ingest/SKILL.md +2 -1
- package/.opencode/skills/issue-tracer/SKILL.md +1 -1
- package/.opencode/skills/loop/SKILL.md +2 -1
- package/.opencode/skills/merge-queue-readiness/SKILL.md +2 -1
- package/.opencode/skills/orchestrating-subagents/SKILL.md +2 -1
- package/.opencode/skills/parallel-work-check/SKILL.md +17 -16
- package/.opencode/skills/phase-wrap/SKILL.md +2 -1
- package/.opencode/skills/pre-phase-briefing/SKILL.md +2 -1
- package/.opencode/skills/running-tests/SKILL.md +2 -1
- package/.opencode/skills/skill-edit-validation/SKILL.md +2 -1
- package/.opencode/skills/specify/SKILL.md +2 -1
- package/.opencode/skills/swarm/SKILL.md +2 -1
- package/.opencode/skills/swarm-ci-monitor/SKILL.md +3 -2
- package/.opencode/skills/swarm-implement/SKILL.md +2 -1
- package/.opencode/skills/swarm-plan/SKILL.md +2 -1
- package/.opencode/skills/swarm-pr-feedback/SKILL.md +9 -162
- package/.opencode/skills/swarm-pr-feedback/references/gate-evidence-scope.md +20 -0
- package/.opencode/skills/swarm-pr-feedback/references/intake-surfaces.md +107 -0
- package/.opencode/skills/swarm-pr-feedback/references/preflight-checkout.md +67 -0
- package/.opencode/skills/swarm-pr-review/SKILL.md +12 -344
- package/.opencode/skills/swarm-pr-review/references/council-mode.md +38 -0
- package/.opencode/skills/swarm-pr-review/references/explorer-output-format.md +54 -0
- package/.opencode/skills/swarm-pr-review/references/findings-persistence-detail.md +15 -0
- package/.opencode/skills/swarm-pr-review/references/parser-candidate-extraction.md +75 -0
- package/.opencode/skills/swarm-pr-review/references/phase0a-intake.md +61 -0
- package/.opencode/skills/swarm-pr-review/references/scope-detection.md +103 -0
- package/.opencode/skills/swarm-pr-review/references/writeback-phases.md +68 -0
- package/.opencode/skills/swarm-pr-subscribe/SKILL.md +3 -2
- package/.opencode/skills/swarm-resume/SKILL.md +2 -1
- package/.opencode/skills/test-file-split/SKILL.md +2 -1
- package/.opencode/skills/worktree-retry-cleanup/SKILL.md +2 -1
- package/.opencode/skills/writing-tests/SKILL.md +7 -437
- package/.opencode/skills/writing-tests/references/cross-platform-patterns.md +237 -0
- package/.opencode/skills/writing-tests/references/mock-tiers.md +207 -0
- package/README.md +1 -1
- package/binaries/win32-arm64/swarm-sandbox-runner.exe +0 -0
- package/binaries/win32-x64/swarm-sandbox-runner.exe +0 -0
- package/dist/cli/{auto-checkpoint-anf21wwa.js → auto-checkpoint-s05sxmg6.js} +4 -4
- package/dist/cli/{checkpoint-ngdw2qx2.js → checkpoint-kzyy9b7y.js} +8 -8
- package/dist/cli/{coder-settlement-bqb059y2.js → coder-settlement-d1qqfgdm.js} +3 -3
- package/dist/cli/{config-doctor-vetg215g.js → config-doctor-yrven0ya.js} +7 -7
- package/dist/cli/{core-8qgvsm6x.js → core-qpmksfae.js} +1 -1
- package/dist/cli/{curator-drift-3gjycbkv.js → curator-drift-2h6nf0mw.js} +5 -5
- package/dist/cli/{curator-llm-factory-d17ybmgm.js → curator-llm-factory-kt3jhgs9.js} +25 -25
- package/dist/cli/{curator-hh0n9wgz.js → curator-mqnqst1g.js} +25 -25
- package/dist/cli/{doctor-knowledge-receipt-phase-id-1d465vkg.js → doctor-knowledge-receipt-phase-id-d8arz38j.js} +4 -4
- package/dist/cli/{evidence-summary-service-rkd3yc5x.js → evidence-summary-service-gjs4zssz.js} +9 -9
- package/dist/cli/{gate-evidence-wtjrerkh.js → gate-evidence-9n5r17hq.js} +2 -2
- package/dist/cli/{guardrail-explain-8xh3r0dn.js → guardrail-explain-g4wcr664.js} +26 -26
- package/dist/cli/{guardrail-log-jrey2myg.js → guardrail-log-0hy23f7d.js} +3 -3
- package/dist/cli/{guardrail-reset-m5qbg30j.js → guardrail-reset-9z5rmp3f.js} +25 -25
- package/dist/cli/{hive-promoter-36yhdfre.js → hive-promoter-w8f840ah.js} +25 -25
- package/dist/cli/{index-gz16dke1.js → index-1cks27mv.js} +6 -6
- package/dist/cli/{index-epdzvp4w.js → index-1gnrtz76.js} +5 -5
- package/dist/cli/{index-jn4mmhqc.js → index-20m9q2z9.js} +1 -1
- package/dist/cli/{index-yryy781f.js → index-29xr45xr.js} +153 -52
- package/dist/cli/{index-bpq6z8qe.js → index-3qqnz0jg.js} +3 -3
- package/dist/cli/{index-kmvy5fr8.js → index-401a12w5.js} +1 -1
- package/dist/cli/{index-jatgbm7k.js → index-5p0jhn2s.js} +1 -1
- package/dist/cli/{index-gns3gc87.js → index-67w61x1b.js} +3 -3
- package/dist/cli/{index-ha8xc5d3.js → index-8ahnfc03.js} +2 -2
- package/dist/cli/{index-akb436kn.js → index-8zwmfh9d.js} +1 -1
- package/dist/cli/{index-3k65kdfp.js → index-9px1knpj.js} +1 -1
- package/dist/cli/{index-1hcjxz6w.js → index-9sqcmzc2.js} +2 -2
- package/dist/cli/{index-9teqkkp4.js → index-agvmmvdt.js} +25 -25
- package/dist/cli/{index-tc8x0zc5.js → index-avbvxmw2.js} +1 -1
- package/dist/cli/{index-c6hyd8hg.js → index-bpnpvhja.js} +1 -1
- package/dist/cli/{index-8448vj9w.js → index-dgvf1jey.js} +1 -1
- package/dist/cli/{index-bftpd2m5.js → index-eabxecjf.js} +2 -2
- package/dist/cli/{index-6vpqe4ws.js → index-fzx1wfxn.js} +1 -1
- package/dist/cli/{index-h7275akd.js → index-g1r94prs.js} +27 -27
- package/dist/cli/{index-82aj6gaj.js → index-g749xs7c.js} +2 -2
- package/dist/cli/{index-7kbpzg05.js → index-jvpvghyh.js} +328 -224
- package/dist/cli/{index-780g1hht.js → index-jvzfw0n5.js} +1 -1
- package/dist/cli/{index-479kf368.js → index-mbdkr2sh.js} +1 -1
- package/dist/cli/{index-c4hx57tz.js → index-ng6me2hh.js} +2 -2
- package/dist/cli/{index-fk4j26km.js → index-njfkqr77.js} +3 -3
- package/dist/cli/{index-peyz4k9k.js → index-t3tzk566.js} +1 -1
- package/dist/cli/{index-z59414gv.js → index-v8jhmn8x.js} +1 -1
- package/dist/cli/{index-5gf2wx2c.js → index-xb3h5tvj.js} +4 -4
- package/dist/cli/{index-xs9cn3c0.js → index-xf6tdsyr.js} +3 -3
- package/dist/cli/{index-863hst4e.js → index-ysbmrmzk.js} +1 -1
- package/dist/cli/index.js +26 -26
- package/dist/cli/{knowledge-escalator-6sgrjbcj.js → knowledge-escalator-42cvn0ec.js} +7 -7
- package/dist/cli/{knowledge-events-hztjt63y.js → knowledge-events-v0xavjkd.js} +5 -5
- package/dist/cli/{knowledge-store-3sd2034v.js → knowledge-store-s01cread.js} +1 -1
- package/dist/cli/{knowledge-validator-3fe82zy4.js → knowledge-validator-kvtcqpke.js} +2 -2
- package/dist/cli/{mcp-spbx3efe.js → mcp-ff73w9z7.js} +1 -1
- package/dist/cli/{model-preflight-k2bw7rt1.js → model-preflight-0k8bd388.js} +25 -25
- package/dist/cli/{pending-delegations-kcvd9t41.js → pending-delegations-bmj7djk1.js} +2 -2
- package/dist/cli/{pr-subscriptions-nqby87en.js → pr-subscriptions-9dbrw7ky.js} +1 -1
- package/dist/cli/{pr-workflow-gate-cgbc0x1z.js → pr-workflow-gate-wg003fkd.js} +25 -25
- package/dist/cli/{scan-cursor-sphr9phs.js → scan-cursor-d93q0q3s.js} +2 -2
- package/dist/cli/{schema-x892aatt.js → schema-0ny9ef6s.js} +1 -1
- package/dist/cli/{scope-persistence-n9xksct2.js → scope-persistence-ekcfq34x.js} +5 -5
- package/dist/cli/{server-am2y11yn.js → server-rpx7pkg1.js} +25 -25
- package/dist/cli/{skill-generator-p71b6h5k.js → skill-generator-h1w7s5dh.js} +8 -8
- package/dist/cli/{snapshot-coordination-init-x9b4t9sk.js → snapshot-coordination-init-f6ecsn9c.js} +25 -25
- package/dist/cli/{speckit-checkoff-xp9vsp9k.js → speckit-checkoff-kf0zkpdw.js} +3 -3
- package/dist/cli/{worktree-collision-ownership-2pmyww6g.js → worktree-collision-ownership-p7zmj4ag.js} +2 -2
- package/dist/cli/{worktree-isolation-379tmkta.js → worktree-isolation-nz90xvw5.js} +25 -25
- package/dist/commands/registry.d.ts +1 -1
- package/dist/config/skill-contract-digest.d.ts +6 -0
- package/dist/epic/start.d.ts +5 -4
- package/dist/hooks/skill-scoring.d.ts +1 -1
- package/dist/hooks/skill-usage-log.d.ts +2 -0
- package/dist/host/v2/agents-commands.d.ts +13 -3
- package/dist/index.js +103 -98
- package/dist/sbom/detectors/index.d.ts +15 -0
- package/dist/services/skill-evaluator.d.ts +2 -0
- package/dist/services/skill-generator.d.ts +13 -0
- package/dist/tools/sbom-generate.d.ts +1 -1
- package/package.json +1 -1
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: writing-tests
|
|
3
|
-
audience: swarm-plugin
|
|
4
3
|
description: >
|
|
5
4
|
Guidelines for writing, organizing, and maintaining tests in the opencode-swarm repository.
|
|
6
5
|
Covers framework rules (bun:test), mock isolation, CI pipeline structure, file placement,
|
|
7
6
|
and anti-patterns that break cross-platform CI. Load this skill before writing or modifying
|
|
8
7
|
any test file.
|
|
8
|
+
metadata:
|
|
9
|
+
audience: swarm-plugin
|
|
9
10
|
---
|
|
10
11
|
|
|
11
12
|
# Writing Tests for opencode-swarm
|
|
@@ -156,138 +157,7 @@ When test files pass individually but fail when run together, follow this protoc
|
|
|
156
157
|
- Symptom: `expect(mockFn).not.toHaveBeenCalled()` fails because the real function was called
|
|
157
158
|
6. **Fix path**: Migrate the affected test file to the `_internals` DI seam pattern documented above (opencode-swarm repository contributors also have a dedicated mock-to-internals-migration skill that walks the recipe in depth). This eliminates both the `vi.mock()` call and the closure capture surface area. **Exception — reference-captured functions**: if the source code passes a function as a direct argument or captures it in a closure at module scope (e.g., `transactFile(path, readKnowledge, ...)`), the reference bypasses `_internals` entirely — mutating `_internals.readKnowledge` changes only the object property, not the module-scope binding the source already holds. Migrating to `_internals` does not help. In that case, test via observable outcomes (e.g., run concurrent callers and assert on final persisted state).
|
|
158
159
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
The codebase uses a two-tier strategy for mock isolation, plus a zero-mock testing pattern:
|
|
162
|
-
|
|
163
|
-
### Tier 0: _test_exports Pure Function Testing (Zero Mocks)
|
|
164
|
-
|
|
165
|
-
When a module contains internal utility functions (formatters, normalizers, transformers) that don't need external dependencies, export them via a `_test_exports` object for direct unit testing. This avoids `mock.module` entirely and produces tests that are deterministic, fast, and immune to Bun's cross-file mock leakage:
|
|
166
|
-
|
|
167
|
-
```typescript
|
|
168
|
-
// In source file (src/tools/formatter.ts)
|
|
169
|
-
function formatEntry(entry: SomeType): string {
|
|
170
|
-
// internal implementation — may use optional chaining, defaults, etc.
|
|
171
|
-
return entry.score?.toFixed(2) ?? 'N/A';
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// Public API (tool handler, command handler, etc.)
|
|
175
|
-
export function handleQuery(ctx: Context) {
|
|
176
|
-
const entries = readData(ctx);
|
|
177
|
-
return entries.map(formatEntry);
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
// Export seam for testing — only used by test files
|
|
181
|
-
export const _test_exports = { formatEntry };
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
```typescript
|
|
185
|
-
// In test file (tests/unit/tools/formatter.test.ts)
|
|
186
|
-
import { _test_exports } from '../../../src/tools/formatter';
|
|
187
|
-
|
|
188
|
-
const { formatEntry } = _test_exports;
|
|
189
|
-
|
|
190
|
-
describe('formatEntry', () => {
|
|
191
|
-
test('handles missing score', () => {
|
|
192
|
-
expect(formatEntry({ score: undefined })).toBe('N/A');
|
|
193
|
-
});
|
|
194
|
-
test('formats numeric score', () => {
|
|
195
|
-
expect(formatEntry({ score: 0.85 })).toBe('0.85');
|
|
196
|
-
});
|
|
197
|
-
});
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
**When to use Tier 0 vs Tier 1:**
|
|
201
|
-
- **Tier 0 (`_test_exports`)**: The function is a pure utility (formatter, normalizer, transformer) that doesn't call external modules. No mocking needed — test it directly.
|
|
202
|
-
- **Tier 1 (`_internals`)**: You need to mock a function within the same module to test the caller in isolation. The function has side effects or calls external APIs.
|
|
203
|
-
- **Tier 2 (`mock.module`)**: You need to mock a dependency from another module (Node built-ins, other application modules).
|
|
204
|
-
|
|
205
|
-
**Benefits of Tier 0:**
|
|
206
|
-
- Zero mock pollution — no `mock.module` calls, no `mock.restore()` needed
|
|
207
|
-
- Works in batch test runs without per-file isolation
|
|
208
|
-
- Type-safe (the exported object carries the real TypeScript types)
|
|
209
|
-
- No filesystem dependencies (no tmpDir, no chdir, no existsSync)
|
|
210
|
-
- Deterministic on all platforms and CI environments
|
|
211
|
-
|
|
212
|
-
### Tier 1: _internals DI Seams (Within-Module)
|
|
213
|
-
|
|
214
|
-
For mocking functions within the same module, source files export an `_internals` object that wraps key functions. Tests can replace individual functions without using `mock.module`:
|
|
215
|
-
|
|
216
|
-
```typescript
|
|
217
|
-
// In source file (src/services/my-service.ts)
|
|
218
|
-
export const _internals = {
|
|
219
|
-
helperFn: () => { /* real implementation */ }
|
|
220
|
-
};
|
|
221
|
-
|
|
222
|
-
export function mainFn() {
|
|
223
|
-
return _internals.helperFn();
|
|
224
|
-
}
|
|
225
|
-
```
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
// In test file
|
|
229
|
-
import { _internals, mainFn } from '../../../src/services/my-service';
|
|
230
|
-
|
|
231
|
-
test('mainFn uses mocked helper', () => {
|
|
232
|
-
const original = _internals.helperFn;
|
|
233
|
-
_internals.helperFn = mock(() => 'mocked');
|
|
234
|
-
// ... test ...
|
|
235
|
-
_internals.helperFn = original; // restore
|
|
236
|
-
});
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
**Benefits:**
|
|
240
|
-
- No process-global mock pollution
|
|
241
|
-
- Type-safe
|
|
242
|
-
- Fast (no module re-parsing)
|
|
243
|
-
- Works in batch test runs without isolation
|
|
244
|
-
|
|
245
|
-
**Critical limitation — reference-captured functions:** `_internals` interception requires the source code to read `_internals.fn` at the call site. When a function is instead passed as a direct argument or captured in a closure at module definition time, replacing `_internals.fn` has no effect — the mock is silently ignored and the real function runs.
|
|
246
|
-
|
|
247
|
-
```typescript
|
|
248
|
-
// Source: readKnowledge is captured at definition time, NOT via _internals
|
|
249
|
-
export async function transactKnowledge(filePath: string, mutate: Fn) {
|
|
250
|
-
return transactFile(filePath, readKnowledge, ...); // direct ref, captured at definition time
|
|
251
|
-
}
|
|
252
|
-
export const _internals = { readKnowledge }; // mutating this does NOT affect the closure above
|
|
253
|
-
|
|
254
|
-
// Test — mock is silently ignored; real readKnowledge still runs
|
|
255
|
-
const orig = _internals.readKnowledge;
|
|
256
|
-
_internals.readKnowledge = mock(() => []); // only mutates the object property
|
|
257
|
-
await transactKnowledge(path, mutate); // still calls the real readKnowledge
|
|
258
|
-
_internals.readKnowledge = orig;
|
|
259
|
-
```
|
|
260
|
-
|
|
261
|
-
When `_internals` interception cannot work, verify **observable outcomes** instead: run concurrent callers and assert on final persisted state. See `tests/unit/hooks/knowledge-application.test.ts` ("two concurrent bumpCountersBatch calls") for the pattern.
|
|
262
|
-
|
|
263
|
-
### Tier 2: mock.module (Cross-Module)
|
|
264
|
-
|
|
265
|
-
When mocking dependencies from other modules (especially Node built-ins), use `mock.module` with proper cleanup:
|
|
266
|
-
|
|
267
|
-
```typescript
|
|
268
|
-
import * as realFs from 'node:fs/promises';
|
|
269
|
-
|
|
270
|
-
mock.module('node:fs/promises', () => ({
|
|
271
|
-
...realFs, // MUST spread real exports
|
|
272
|
-
readFile: mock(() => Promise.resolve('mocked')),
|
|
273
|
-
}));
|
|
274
|
-
|
|
275
|
-
afterEach(() => mock.restore());
|
|
276
|
-
```
|
|
277
|
-
|
|
278
|
-
**Critical rules for cross-module mocks:**
|
|
279
|
-
1. **Always spread real exports** for Node built-ins — other code depends on exports you don't mock
|
|
280
|
-
2. **Always add `afterEach(mock.restore())`** — provides best-effort cleanup
|
|
281
|
-
3. **Run in per-file isolation** — CI runs each file in its own process (`for f in *.test.ts; do bun --smol test "$f"; done`)
|
|
282
|
-
|
|
283
|
-
### Choosing Between Tiers
|
|
284
|
-
|
|
285
|
-
| Scenario | Pattern | Example |
|
|
286
|
-
|----------|---------|--------|
|
|
287
|
-
| Mocking a function in the same module you're testing | `_internals` seam | `src/state.ts` `_internals.loadSnapshot` |
|
|
288
|
-
| Mocking a Node built-in (fs, child_process, etc.) | `mock.module` + spread real | `mock.module('node:fs/promises', () => ({ ...realFs, readFile: mockFn }))` |
|
|
289
|
-
| Mocking another application module | `mock.module` + cleanup | `mock.module('../../../src/utils/logger', ...)` + `afterEach(mock.restore())` |
|
|
290
|
-
| File-scoped mock (applies to all tests in file) | `mock.module` at top level + `mockReset()` in `beforeEach` | Preflight tests with `mockLoadPlan.mockReset()` |
|
|
160
|
+
Detailed reference material moved to `.opencode/skills/writing-tests/references/mock-tiers.md` (issue #2907 progressive-disclosure budget).
|
|
291
161
|
|
|
292
162
|
## Mock Coverage Documentation
|
|
293
163
|
|
|
@@ -320,216 +190,9 @@ mock.module('../../../src/turbo/lean/runtime-conformance', () => ({
|
|
|
320
190
|
|
|
321
191
|
This requirement applies to all three mock tiers (`_test_exports`, `_internals`, `mock.module`) whenever the mock narrows the exercised branch set.
|
|
322
192
|
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
When using `mock.module()` (or `vi.mock()`) with Bun's test runner, the mock factory **MUST provide stubs for ALL named exports** of the target module — not just the ones your test calls. Bun validates the export set at dynamic-import time and throws `SyntaxError: Export named 'X' not found` if any export is missing.
|
|
326
|
-
|
|
327
|
-
### Why this matters
|
|
328
|
-
|
|
329
|
-
Transitive imports may reference exports your test never calls directly. For example, if your test mocks `config/schema.js` and only uses `stripKnownSwarmPrefix`, but a transitive dependency imports `PluginConfigSchema` from the same module, the mock MUST include `PluginConfigSchema` as a stub — even though your test never calls it.
|
|
330
|
-
|
|
331
|
-
When the source module gains new exports (e.g., a PR adds 50 new Zod schemas to `config/schema.ts`), ALL existing `mock.module()` calls targeting that module must be updated — even if the new exports are irrelevant to your test.
|
|
332
|
-
|
|
333
|
-
### How to verify completeness
|
|
334
|
-
|
|
335
|
-
Before finalizing a test that uses `mock.module()`:
|
|
336
|
-
|
|
337
|
-
1. List all runtime exports of the target module (type-only exports are erased at compile time and need no stub):
|
|
338
|
-
```bash
|
|
339
|
-
grep -E "^export (const|function|async function|class) " src/path/to/module.ts
|
|
340
|
-
```
|
|
341
|
-
**Note:** Do NOT include `type` or `interface` exports — Bun erases these at compile time and they need no runtime stub.
|
|
342
|
-
2. Ensure every export name has an entry in your `mock.module()` factory.
|
|
343
|
-
3. Stubs can be minimal:
|
|
344
|
-
- Functions: `() => null` or `async () => {}`
|
|
345
|
-
- Zod schemas: use a comprehensive stub that supports common methods:
|
|
346
|
-
```typescript
|
|
347
|
-
const zodStub = {
|
|
348
|
-
parse: (v: unknown) => v,
|
|
349
|
-
safeParse: (v: unknown) => ({ success: true as const, data: v }),
|
|
350
|
-
parseAsync: async (v: unknown) => v,
|
|
351
|
-
};
|
|
352
|
-
```
|
|
353
|
-
- Constants: appropriate zero values (`''`, `0`, `null`, `[]`, `{}`)
|
|
354
|
-
|
|
355
|
-
### Verification pattern
|
|
356
|
-
|
|
357
|
-
```typescript
|
|
358
|
-
// ✅ CORRECT — all exports provided, test uses only the first one
|
|
359
|
-
mock.module('../../../src/config/schema.js', () => ({
|
|
360
|
-
// The one export your test actually uses
|
|
361
|
-
stripKnownSwarmPrefix: mockStripFn,
|
|
362
|
-
// Stubs for transitive import resolution (never called in test)
|
|
363
|
-
PluginConfigSchema: zodStub,
|
|
364
|
-
ScoringConfigSchema: zodStub,
|
|
365
|
-
isKnownCanonicalRole: () => false,
|
|
366
|
-
// ... all other runtime exports as stubs
|
|
367
|
-
}));
|
|
368
|
-
|
|
369
|
-
// ❌ WRONG — missing exports cause SyntaxError at module-load time
|
|
370
|
-
mock.module('../../../src/config/schema.js', () => ({
|
|
371
|
-
stripKnownSwarmPrefix: mockStripFn,
|
|
372
|
-
// Missing: PluginConfigSchema, ScoringConfigSchema, etc.
|
|
373
|
-
// → "SyntaxError: Export named 'PluginConfigSchema' not found"
|
|
374
|
-
}));
|
|
375
|
-
```
|
|
193
|
+
Detailed reference material moved to `.opencode/skills/writing-tests/references/mock-tiers.md` (issue #2907 progressive-disclosure budget).
|
|
376
194
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
Adding stubs for ESM resolution is NOT test theater — it's a Bun runtime requirement. The distinction:
|
|
380
|
-
|
|
381
|
-
| Pattern | Test theater? | Why |
|
|
382
|
-
|---------|--------------|-----|
|
|
383
|
-
| Adding `PluginConfigSchema: zodStub` so the module loads | **No** | Required for ESM resolution; stub is never called |
|
|
384
|
-
| Stubbing `validateDirectory` to return `true` then asserting "validation works" | **Yes** | The stub bypasses the logic you should be testing |
|
|
385
|
-
| Using `zodStub` in assertions: `expect(zodStub.parse(input)).toBe(input)` | **Yes** | Testing the stub, not the real code |
|
|
386
|
-
| Adding stubs for ALL 50 Zod schemas in config/schema.ts | **No** | All are required for transitive import resolution |
|
|
387
|
-
|
|
388
|
-
The stubs exist solely to satisfy the module loader. Test assertions must verify behavior through the real-mocked functions (the ones your test actually calls), not through the stubs.
|
|
389
|
-
|
|
390
|
-
### Files Intentionally Using File-Scoped Mocks
|
|
391
|
-
|
|
392
|
-
Some test files use top-level `mock.module` that must persist across all tests in the file. These files use `mockReset()`/`mockClear()` in `beforeEach` instead of `mock.restore()` in `afterEach`:
|
|
393
|
-
|
|
394
|
-
- `src/__tests__/preflight-phase.test.ts` — mocks `plan/manager` and `preflight-service`
|
|
395
|
-
|
|
396
|
-
## Cross-Platform Test Patterns
|
|
397
|
-
|
|
398
|
-
Tests run on all three CI platforms (ubuntu, macos, windows). Path and filesystem behavior
|
|
399
|
-
differs between them. Follow these patterns to prevent platform-specific failures:
|
|
400
|
-
|
|
401
|
-
### Mock keys with filesystem paths
|
|
402
|
-
|
|
403
|
-
**Never hardcode Unix-format paths as mock keys.** On Windows, `path.resolve('/dir', 'file')`
|
|
404
|
-
produces drive-letter-prefixed paths like `D:\dir\file`, not `/dir/file`. A mock that checks
|
|
405
|
-
for `/dir/file` will silently never match, causing the test to behave differently on Windows.
|
|
406
|
-
|
|
407
|
-
**Use `path.resolve()` to construct mock keys the same way the source code does:**
|
|
408
|
-
|
|
409
|
-
```typescript
|
|
410
|
-
// ❌ WRONG — fails on Windows (mock expects '/safe/dir/linked.ts',
|
|
411
|
-
// but path.resolve('/safe/dir', 'linked.ts') = 'D:\safe\dir\linked.ts')
|
|
412
|
-
mockRealpathSync.mockImplementation((inputPath: string) => {
|
|
413
|
-
if (inputPath === '/safe/dir') return '/safe/dir';
|
|
414
|
-
if (inputPath === '/safe/dir/linked.ts') return '/outside/linked.ts';
|
|
415
|
-
return inputPath;
|
|
416
|
-
});
|
|
417
|
-
|
|
418
|
-
// ✅ CORRECT — path.resolve produces matching keys on all platforms
|
|
419
|
-
const mockDir = path.resolve('/safe/dir');
|
|
420
|
-
const linkedResolved = path.resolve(mockDir, 'linked.ts');
|
|
421
|
-
const outsideResolved = path.resolve('/outside/linked.ts');
|
|
422
|
-
|
|
423
|
-
// mockRealpathSync is a mock() function (bun:test) — see mocking patterns above
|
|
424
|
-
mockRealpathSync.mockImplementation((inputPath: string) => {
|
|
425
|
-
if (inputPath === mockDir) return mockDir;
|
|
426
|
-
if (inputPath === linkedResolved) return outsideResolved;
|
|
427
|
-
return inputPath;
|
|
428
|
-
});
|
|
429
|
-
```
|
|
430
|
-
|
|
431
|
-
### Symlink behavior differences
|
|
432
|
-
|
|
433
|
-
- On Windows, `fs.symlinkSync` for directories creates **junctions** by default, which
|
|
434
|
-
resolve differently than POSIX symlinks. Junction creation may require administrator
|
|
435
|
-
elevation on older Node.js versions.
|
|
436
|
-
- `fs.realpathSync` on a broken symlink throws `ENOENT` on POSIX but may throw
|
|
437
|
-
`EINVAL` on Windows, depending on symlink type.
|
|
438
|
-
- Use `test.skipIf(process.platform === 'win32')` for tests that directly manipulate
|
|
439
|
-
filesystem symlinks, unless the test's purpose is explicitly to verify cross-platform
|
|
440
|
-
symlink behavior.
|
|
441
|
-
|
|
442
|
-
### Temporary directory patterns
|
|
443
|
-
|
|
444
|
-
- Use `os.tmpdir()` + `path.join()` for temp paths. **Never** hardcode `/tmp` or `C:\`.
|
|
445
|
-
- Wrap `mkdtempSync` in `realpathSync` if the result is `chdir`'d on macOS (temp
|
|
446
|
-
dirs are often symlinked to `/private/var/...`).
|
|
447
|
-
- Clean up temp dirs in `afterEach` or `afterAll` with a bounded helper that
|
|
448
|
-
verifies the resolved cleanup target is a child of `os.tmpdir()` before
|
|
449
|
-
calling recursive `rm`. Reuse `tests/helpers/safe-test-dir.ts` when possible.
|
|
450
|
-
Do not call recursive `rm` on a computed path unless the helper has rejected
|
|
451
|
-
empty strings, `os.tmpdir()` itself, and paths outside the temp root.
|
|
452
|
-
|
|
453
|
-
### Platform-specific environment variable redirection
|
|
454
|
-
|
|
455
|
-
When tests redirect `process.env.HOME` to isolate path-resolver-dependent code
|
|
456
|
-
(functions like `resolveHiveKnowledgePath`, `resolveSwarmKnowledgePath`, or any
|
|
457
|
-
function that reads `os.homedir()` / platform env vars), they MUST redirect ALL
|
|
458
|
-
platform-specific env vars, not just `HOME`. A partial redirect silently falls
|
|
459
|
-
back to the real user profile on some platforms, causing tests to read/write
|
|
460
|
-
actual user data instead of the isolated temp directory.
|
|
461
|
-
|
|
462
|
-
Per-platform requirements:
|
|
463
|
-
|
|
464
|
-
- **Linux**: redirect `HOME`, `XDG_CONFIG_HOME`, and `XDG_DATA_HOME`.
|
|
465
|
-
- **macOS**: redirect `HOME` (macOS resolves `~/Library/Application Support` from
|
|
466
|
-
the home directory).
|
|
467
|
-
- **Windows**: redirect `HOME`, `LOCALAPPDATA`, AND `APPDATA`. Windows path
|
|
468
|
-
resolvers read `LOCALAPPDATA` and `APPDATA`, neither of which is derived from
|
|
469
|
-
`HOME`. Redirecting only `HOME` silently fails on Windows, causing tests to
|
|
470
|
-
touch the real `%LOCALAPPDATA%` and `%APPDATA%` trees.
|
|
471
|
-
|
|
472
|
-
> **⚠️ Bun caches `os.homedir()` on first call.** If a module calls `os.homedir()`
|
|
473
|
-
> before the test sets `process.env.HOME`, the cached value persists for the
|
|
474
|
-
> lifetime of the process and later env changes are silently ignored. Set
|
|
475
|
-
> `process.env.HOME` (and other redirected vars) **before** importing any module
|
|
476
|
-
> that calls `os.homedir()`. The source code documents this at
|
|
477
|
-
> `src/hooks/knowledge-store.ts`: "Bun caches os.homedir(), so changing $HOME
|
|
478
|
-
> after first call is ignored."
|
|
479
|
-
|
|
480
|
-
Use per-variable save/restore rather than saving and replacing the entire
|
|
481
|
-
`process.env` object — the latter discards process-level env state and can
|
|
482
|
-
interfere with other test infrastructure:
|
|
483
|
-
|
|
484
|
-
```typescript
|
|
485
|
-
import { beforeEach, afterEach } from 'bun:test';
|
|
486
|
-
import os from 'node:os';
|
|
487
|
-
import path from 'node:path';
|
|
488
|
-
|
|
489
|
-
const saved = {
|
|
490
|
-
HOME: process.env.HOME,
|
|
491
|
-
LOCALAPPDATA: process.env.LOCALAPPDATA,
|
|
492
|
-
APPDATA: process.env.APPDATA,
|
|
493
|
-
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
|
494
|
-
XDG_DATA_HOME: process.env.XDG_DATA_HOME,
|
|
495
|
-
};
|
|
496
|
-
|
|
497
|
-
beforeEach(() => {
|
|
498
|
-
const isolatedDir = path.join(os.tmpdir(), 'test-home');
|
|
499
|
-
process.env.HOME = isolatedDir;
|
|
500
|
-
process.env.LOCALAPPDATA = isolatedDir;
|
|
501
|
-
process.env.APPDATA = isolatedDir;
|
|
502
|
-
process.env.XDG_CONFIG_HOME = isolatedDir;
|
|
503
|
-
process.env.XDG_DATA_HOME = isolatedDir;
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
afterEach(() => {
|
|
507
|
-
for (const [key, value] of Object.entries(saved)) {
|
|
508
|
-
if (value === undefined) delete process.env[key];
|
|
509
|
-
else process.env[key] = value;
|
|
510
|
-
}
|
|
511
|
-
});
|
|
512
|
-
```
|
|
513
|
-
|
|
514
|
-
For cross-file isolation (tests that must survive across multiple files in the
|
|
515
|
-
same process, e.g. batch steps), use `beforeAll` / `afterAll` with the same
|
|
516
|
-
per-var save/restore pattern. Never mutate `process.env` without restoring it in
|
|
517
|
-
a matching teardown hook.
|
|
518
|
-
|
|
519
|
-
**Preferred approach:** Use `createIsolatedTestEnv()` from
|
|
520
|
-
`tests/helpers/isolated-test-env.ts`. It handles `XDG_CONFIG_HOME`, `APPDATA`,
|
|
521
|
-
`LOCALAPPDATA`, and `HOME` with correct per-variable save/restore and returns a
|
|
522
|
-
cleanup function that removes the temp directory. Use this helper unless your
|
|
523
|
-
test has specific requirements it doesn't cover.
|
|
524
|
-
|
|
525
|
-
### Line ending normalization
|
|
526
|
-
|
|
527
|
-
Git on Windows converts LF to CRLF by default. Tests that compare file contents
|
|
528
|
-
byte-by-byte against expected strings must normalize line endings:
|
|
529
|
-
|
|
530
|
-
```typescript
|
|
531
|
-
const actual = readFileSync(path, 'utf-8').replace(/\r\n/g, '\n');
|
|
532
|
-
```
|
|
195
|
+
Detailed reference material moved to `.opencode/skills/writing-tests/references/cross-platform-patterns.md` (issue #2907 progressive-disclosure budget).
|
|
533
196
|
|
|
534
197
|
## CI Pipeline Structure
|
|
535
198
|
|
|
@@ -734,104 +397,11 @@ When a SKILL.md (or other agent-facing document) contains an **executable exampl
|
|
|
734
397
|
- Examples that are intentionally schematic ("the response looks roughly like this") rather than literal.
|
|
735
398
|
- Documentation that is auto-generated from source — drift is impossible by construction in that case.
|
|
736
399
|
|
|
737
|
-
|
|
400
|
+
Detailed reference material moved to `.opencode/skills/writing-tests/references/cross-platform-patterns.md` (issue #2907 progressive-disclosure budget).
|
|
738
401
|
|
|
739
|
-
> **See also**: [Cross-Platform Test Patterns](
|
|
402
|
+
> **See also**: [Cross-Platform Test Patterns](references/cross-platform-patterns.md) for detailed
|
|
740
403
|
> guidance on mock keys, symlink behavior, temp directories, and line endings.
|
|
741
404
|
|
|
742
|
-
All tests must pass on Linux, macOS, and Windows unless explicitly gated with:
|
|
743
|
-
```typescript
|
|
744
|
-
const isWindows = process.platform === 'win32';
|
|
745
|
-
if (isWindows) test.skip('reason', () => {});
|
|
746
|
-
```
|
|
747
|
-
|
|
748
|
-
### Path handling
|
|
749
|
-
- Use `path.join()` or `path.resolve()`, never string concatenation with `/`.
|
|
750
|
-
- Temp directories: use `os.tmpdir()`, not hardcoded `/tmp`.
|
|
751
|
-
- File comparisons: normalize paths before comparing (`path.resolve(a) === path.resolve(b)`).
|
|
752
|
-
|
|
753
|
-
### Process spawning
|
|
754
|
-
- Use `.cmd` extension on Windows for npm/bun binaries: `process.platform === 'win32' ? 'bun.cmd' : 'bun'`.
|
|
755
|
-
- Use array-form `spawn`/`spawnSync`, never shell string commands.
|
|
756
|
-
|
|
757
|
-
### macOS rename-visibility race (write-then-read atomic files)
|
|
758
|
-
|
|
759
|
-
On macOS/APFS, `fs.renameSync` can complete before the data is visible to
|
|
760
|
-
subsequent reads. Tests that write-then-read atomic files may fail on
|
|
761
|
-
`macos-latest` but pass on `ubuntu-latest` and `windows-latest`.
|
|
762
|
-
|
|
763
|
-
**Symptom:** Test fails only on macOS with `result === null` or
|
|
764
|
-
`result === undefined` immediately after a write that should have made the
|
|
765
|
-
file visible.
|
|
766
|
-
|
|
767
|
-
**Root cause:** macOS filesystem updates the directory entry asynchronously
|
|
768
|
-
after `fs.renameSync`. Immediately-following reads may see ENOENT or stale
|
|
769
|
-
data.
|
|
770
|
-
|
|
771
|
-
**Fix — three layers (use all three for production code):**
|
|
772
|
-
|
|
773
|
-
**Layer 1: Use `bunWrite` for atomic writes.** The `bunWrite` function in
|
|
774
|
-
`src/utils/bun-compat.ts` already handles temp file creation, fsync,
|
|
775
|
-
atomic rename, and parent directory fsync correctly across platforms.
|
|
776
|
-
Do NOT reimplement this pattern.
|
|
777
|
-
|
|
778
|
-
**Layer 2: Add ENOENT retry in the read path.** Wrap `validateSwarmPath` and
|
|
779
|
-
the file read in a try/catch with a bounded retry loop for ENOENT:
|
|
780
|
-
|
|
781
|
-
```typescript
|
|
782
|
-
// CORRECT — retry on ENOENT only (not other errors), bounded
|
|
783
|
-
const maxAttempts = 5;
|
|
784
|
-
const retryDelayMs = 10;
|
|
785
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
786
|
-
try {
|
|
787
|
-
const resolvedPath = _internals.validateSwarmPath(directory, filename);
|
|
788
|
-
const file = bunFile(resolvedPath);
|
|
789
|
-
const content = await file.text();
|
|
790
|
-
return content;
|
|
791
|
-
} catch (err) {
|
|
792
|
-
const isNotFound = (err as NodeJS.ErrnoException)?.code === 'ENOENT';
|
|
793
|
-
if (!isNotFound || attempt === maxAttempts - 1) {
|
|
794
|
-
return null;
|
|
795
|
-
}
|
|
796
|
-
await new Promise((resolve) => setTimeout(resolve, retryDelayMs));
|
|
797
|
-
}
|
|
798
|
-
}
|
|
799
|
-
return null;
|
|
800
|
-
```
|
|
801
|
-
|
|
802
|
-
CRITICAL: `validateSwarmPath` must be INSIDE the try block so that throws
|
|
803
|
-
(for path traversal attempts) are caught and return null. Security tests
|
|
804
|
-
expect `readSwarmFileAsync` to return null for traversal attempts.
|
|
805
|
-
|
|
806
|
-
**Layer 3: Don't add arbitrary delays.** `setTimeout` should be bounded
|
|
807
|
-
(5-10ms, max 5 attempts). Do not add `await new Promise(r => setTimeout(r, 100))`
|
|
808
|
-
"just in case" — that's a code smell. The retry loop handles it.
|
|
809
|
-
|
|
810
|
-
See [`.opencode/skills/engineering-conventions/SKILL.md`](../engineering-conventions/SKILL.md)
|
|
811
|
-
for the evidence file flow that triggers this retry pattern in QA gates.
|
|
812
|
-
|
|
813
|
-
### Node FileHandle API
|
|
814
|
-
|
|
815
|
-
Node's `FileHandle` uses `.sync()`, NOT `.fsync()`:
|
|
816
|
-
|
|
817
|
-
```typescript
|
|
818
|
-
// CORRECT
|
|
819
|
-
const fd = await fsPromises.open(dir, 'r');
|
|
820
|
-
try {
|
|
821
|
-
await fd.sync();
|
|
822
|
-
} finally {
|
|
823
|
-
await fd.close();
|
|
824
|
-
}
|
|
825
|
-
|
|
826
|
-
// WRONG — TypeScript error: Property 'fsync' does not exist on type 'FileHandle'
|
|
827
|
-
const fd = await fsPromises.open(dir, 'r');
|
|
828
|
-
try {
|
|
829
|
-
await fd.fsync();
|
|
830
|
-
} finally {
|
|
831
|
-
await fd.close();
|
|
832
|
-
}
|
|
833
|
-
```
|
|
834
|
-
|
|
835
405
|
## Running Tests
|
|
836
406
|
|
|
837
407
|
For the full test execution commands (bash and PowerShell per-file isolation loops, CI integration), read `file:.swarm/bundled-skills/running-tests/SKILL.md`. The key principle: always run one test file per process (`bun --smol test <file> --timeout 30000`) to prevent `mock.module` cross-contamination.
|