14 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 288 | Extract shared test fixtures to cut permission-system test duplication |
Extract shared test fixtures to cut permission-system test duplication
Problem Statement
The pi-permission-system test tree carries the package's single largest health-score deduction.
fallow dupes reports 9.1% duplication across 122 clone groups, and the clones are almost entirely repeated handler/session setup, gate-descriptor construction, and config-manager harness code copied verbatim across test files.
The same makeCtx / makeSession / makeToolRegistry / makeCheckResult factories are redefined in five-plus files, and a 120-line setup block is duplicated between the two external-directory test files.
This is mechanical copy-paste, not intentional per-file divergence, so it can be consolidated into shared fixtures without changing what any test asserts.
Goals
- Extract the duplicated test setup into focused modules under
test/helpers/, mirroring thepi-subagents/test/helpers/convention. - Migrate the top clone families to the shared fixtures incrementally, one family per commit, keeping the full suite green at every step.
- Reduce
fallow dupesclone-group count and the duplication deduction in the package health score. - Preserve every existing assertion — this is a pure test refactor with the existing suite as the safety net.
Non-Goals
- No production-code changes under
src/. This work is orthogonal to the decomposition issues (#285–#289) and touches onlytest/. - No co-located helper tests.
The factories are simple object builders exercised transitively by the migrated suites; we do not add
test/helpers/*.test.tsfiles (unlike pi-subagents). - No attempt to eliminate every one of the 122 clone groups. We target the named families; long-tail single-line clones are out of scope.
- No change to the
vitest.config.tsalias setup —#test/*already resolves totest/.
Background
Relevant existing structure:
vitest.config.tsaliases#test→test/and#src→src/;tsconfig.jsonandpackage.jsonmirror#test/*and#src/*. Shared helpers can be imported via relative paths (the pi-subagents convention) or#test/helpers/....pi-subagents/test/helpers/is the established sibling convention: focused files by concern (make-deps.ts,make-subagent.ts,mock-session.ts,stub-ctx.ts,ui-stubs.ts). This plan follows the focused-files layout but omits the co-located helper tests that pi-subagents adds.
Confirmed clone families (from fallow dupes):
| Family | Files | Shared factories |
|---|---|---|
| Handler fixtures | handlers/tool-call-events.test.ts, handlers/tool-call.test.ts, handlers/input-events.test.ts, permission-session.test.ts |
makeCtx, makeEvents, makeSession, makeToolRegistry, makeToolCallEvent, makeCheckResult, makeHandler |
| External-directory | handlers/external-directory-integration.test.ts, handlers/external-directory-session-dedup.test.ts |
the 120-line block: makeCheckPermission, makeCtx, makeToolCallEvent, plus handler fixtures |
| Gate fixtures | handlers/gates/runner.test.ts, handlers/gates/bash-path.test.ts, handlers/gates/path.test.ts |
makeDescriptor, makeRunnerDeps, makeTcc, makeCheckResult |
| Manager harness | permission-system.test.ts (intra-file groups, e.g. lines 891-943) |
createManager, extension-harness builder, config/ruleset builders |
| Lifecycle setup | handlers/before-agent-start.test.ts, handlers/lifecycle.test.ts |
shared before-agent-start ctx/state setup |
AGENTS.md constraints that apply:
- Lift-and-shift rule: never rewrite a large test file in one step. Introduce the shared fixture alongside the existing inline copies, migrate file-by-file, and delete the inline copies last.
- When a fix changes shared helper functions, run the full suite before committing (testing skill).
Critical divergence to preserve (testing skill — "diff defaults before consolidating"): the makeCheckResult copies do not share defaults.
handlers/gates/runner.test.ts:{ state, toolName: "read", source: "tool", origin: "builtin", matchedPattern: "*" }.handlers/gates/bash-path.test.ts:{ toolName: "path", state, source: "special", origin: "global" }(nomatchedPattern).handlers/tool-call.test.tsmakePermissionResult:{ state, toolName: "read", source: "tool", origin: "builtin" }(nomatchedPattern).
Per the #288 design decision, the shared factory uses one makeCheckResult with a single neutral default; each migrated call site passes the fields it currently relies on as explicit overrides so behavior is unchanged.
Design Overview
Three focused helper modules under test/helpers/, plus the harness module:
test/helpers/handler-fixtures.ts— handler-level mocks and builders.test/helpers/gate-fixtures.ts— gate descriptor / runner-deps / tool-call-context builders.test/helpers/manager-harness.ts— filesystem-backedPermissionManagerharness and config builders forpermission-system.test.ts.
makeCheckResult shape (single neutral default, override-driven):
import { vi } from "vitest";
import type { PermissionCheckResult } from "#src/types";
export function makeCheckResult(
overrides: Partial<PermissionCheckResult> = {},
): PermissionCheckResult {
return {
state: "allow",
toolName: "read",
source: "tool",
origin: "builtin",
...overrides,
};
}
Migration discipline: at each makeCheckResult(...) call site, pass exactly the fields the original local copy hard-coded.
For example, the bash-path sites migrate to makeCheckResult({ toolName: "path", source: "special", origin: "global" }), and runner sites that depended on matchedPattern: "*" pass it explicitly.
Factory signature notes (testing skill):
- Return types annotated with the production interface (
PermissionCheckResult,GateDescriptor,GateRunnerDeps) — these are plain data builders whose callers do not needMock<...>accessors on the returned object. makeHandler/makeRunnerDepsreturn objects whosevi.fn()members are configured by tests; keep the returned mock objects' types inferred (do not annotate the bag with the production interface) so callers retain.mockReturnValueaccess on the stub fields, matching the existing inline copies.- Reuse the existing
Partial<...> = {}override style already present in the inline copies — no new override semantics.
The makeSession variants differ slightly: input-events.test.ts takes a positional state argument, others take only an overrides bag, and input-events includes createPermissionRequestId while the others include getInfrastructureDirs/getActiveSkillEntries.
The shared makeSession takes an overrides bag containing the union of mocked methods (each defaulted), and the input-events call sites pass checkPermission overrides explicitly instead of a positional state.
Edge cases:
external-directory-integration.test.tshas a documented regression guard that imports the four external-directory message helpers so the file fails to load if any is removed. Keep that import in the file after migration — do not move it into a helper.permission-system.test.tsmixes real filesystem harness setup (mkdtempSync,writeFileSync) with env isolation; extract only the repeatedcreateManager+ config-builder clones, leavingwithIsolatedSubagentEnvand the env-key handling in place.
Module-Level Changes
New files:
test/helpers/handler-fixtures.ts—makeCtx,makeEvents,makeSession,makeToolRegistry,makeToolCallEvent,makeCheckResult,makeHandler, and the external-directorymakeCheckPermissionbuilder.test/helpers/gate-fixtures.ts—makeDescriptor,makeRunnerDeps,makeTcc, plus a gate-flavoredmakeCheckResultre-export or the shared one with override presets passed at the call site.test/helpers/manager-harness.ts—createManagerand the repeated config/ruleset builders frompermission-system.test.ts.
Changed files (remove inline copies, import from helpers):
test/handlers/tool-call-events.test.tstest/handlers/tool-call.test.tstest/handlers/input-events.test.tstest/handlers/input.test.tstest/permission-session.test.tstest/handlers/external-directory-integration.test.tstest/handlers/external-directory-session-dedup.test.tstest/handlers/gates/runner.test.tstest/handlers/gates/bash-path.test.tstest/handlers/gates/path.test.tstest/permission-system.test.tstest/handlers/before-agent-start.test.ts,test/handlers/lifecycle.test.ts(lifecycle family — only if step 5 is in scope)
Docs:
docs/architecture/architecture.md— the duplication track in the Phase 2 roadmap references this work; update the duplication figure / mark the item progressed once the families are migrated. Check for a clone-count or health-score table that names these test files and refresh it.
No src/ changes, no schema/config/README changes (this issue touches no permission surface).
Test Impact Analysis
This is a test-refactor issue, so the standard extraction questions invert:
- New tests enabled: none required. The decision (#288) is to skip co-located helper tests; the migrated suites cover the factories transitively.
- Tests becoming redundant: the duplicated inline factory definitions are the redundancy being removed. No assertion-bearing test becomes redundant — only setup boilerplate is deleted.
- Tests that must stay as-is: every assertion in every migrated file.
The migration must not alter a single
expect(...); only the construction of inputs moves to shared factories. The external-directory regression-guard import stays in its file.
Verification at each step is "full suite stays green," not red→green — the existing suite is the safety net for the refactor.
Migration Order (refactor cycles)
Each step: create or extend a helper module, migrate one clone family's call sites to it, delete the now-dead inline copies, run the full suite (pnpm --filter @gotgenes/pi-permission-system exec vitest run) and pnpm run check, then commit.
No production behavior changes, so commits use test:.
-
Handler fixtures + first consumers. Create
test/helpers/handler-fixtures.tswith the neutral-default factories. Migratetool-call-events.test.ts,tool-call.test.ts,input-events.test.ts,input.test.ts, and themakeSessionclone inpermission-session.test.ts. Convert positional-statemakeSessioncall sites to override-bag form. Commit:test: extract shared handler fixtures (#288). -
External-directory family. Move the 120-line shared block (
makeCheckPermission, ext-dirmakeCtx/makeToolCallEvent) intohandler-fixtures.ts(or atest/helpers/external-directory-fixtures.tsif it does not generalize cleanly). Migrateexternal-directory-integration.test.tsandexternal-directory-session-dedup.test.ts, keeping the regression-guard import in the integration file. Commit:test: dedupe external-directory integration fixtures (#288). -
Gate fixtures. Create
test/helpers/gate-fixtures.tswithmakeDescriptor,makeRunnerDeps,makeTcc. Migrategates/runner.test.ts,gates/bash-path.test.ts,gates/path.test.ts, passing each surface's defaults as explicitmakeCheckResultoverrides. Commit:test: extract shared gate fixtures (#288). -
Manager harness. Create
test/helpers/manager-harness.tswithcreateManagerand the repeated config/ruleset builders. Migrate the intra-file clone groups inpermission-system.test.ts(e.g. lines ~891-943), leaving env-isolation helpers in place. Commit:test: extract permission-manager test harness (#288). -
Lifecycle setup (optional, scope permitting). Extract the shared
before-agent-startctx/state setup used bybefore-agent-start.test.tsandlifecycle.test.ts. Commit:test: dedupe before-agent-start lifecycle setup (#288). -
Docs refresh. Update the duplication track in
docs/architecture/architecture.mdwith the new clone-group count from a freshfallow dupesrun. Commit:docs: update duplication track after fixture extraction (#288).
Risks and Mitigations
- Risk: consolidating
makeCheckResultcopies with divergent defaults silently changes inputs and breaks (or worse, weakens) assertions. Mitigation: single neutral default + explicit per-call overrides preserving each original copy's values; full-suite green gate after every step. - Risk: annotating a mock-bag factory with the production interface erases
Mock<...>methods, breaking.mockReturnValuecall sites (testing skill). Mitigation: leavemakeHandler/makeRunnerDepsreturn types inferred; only annotate plain-data builders. - Risk: rewriting the 2839-line
permission-system.test.tsat once. Mitigation: lift-and-shift — extract harness alongside inline copies, migrate the targeted intra-file groups only, delete inline copies last. - Risk: removing the external-directory regression-guard import breaks its intended coverage. Mitigation: explicitly keep that import in the file; do not relocate it into a helper.
Open Questions
- Whether step 5 (lifecycle setup) lands in this issue or is deferred — decide during implementation based on how cleanly the
before-agent-startsetup generalizes. - Whether the ext-dir block belongs in
handler-fixtures.tsor its own module — defer until the migration reveals how much it shares with the generic handler fixtures.