Skip to main content

Testing Plan

Last updated: 2026-08-08


Philosophy

Tests are organized in three tiers matching how failure propagates:

  1. Unit — pure functions and isolated modules; fast, no I/O, run on every save
  2. Integration — pipeline stages wired together; require real files, real manifests, subprocess invocations
  3. E2E / cross-agent — full hook invocation from stdin to stdout across different agent protocols

Coverage target: ≥ 85% line coverage across core/ and hooks/lib/. Scanner modules target ≥ 80% given their I/O-heavy nature.


Test Framework

Node.js built-in node:test with node:assert/strict. No additional runtime dependencies.

node --test # run all tests
node --test --test-coverage # with coverage report
node --test tests/unit/ # unit only
node --test tests/integration/ # integration only

Fixtures live in tests/fixtures/. Helpers in tests/helpers/.


Unit Tests

core/match.mjsmatchLessons + findBlocker

Coverage target: 100% (pure functions, fully deterministic)

TestWhat it verifies
commandPattern matchA lesson with a matching commandRegexSources is returned
commandPattern no matchNon-matching command returns empty array
pathPattern matchA lesson with matching pathRegexSources on filePath is returned
pathPattern no matchNon-matching path returns empty
toolName filterLesson with wrong toolNames is excluded even if pattern matches
priority sortMultiple matches returned sorted priority descending
invalid regex skippedLesson with unparseable regex is silently skipped, no throw
findBlocker returns first blockingFirst type: 'guard' lesson returned, {command} substituted
findBlocker skips non-blockingNo blocker if no matching lesson has type: 'guard'
findBlocker command truncated at 120{command} capped at 120 chars in the rendered message
empty lessons objectReturns [] gracefully
missing fields on lessonDefaults applied correctly (type: 'hint', priority: 5)

core/select.mjsselectCandidates

Coverage target: 100%

TestWhat it verifies
basic injection1 match → 1 injected, claimFn called, seen set updated
dedup: already seen slug excludedSlug in seenSet → not injected
maxLessons cap4 matches, maxLessons=3 → first 3 injected
budget: second lesson fitsTwo lessons within budget → both injected
budget: second lesson too large → summary fallbackLesson text exceeds remaining bytes → falls back to **Lesson**: {summary}
budget: summary also too large → droppedNeither text nor summary fits → slug in dropped
first lesson always injected regardless of budgetEven if text > budgetBytes, first lesson is injected
claimFn returns false → droppedConcurrent claim lost → slug in dropped
claimFn called once per candidateclaimFn invocation count matches candidates attempted
empty matches → empty injectedReturns { injected: [], dropped: [], seen: original }
seen set is a new Set (not mutated input)Input seenSet is not mutated

hooks/lib/output.mjsformatHookOutput + formatEmptyOutput

Coverage target: 100%

TestWhat it verifies
formatEmptyOutputReturns '{}' string
with context and lessonsSeenOutput has hookSpecificOutput.additionalContext and env.LESSONS_SEEN
with context only (no seen)No env key if lessonsSeen is empty/null
no context (empty string)No hookSpecificOutput key
metadata comment present<!-- lessonInjection: {...} --> appended to additionalContext
output is valid JSONJSON.parse succeeds on all variants

hooks/lib/stdin.mjsparseHookInput

Coverage target: 95% (fd 0 read is mocked via stdin fixture injection)

TestWhat it verifies
valid Bash payloadReturns { toolName: 'Bash', toolInput, sessionId, agentId, cwd }
valid Read payloadReturns with toolName: 'Read'
unsupported tool (Task)Returns null
malformed JSONReturns null
empty stdinReturns null
missing tool_nameReturns null
missing session_id → empty string defaultsessionId defaults to ''
missing cwd → empty string defaultcwd defaults to ''

hooks/lib/dedup.mjsloadSeenSet, claimLesson, persistSeenState

Coverage target: 85% (uses real tmpdir, no mocking needed)

TestWhat it verifies
loadSeenSet: empty (no env, no files)Returns empty Set
loadSeenSet: reads env var LESSONS_SEENSet contains slugs from env
loadSeenSet: reads seen fileSet contains slugs from temp file
loadSeenSet: reads claim directorySet contains entries from claim dir
loadSeenSet: merges all three layersUnion of all three sources
claimLesson: first claim succeedsReturns true, file created in claim dir
claimLesson: second claim same slug → falseO_EXCL prevents double-claim
claimLesson: different slug same sessionReturns true (different file)
persistSeenState: writes slugs to fileFile contents match [...seenSet].join(',')
persistSeenState: returns slug stringReturn value matches file contents
loadSeenSet uses unique path per sessionIdTwo sessions don't share state

scripts/scanner/structured.mjsparseLessonTags + scanLineForLessons

Coverage target: 95%

