Completes the #1074 migration for workflows. The per-file baseline (PR 1) is now the primary anti-creep guard, so the tier-max tighten-only ceilings are retired here. - Delete the 'SIZE: tier anti-creep' describe (3 assertTightCeiling tests) and the GRACE constant — per-file baseline already guards every file by name, strictly stronger than max(tier). - Convert the per-file tier test into 'SIZE: workflow tier hard caps': absolute red lines (XL 96 KiB / LARGE 60 KiB / DEFAULT 40 KiB) that mean 'extract, do not raise', each with real headroom above its high-water file. - Add a 32 KiB (Codex project_doc_max_bytes) cap for net-new workflow files not yet in the baseline and not explicitly tiered. - Rewrite the header doc comment for the new two-guard model; drop the assertTightCeiling import (now unused here; still exported + used by the agent test until PR 3). Addresses the three PR-2 items from the #1089 review: - Finish the enumeration consolidation (Minor #1): the tier hard-cap and new-file guards now read both their file list and byte sizes from measureWorkflows()/listWorkflowStems(), removing the inline readdirSync + per-file byteCount split-brain. Enumeration and measurement share one source. - Rewrite CONTEXT.md RULESET.WORKFLOW_SIZE_BUDGET (Minor #2): stale caps (XL<=90000/LARGE<=54000/DEFAULT<=38000) replaced with the baseline-first model, current hard caps, the new-file anchor, and the size:baseline remediation. - Ship docs (contract-change requirement): a Diataxis how-to + reference for the size guard in docs/TESTING-SUITES.md, beside the sibling regression-name ratchet (the 'file grew, CI red -> npm run size:baseline, commit the one-line diff, justify or extract lazily' workflow). CONTRIBUTING.md's workflow-tree note is updated to the baseline model and points at the new guide. Negative proof: each guard fails independently when violated (new-file cap at 33 KB; hard cap with baseline current; baseline on any per-file growth). Refs #1074. Part 2 of 3.
18 KiB
Testing Suites
This project's tests/ directory uses filename suffix markers to group tests into named suites. The harness scripts/run-tests.cjs filters by suite when given --suite <name>. Without a flag it runs every *.test.cjs file (the historical default — unchanged).
Tracked by issue #3597.
Suites
| Suite | Filename pattern | What goes here |
|---|---|---|
unit |
*.test.cjs (no other marker) |
Default fast lane. Pure logic, no network, no external processes beyond gsd-tools. Most tests live here. |
integration |
*.integration.test.cjs |
Cross-module flows: full installer end-to-end, multi-tool orchestration, anything that crosses two or more bin entry points. |
install |
*.install.test.cjs |
Tests that perform a real install/uninstall against a sandbox project. Slower; PR CI skips these on PRs and runs them on main push only. |
security |
*.security.test.cjs |
Adversarial input, prompt-injection guards, fixture-driven hostile-payload sweeps. |
slow |
*.slow.test.cjs |
Anything that routinely takes >5s wall-clock or holds significant memory. |
all |
(any) | Explicit alias for "no filter". Equivalent to running with no --suite flag. |
How to place a new test
- Pick the most specific bucket above.
- Name the file with the matching suffix:
tests/<feature>.<suite>.test.cjs. - If unsure, leave the suffix off — the file lands in
unit, the default fast lane.
Examples:
tests/agent-frontmatter.test.cjs—unittests/prompt-injection-guards.security.test.cjs—securitytests/installer-end-to-end.install.test.cjs—installtests/sdk-mutation-stress.slow.test.cjs—slow
The suite-suffix convention was chosen over a directory layout (tests/security/) so the 545+ existing test files don't need to move. Existing files all classify as unit until someone explicitly retags them.
Regression tests
Do not create new top-level tests/bug-NNNN-*.test.cjs files. Add the
regression case to the owning module's main test file instead (e.g. a
describe('regressions') block in tests/<module>.test.cjs).
node --test spawns one child process per FILE, so file count — not test
count — is the unit of CI overhead, and it is worst on Windows lanes where
every spawn is Defender-scanned. The 2026-06 CI audit found 244 one-off
bug-* files (~38% of the suite). That population is grandfathered in
scripts/lint-regression-test-names.allowlist.json and enforced by an
identity ratchet (npm run lint:regression-names, part of npm run lint:ci):
- A new
bug-*file fails CI — fold it into the owning module's file. - Deleting/consolidating a grandfathered file requires pruning its allowlist entry, so the baseline only ever shrinks.
- Inherited drift (the failure names files your PR didn't add — e.g. the
base branch merged
bug-*files without feeding the allowlist, or you rebased and carried a pre-rebase allowlist): runnode scripts/lint-regression-test-names.cjs --updateand commit the regenerated allowlist. Snapshot artifacts like this allowlist (anddocs/INVENTORY.md) must be regenerated after rebasing, never carried through a rebase.
The ratchet deliberately covers only bug-*. Files named feat-NNNN-* /
enh-NNNN-* are feature test files — one (or one per suite) per feature is
the sanctioned layout (see the #443 strategy below), not a one-off regression
pattern. If issue-*/perf-* one-offs start accumulating the same way
bug-* did, extend the ratchet's regex and regenerate the allowlist.
Workflow size budget
Tracked by issue #1074. Bytes (not lines) per #717; LF-normalized per #683.
Workflow files (gsd-core/workflows/*.md) ship in the installed runtime and are
loaded into the agent's context, so their byte size is a real cost. The size
guard in tests/workflow-size-budget.test.cjs keeps that cost from creeping up
invisibly. It is an anti-creep ratchet, sibling to the regression-name
ratchet above — three layers, ordered from day-to-day to last-resort:
| Layer | What it does | Where |
|---|---|---|
| Per-file baseline (primary) | Pins every workflow's exact current size in a committed snapshot. Any growth, shrink, add, or removal fails until the snapshot is regenerated — so sub-ceiling creep is caught by name and delta, not just at the tier's single largest file. | tests/workflow-size-baseline.json |
| Loose tier hard caps (backstop) | Absolute outer red lines per tier — XL ≤ 98304, LARGE ≤ 61440, DEFAULT ≤ 40960 bytes. Unlike the old tighten-only ceiling, a cap is never raised when a file approaches it: crossing it means extract, not bump. |
XL/LARGE/DEFAULT_CAP |
| New-file cap | A workflow not yet in the baseline must stay under 32768 bytes (the Codex project_doc_max_bytes anchor) unless explicitly tiered into XL_WORKFLOWS/LARGE_WORKFLOWS in the same PR. Keeps net-new orchestrators from being born oversized. |
NEW_FILE_CAP |
discuss-phase.md additionally has a thin-dispatcher target of < 32000 bytes
(issue #2551).
How-to: a workflow grew and CI is red
The baseline guard reports the file and the byte delta. To resolve:
- Regenerate the snapshot and inspect the one-line diff:
npm run size:baseline git diff tests/workflow-size-baseline.json - Justify the growth in your PR (a sentence in the description is enough) — the committed baseline diff is the review record that the larger size was a deliberate, seen decision, not silent drift.
- Or shrink it instead of baselining. Prefer extraction when the growth is
incidental: move per-mode bodies to
workflows/<name>/modes/, templates toworkflows/<name>/templates/, and shared prose togsd-core/references/— then load them LAZILY. Do not convert them to eager@-required_readingincludes: that shrinks the file's bytes without shrinking loaded context, so it games the guard while making the real cost worse. Seeworkflows/discuss-phase/for the progressive-disclosure pattern.
If a hard cap (not the baseline) is what failed, regeneration will not help — that is the signal to extract, per step 3.
Reference
| Artifact | Role |
|---|---|
scripts/workflow-size.cjs |
Single source of truth — LF-normalized byte counter (lfByteCount) plus workflow enumeration (listWorkflowStems, measureWorkflows). Imported by both the guard and the generator so they can never measure or enumerate differently. |
scripts/update-size-baseline.cjs (npm run size:baseline) |
Regenerates tests/workflow-size-baseline.json — sorted keys, trailing newline, idempotent. |
tests/workflow-size-baseline.json |
The committed per-file snapshot (one entry per workflow). |
tests/workflow-size-budget.test.cjs |
The three guards above, plus the discuss-phase progressive-disclosure checks. |
Running suites locally
npm test # everything (backcompat — same as before)
npm run test:unit # only unit
npm run test:integration # only integration
npm run test:install # only install
npm run test:security # only security
npm run test:slow # only slow
npm run test:coverage # backcompat — coverage over EVERY test
npm run test:coverage:unit # fast coverage signal — only unit suite
npm run test:coverage:all # alias for test:coverage
Direct harness invocation also works:
node scripts/run-tests.cjs --suite security
node scripts/run-tests.cjs --suite=security
node scripts/run-tests.cjs --files "tests/command-contract.test.cjs tests/core.test.cjs"
node scripts/run-tests.cjs --files-from .ci-selected-tests.txt
npm run test:affected (scripts/run-affected-tests.cjs) is a local-only
convenience that selects tests via the require() dependency graph of your
working-tree diff. CI does not use it — CI selection is the rule table in
scripts/ci-test-scope.cjs, which is the authoritative mapping. If the two
disagree, trust (and fix) the rule table.
Unknown suites exit non-zero with the list of valid suites. Empty suites (e.g. --suite security before any security-tagged file exists) exit 0 with a no tests in suite "..." notice on stderr so CI lanes don't go red while a suite is being populated.
CI matrix
The Tests workflow runs every PR through a scoped gate generated by
scripts/ci-test-scope.cjs.
| Lane | Node 22 | Node 24 |
|---|---|---|
ubuntu-latest |
scoped tests | unit + integration + security |
windows-latest |
— | scoped Windows/path/shell tests |
macos-latest |
full parity when required | full parity when required |
- Node 22 is the
engines.nodefloor (>=22.0.0) — must stay green. - Node 24 is the default development lane.
- Scoped tests are selected from the changed paths, plus a small CLI/package smoke set. They are for confidence on the affected surface, not for counting tests.
The default PR gate runs the broad unit (under the c8 coverage gate),
integration, and security suites once on Ubuntu / Node 24, scoped tests on
Ubuntu / Node 22, and scoped tests on Windows / Node 24. "Scoped" means the
diff-selected list from the rule table — not the full suite and not a fixed
smoke set (the fixed smoke list is only the empty-selection fallback). The
Windows lane's list is the Windows-sensitive subset of the selection, plus
every changed test file, unconditionally (the #494 invariant, narrowed): a
modified test is exercised on the divergent OS before merge at per-file cost,
without paying for the three full parity lanes.
PRs touching workflow, package, test-runner, install, release, or
Windows-sensitive surfaces also run the full parity matrix on macOS and the
older Windows runtime, plus install and slow on the primary Ubuntu lane.
Everything (including the full parity matrix) runs on every push to next,
which covers the residual macOS / Windows-Node-22 cross-product for scoped PRs.
Coverage runs inside the Ubuntu / Node 24 full lane (not a separate job — that
duplicated the entire unit run) and stays single-lane because multiplying
coverage across OS/runtime lanes adds cost without improving the threshold
signal. Note the gate's deliberate blind spot: it measures
gsd-core/bin/lib/*.cjs only — scripts/, hooks/, and bin/ are
unenforced, and stryker.config.mjs additionally excludes ~48% of lib lines
from mutation testing (see the UNMUTATED list there). Widening either gate is
tracked work, not an accident to "fix" silently by raising thresholds.
To inspect the scope locally:
npm run ci:test-scope -- --files "commands/gsd/plan-phase.md"
node scripts/ci-test-scope.cjs --base origin/next --head HEAD
Best practices for forward-compat (Node 24/26)
- Use
process.execPathwhen spawning Node in tests so each matrix lane exercises the lane's Node version. - Avoid stack-trace or error-message prose assertions. Assert
err.code, structured JSON fields, or enums — Node minor releases routinely tweak error wording. - Prefer
node:test,node:assert/strict, andnode:testmocks. No external test frameworks. - Coverage uses
c8and propagatesNODE_V8_COVERAGEthrough the harness's child process.
Test strategy: #443 effort + fast_mode engine
Feature: unified cross-provider effort and fast_mode knobs (issue #443). Test files:
tests/feat-443-effort-fast-mode.test.cjs(unit),tests/feat-443-effort-fast-mode.integration.test.cjs(integration).
Testing pyramid
| Layer | File | What it covers |
|---|---|---|
| Unit | feat-443-effort-fast-mode.test.cjs |
Pure logic: cascade rules, clamping, escalation math, malformed config handling, schema key validation. No CLI subprocess. |
| Integration | feat-443-effort-fast-mode.integration.test.cjs |
Architecture-level invariants: cross-provider validity, totality across the 33-agent registry, CLI JSON contract, config round-trip, fast-mode honesty. Real subprocesses via runGsdTools. |
| E2E (pending) | (not yet wired) | Propagation layer: effort frontmatter / CLAUDE_CODE_EFFORT_LEVEL env actually reaching a spawned Claude Code subagent. See "Gaps" below. |
Architectural invariants
Each invariant exists to prevent a specific class of production failure.
(a) Cross-provider validity
What: renderEffortForRuntime(runtime, universalEffort).value must always
be a member of the runtime's real provider enum. Ground-truth enums are defined
as local constants in the test — not sourced from the implementation.
PROVIDER_EFFORT_ENUMS = {
claude: Set { 'low', 'medium', 'high', 'xhigh', 'max' } // Anthropic output_config.effort
codex: Set { 'minimal', 'low', 'medium', 'high', 'xhigh' } // OpenAI model_reasoning_effort
}
Why: Passing a value outside these sets results in a 400 from the real API.
The clamping logic (max -> xhigh for codex; minimal -> low for claude) must
hold for every cell of the VALID_EFFORTS × runtimes matrix.
(b) Param/channel contract
What: Each runtime exposes a stable param string (the native API field
name) and channel (how the value is propagated). Unknown runtimes return
param: null, channel: null and pass the effort value through unchanged.
Why: Callers read .param to construct the dispatch payload. A regression
here would silently drop effort from subagent invocations.
(c) Resolve-execution JSON contract
What: The gsd-tools resolve-execution <agent> command emits a JSON object
with all eight keys present and typed correctly: model (string), profile
(string), effort (VALID_EFFORTS member), effort_rendered (string),
effort_param (string|null), effort_propagation (string|null), fast_mode
(boolean), fast_mode_supported (boolean).
Why: Orchestrators and workflow dispatchers parse this JSON. A missing or mistyped field silently breaks downstream consumers.
(d) Totality across the real registry
What: For every agent in the 33-agent registry, resolveEffortInternal
returns a VALID_EFFORTS member (never undefined/null), resolveFastModeInternal
returns a strict boolean, and renderEffortForRuntime('claude', effort) stays
within the claude provider enum.
Why: A catalog addition that introduces a missing routingTier mapping
would otherwise produce undefined and propagate silently.
(e) Fast-mode honesty invariant
What: When the runtime is claude, fast_mode_supported in
resolve-execution output is always false, regardless of the fast_mode config.
RUNTIMES_WITH_FAST_MODE contains only 'api'.
Why: Claude Code's /fast toggle is session-level only. Emitting
fast_mode: true as frontmatter on a Claude subagent is a silent no-op.
Advertising fast_mode_supported: true for claude would cause orchestrators to
believe the knob was wired when it is not.
(f) Precedence first-valid-wins
What: Both effort and fast_mode use a layered cascade. The test table covers all four effort layers (invocation override → agent_overrides → routing_tier_defaults → default) and all five fast_mode layers, including the case where an invalid value at a higher layer correctly falls through.
Why: Silent precedence bugs (e.g., a numeric value in agent_overrides not being rejected) would override intentional user config.
(g) Dynamic-routing composition
What: resolveEffortForTier escalates effort by attempt number
independently of the model tier mapping. The test verifies the effort ladder
(low -> medium -> high -> xhigh -> max), the max clamp, the
max_escalations cap, and that escalate_on_failure: false suppresses
escalation entirely.
Why: Effort escalation and model escalation share configuration
(dynamic_routing) but must operate independently; coupling them would cause
over-escalation or under-escalation.
(h) Config-tooling round-trip
What: gsd-tools config-set accepts all new key namespaces
(effort.default, effort.routing_tier_defaults.<tier>,
effort.agent_overrides.<agent>, fast_mode.enabled,
fast_mode.routing_tier_defaults.<tier>, fast_mode.agent_overrides.<agent>)
without an "Unknown config key" error, and values set via config-set are
reflected in resolve-execution output.
Why: The schema validation gate (VALID_CONFIG_KEYS + DYNAMIC_KEY_PATTERNS)
is separate from the resolver logic. A key missing from the schema would produce
a silent write failure and appear as a bug only at runtime.
Coverage targets
| Suite | Target |
|---|---|
| Unit | Every cascade rule, every fallthrough, every clamp. All function branches in resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, renderEffortForRuntime. |
| Integration | All 8 architectural invariants. All 33 registered agents. All 6 provider × effort combinations for the valid-enum check. Full config-set key namespace. |
Gaps / not yet covered
E2E orchestrator-spawn-propagation layer (pending follow-up wiring): The integration tests verify that GSD resolves and renders effort values correctly. They do NOT verify that the rendered values actually reach a spawned Claude Code or Codex subagent at runtime. Specifically uncovered:
CLAUDE_CODE_EFFORT_LEVELenv var being set and read by a spawned claude subprocessoutput_config.effortfrontmatter key surviving the AGENTS.md template substitutionmodel_reasoning_effortfield surviving serialization into a Codex API request body- Fast-mode
speed: "fast"field reaching anapi-runtime request whenfast_mode_supported: true
These require spawning real subagents (or stubs thereof) and asserting on the
process environment / request payload — a scope that belongs in a future E2E
suite under *.slow.test.cjs or dedicated fixture-driven integration work.