TestWhat it verifies
parseLessonTags: basic tagReturns candidate with tool, trigger, mistake, fix, tags
parseLessonTags: multiple tags in one blockBoth candidates returned
parseLessonTags: inside code fenceCode fence delimiters stripped, block parsed correctly
parseLessonTags: missing mistake → skippedIncomplete block not returned
parseLessonTags: missing fix → skippedIncomplete block not returned
parseLessonTags: tags parsed as arrayComma-separated tags become string[]
parseLessonTags: no tags field → empty arrayDefaults to []
parseLessonTags: non-string input → emptynull, undefined, number return []
scanLineForLessons: non-assistant line → emptyUser message line returns []
scanLineForLessons: no #lesson marker → emptyFast-path rejection
scanLineForLessons: malformed JSON → emptyNo throw
scanLineForLessons: assistant with text blockDelegates to parseLessonTags
scanLineForLessons: attaches sessionId/messageIdContext attached from JSONL envelope

scripts/scanner/extractor.mjsextractFromStructured, extractFromHeuristic, scoring

Coverage target: 90%

TestWhat it verifies
extractFromStructured: full tagAll fields normalized, contentHash computed
extractFromStructured: confidence high with tool+trigger+tags≥ 0.75
extractFromStructured: confidence lower without optional fields0.6 baseline
extractFromHeuristic: basic windowmistake, remediation, tool, trigger extracted
extractFromHeuristic: needsReview always trueHeuristic candidates always flagged
extractFromHeuristic: tool from errorTurntool pulled from error turn toolName
extractFromHeuristic: trigger from preceding tool_callcommand/file_path extracted
scoreCandidateConfidence: user correction bonus+0.15 for userCorrection signal
scoreCandidateConfidence: multiple error signals bonus+0.1 for ≥2 error signals
scoreCandidatePriority: severity tags boost priorityhang/data-loss adds +1 each
inferTags: python tool detectedlang:python added from trigger
contentHash deterministicSame inputs → same hash
contentHash differs for different contentDifferent mistake → different hash

Integration Tests

Integration tests invoke real code against real files (fixtures, temp dirs). They test pipeline stages wired together.

Hook pipeline: stdin → stdout

Tests pipe JSON to pretooluse-lesson-inject.mjs as a subprocess and assert on stdout.

Fixture: a minimal lesson-manifest.json with 2 lessons — one matching, one blocking.

TestWhat it verifies
matching command → injects lessonstdout contains hookSpecificOutput.additionalContext
non-matching command → {}stdout is exactly {}
blocking lesson → deny decisionstdout contains permissionDecision: "deny"
already-seen slug (env var set) → {}With LESSONS_SEEN=<slug> in env, returns {}
malformed stdin → {}Bad JSON on stdin returns {}, exit 0
missing manifest → {}Pointing hook at non-existent manifest returns {}, exit 0
Read tool with matching pathfilePath match triggers injection
multi-lesson match respects maxLessonsOnly top N returned
additionalContext contains lessonInjection commentMetadata comment present
env.LESSONS_SEEN set in outputenv.LESSONS_SEEN contains injected slug

CLI: lessons add + lessons build

Tests use a temp store (via LESSONS_DATA_DIR) to avoid polluting the real lessons.db.

TestWhat it verifies
add via --jsonLesson appears in store after add
add triggers manifest rebuildlesson-manifest.json updated after add
add: duplicate content hash rejectedExit non-zero, lesson not duplicated
add: fuzzy duplicate rejectedJaccard ≥ 0.5 exits non-zero
add: validation failure rejectsShort mistake exits non-zero with message
build: excluded lessons not in manifestBelow-minConfidence lesson absent from manifest
build: included lessons have correct shapecommandRegexSources, slug, message all present
list: outputs all lessonsCount matches the DB
list --json: valid JSON arrayJSON.parse succeeds

Scanner: incremental scan against fixture JSONL

Fixture: two JSONL files — one with a #lesson tag, one without.

TestWhat it verifies
scan --tier1-only --dry-runCandidate extracted from tagged file
scan incremental: second scan skips processed bytesOffset advanced, no duplicate candidates
scan --full resets offsetsBoth files re-scanned
scan on empty directoryExits 0, no candidates

E2E / Cross-Agent Tests

These tests validate that the hook protocol is correctly interpreted by each agent runtime. They confirm the JSON schema contract, not the business logic.

Each test pipes a well-formed hook payload through the injection hook and asserts on the output schema.

Claude Code (baseline)

The production protocol. All integration tests implicitly validate this.

FieldExpected
Input tool_name"Bash", "Read", "Edit", "Write", "Glob"
Input tool_input.commandBash command string
Input tool_input.file_pathAbsolute path for file tools
Input session_idUUID string
Output (inject){ hookSpecificOutput: { additionalContext: "..." }, env: { LESSONS_SEEN: "..." } }
Output (block){ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "deny", permissionDecisionReason: "..." } }
Output (no match){}
TestWhat it verifies
Bash injectFull round-trip, output schema valid
Read injectFile path match, output schema valid
block decisionDeny schema, reason contains corrected command
no matchExact {} output

Codex

Codex uses different tool names for equivalent operations.

Codex toolMapped to
shellBash
apply_patchEdit
read_fileRead

The adapter layer (or a normalization step) must map Codex tool names to the canonical set before matching. Tests verify the mapping works correctly.

TestWhat it verifies
shell tool → Bash matchingtool_name: "shell" triggers command pattern match
read_file tool → Read matchingtool_name: "read_file" triggers path pattern match
apply_patch tool → Edit matchingtool_name: "apply_patch" triggers path pattern match
unknown Codex tool → {}Unsupported tool returns {}, no error
note

Current status: Implemented in tests/e2e/codex.test.mjs, using LESSONS_AGENT_PLATFORM=codex and the tool-name normalization in hooks/lib/normalize-tool.mjs.

Gemini CLI

Gemini CLI uses its own hook protocol. Tool names may differ from Claude Code.

Gemini toolMapped to
run_shell_commandBash
read_fileRead
write_fileWrite
replace_in_fileEdit
TestWhat it verifies
run_shell_command → Bash matchingCommand pattern fires
read_file → Read matchingPath pattern fires
write_file → Write matchingPath pattern fires
replace_in_file → Edit matchingPath pattern fires
unknown Gemini tool → {}Returns {}, no error
note

Current status: Implemented in tests/e2e/gemini.test.mjs, using LESSONS_AGENT_PLATFORM=gemini and the same normalization layer as Codex.

Protocol schema validation

Cross-agent tests also validate the output schema is well-formed regardless of which agent produced the input.

TestWhat it verifies
inject output is valid JSONJSON.parse succeeds
inject output: only known keys presentNo extra keys outside schema
block output: permissionDecision is "deny"Exact string, not "block" or "reject"
block output: reason is non-empty stringNo null or empty permissionDecisionReason
empty output is exactly "{}"Not null, not "", not "{ }"

Coverage Map

ModuleTargetUnitIntegration
core/match.mjs100%via hook pipeline
core/select.mjs100%via hook pipeline
hooks/lib/output.mjs100%via hook pipeline
hooks/lib/stdin.mjs95%via hook pipeline
hooks/lib/dedup.mjs85%via hook pipeline
hooks/pretooluse-lesson-inject.mjs90%
scripts/scanner/structured.mjs95%via scan integration
scripts/scanner/extractor.mjs90%via scan integration
scripts/scanner/detector.mjs80%
scripts/scanner/incremental.mjs85%
scripts/lessons.mjs70%✓ (CLI subprocess)

Explicit exclusions from coverage:

  • hooks/session-start-*.mjs — thin glue scripts, tested manually during deployment validation
  • data/*.json — not code
  • schemas/ — not code

Test File Structure

tests/
unit/
core/
match.test.mjs
select.test.mjs
hooks/
output.test.mjs
stdin.test.mjs
dedup.test.mjs
normalize.test.mjs # Codex/Gemini -> canonical tool name mapping
precompact.test.mjs # transcript parsing for handoff generation
session-start.test.mjs # shared SessionStart/SubagentStart injection logic
scanner/
structured.test.mjs
extractor.test.mjs
detector.test.mjs
plugin-integrity.test.mjs # plugin.json / commands / hooks.json consistency
integration/
hook-pipeline.test.mjs # stdin→stdout subprocess tests
cli-lessons.test.mjs # lessons add/build/list subprocess tests
cli-doctor.test.mjs # doctor subprocess tests
precompact-hook.test.mjs # PreCompact hook subprocess tests
scan-incremental.test.mjs # scanner against fixture JSONL
e2e/
claude-code.test.mjs # CC protocol round-trips
codex.test.mjs # Codex tool name mapping
gemini.test.mjs # Gemini CLI tool name mapping
schema.test.mjs # Output schema validation across agents
fixtures/
minimal-manifest.json # 2 lessons: 1 matching, 1 blocking
lessons-store.json # Seed data for tmpstore.mjs — {lessons: [...]} loaded into a temp lessons.db
session-with-lesson.jsonl # JSONL with embedded #lesson tag
session-no-lesson.jsonl # JSONL without any lesson tags
session-precompact.jsonl # JSONL fixture for PreCompact handoff tests
helpers/
subprocess.mjs # spawn + collect stdout/stderr
tmpstore.mjs # creates an isolated temp lessons store (LESSONS_DATA_DIR)
fixtures.mjs # loads fixture files by name