26 KiB
Testing audit — gaps and shortcomings
Audited 2026-07-29 at 7a3a55d (branch journey-stage1a-directory-open,
even with main for everything cited here). Scope: testing coverage and
test quality only, plus CI-pipeline improvements (added mid-audit at the
user's request). This document is the raw material for a future testing
arc; each numbered lane in §8 still needs its own framing doc per the
workflow, and coherence-affecting lanes must state their COHERENCE.md
§20 impact there, not here.
Every count in this file is a reading, not a constant. The dark-test
census in docs/active-work.md ("The CRDT half of the test corpus is
dark in CI — NEEDS A LANE") moved three times in three weeks. Re-measure
before acting; the appendix gives the commands.
Method: five parallel read-only audits (dark/gated tests; unit-coverage
map; Lua-layer coverage; test-quality/flake patterns; CI-vs-local-gate
delta), findings cross-checked against docs/active-work.md,
docs/agent-handoff.md §3/§5, and the memory of prior review rounds.
Claims that accuse an existing doc of being wrong were re-verified by
hand before inclusion.
0. Baseline: what exists
- Root
pmacspackage: ~2,030 unit tests insrc/, 92 integration targets intests/(~93k lines, ~1,426 tests), shared harness intests/common/(daemon + PTY, both high quality). pmacs-gpu: 202 unit tests, including a real headless wgpu render harness with ~20 pixel-asserting tests. Notests/dir; bin-only, so doctests are structurally impossible.pmacs-protocol: 17 unit tests (12 transport, 5 terminal). Notests/dir.builtin/: 16,445 lines of Lua across 31 files. No Lua test framework of any kind; all coverage is indirect via Rusteval().- Property tests: 5 proptest sites, all in the root crate (CRDT
convergence, rope≡CRDT projection, text_view inverse, keymap stack,
manifest round-trip). One checked-in regression seed (
crdt.txt). No fuzzing anywhere. - CI: one workflow, 8 jobs / 12 legs, ~14.5 min wall per run, triggers
on
push:main+pull_requestonly. No coverage measurement, no scheduled runs, no branch protection onmain(verified via API: 404, so every job is advisory).No branch protection— CLOSED. Protection was enabled during the arc; the API now reports 12 required contexts,strictoff,enforce_adminsoff. The 404 above was a reading at audit time.
The suite is unusually thoughtful in places — the daemon harness's
connect-based readiness probe, the PMACS_REQUIRE_GPU hard-fail
pattern, dual_mode_test!, the encoding-stability pins, scripts/bite
itself. Most defects below are inconsistent application of patterns
the project already invented: the correct helper exists in one file
and a degraded copy exists in another.
1. Dark tests — things that never run
1.1 CRDT (known; already a scoped lane — do not re-scope here)
docs/active-work.md §"The CRDT half of the test corpus is dark in CI"
carries the full census (273 dark at 74301d1: 185 library + 88
integration), the two-part fix shape, and the two standing obstacles
(clippy --features crdt is red on main; the setsid_escapee ~1-in-5
load flake). Defer to it. What this audit adds to that lane:
- Nothing anywhere compiles the 8
#![cfg(feature = "crdt")]integration targets — not CI, and not the local gates either:cargo test --lib --features crdtbuilds the library only. The*_crdt_acceptance.rs/m10_*files are type-checked exclusively on developer machines that happen to run them by name. CI builds them as empty green binaries, which reads as "passed" in the log. tests/m10_11_perf.rsputs its#![cfg(feature = "crdt")]at line 60, after the module doc — any future head-of-file scan for the gate will misclassify it (the "never classify from truncated output" trap, now in file-layout form).src/buffer.rs:2984— the parked CRDT no-op-replace undo bug is doubly dark (#[ignore]+ crdt) and its own comment says un-ignoring it is step one of the fix. It needs a named owner or it will sit forever.
1.2 Vacuous-green skips: tests that pass without executing
The let Ok(_) = which_binary(x) else { eprintln!(...); return; } shape
passes green when the tool is absent. 21 external-tool-gated tests; 15
have never executed their bodies in CI because ci.yml installs none
of the tools (the only apt-get install in the workflow is lavapipe):
- All 9 real-language-server tests in
tests/m4_acceptance.rs(rust-analyzer ×2, basedpyright, clangd ×2, gopls ×2, json-ls, yaml-ls) — the only real-LSP coverage in the suite. - The entire M6.8 multi-REPL acceptance (6/6 tests, gated on a
lua/luajit binary) plus the zsh/fish/lua shell-spawn tests in
tests/m6_5_repl_acceptance.rs.
Same pathology, already ledger-documented: a37 in
tests/vterm_stage3_acceptance.rs is triple-dark (crdt-gated; skips
when pmacs-gpu isn't built; PMACS_REQUIRE_GPU is only set on a job
that tests a different package). It has never executed in CI.
The project already owns the fix pattern: PMACS_REQUIRE_GPU
promotes a silent skip to a hard failure, and CI arms it on the
gpu-render job. There is no PMACS_REQUIRE_LSP / PMACS_REQUIRE_SHELLS
analogue, and no CI step installs the cheap tools (gopls, clangd, zsh,
fish, lua are all one apt-get/brew line; basedpyright is an npm
install).
Related mid-test variant: tests/m4_acceptance.rs:5783-5789 skips its
only assertion when rust-analyzer is "likely still indexing" — the
assertion vanishes exactly when the system is slow, which is when a
regression would show.
1.3 Ignored tests owned by nobody
27 #[ignore] tests; 11 run in the four --ignored CI jobs; 16 run
nowhere:
src/rope.rs:1101,1112,1130(3 perf smokes) andsrc/buffer.rs:2984—cargo test --libnever passes--ignored; these are dead code that still compiles.tests/m3_acceptance.rs:218(m3_6_grep_kernel_under_2s_on_8_cores) —#[ignore]+ needsPMACS_KERNEL_PATH+ no job namesm3_acceptance --ignored.- The m10_2/m10_11 perf and doubled-PTY operator tests (crdt-gated on
top of
#[ignore]; the doubled-PTY trio is deliberately operator-invoked — fine, but nothing records when it last ran).
1.4 CI runs weakened versions of the gates it does run
CI green currently certifies less than the spec text implies:
async_runtime_soak_lifecycle_stableruns at 10 s vs its 3600 s spec gate (PMACS_SOAK_SECS); the hour-long soak has never run.- M6 ingest gate: CI floor 64 MB/s vs the 100 MB/s default; cancel
gate: 30 trials/500 ms vs 100/5000. Documented in
ci.ymlcomments as a runner-budget tradeoff, but nothing anywhere runs the full profile, ever. - The soak's FD and RSS assertions silently no-op on macOS
(
/proc-dependent) while the test reports green.
2. Coverage gaps by layer
2.1 pmacs-protocol: the wire format is tested from the wrong crate
pmacs-protocol/src/message.rs — 2,100 lines: InstanceMessage,
FrontendEvent, Hello, capability negotiation, version constants —
has zero tests in its own crate. The real coverage (117 tests:
postcard round-trips, v16–v19 encoding-stability pins, negotiation
matrix) lives in the root package's src/protocol.rs. Consequences:
cargo test -p pmacs-protocolcertifies almost nothing (17 tests).- A future consumer that depends on
pmacs-protocolalone inherits an effectively untested crate. - CI's
-p pmacs-protocol --all-targetsstep cannot run doctests (--all-targetsexcludes them) — zero exist today, so this is a trap armed for the first doctest someone writes.
Also: local cargo fmt --check (no --all) formats the root package
only, and local clippy via --workspace covers pmacs-protocol while
CI's clippy never does — each side has a hole the other covers.
2.2 Root crate: zero-test production files
Largest files with no #[cfg(test)] at all:
src/terminal/session.rs (854 — the process/session registry),
src/lua_bindings/window_panel.rs (796), src/lua_bindings/mcp.rs
(602), src/lua_bindings/index.rs (395), src/lua_bindings/fold.rs
(390), src/lua_bindings/diag.rs (237), src/audit/rules.rs (154).
Thinnest large files: src/mcp.rs (2.5 tests/kloc),
src/lua_bindings/mod.rs (13,955 prod lines, 10.6/kloc — also the
F-016 split candidate), src/daemon.rs (9.7/kloc). The lua_bindings
submodules are integration-covered via acceptance suites but have no
unit-level pins; per the project's own review history ("pin where
production CALLS"), that leaves the binding seam itself unpinned.
The LSP client (src/lsp.rs) unit-tests only wire mechanics (framing,
URIs, position encoding); server lifecycle, capability handling, and
the diagnostics pipeline are covered solely through the fake-LSP
acceptance path — plus the 9 real-server tests that never run (§1.2).
2.3 pmacs-gpu: the live half is untested
Well covered: headless render, math layout/parse, attach negotiation,
coalescing, minimap, tab projection. Untested: the winit
ApplicationHandler event loop (one test), the live surface/swapchain
render() path (only the offscreen twin is exercised), clipboard
(arboard — zero tests reference cut/copy/paste), font-database
sanitization beyond the four bundled fixture TTFs, edge-scroll/context
menu/minibuffer interactive drivers. No proptests in the crate.
2.4 The Lua layer: 16.4k lines, no test framework, one universal
guarantee
Every EditorState::new() evals all 30 runtime chunks, so every Rust
test is a load/syntax smoke for all Lua. Behavior coverage is via
per-file eval() helpers copy-pasted into 67 of 92 test files (823
call sites) poking module namespaces directly. Findings:
pmacs.erroris a test-stub-only API — confirmed and worse than the memory of it. ~35 references across 9 builtin files, every one guarded (if pmacs.error then/pcall). Production never assigns it; the only assignments are 10 stubs inside test code. The tests that assert into those stubs prove the message is formatted, not that anyone receives it — all would stay green if the channel were deleted.dired.lua:56,lsp.lua:1659,lean.lua:171acknowledge this in comments and route around it viaset_status. TheCOHERENCE.mdtally ("fifteen call sites") is stale.- 56 statement-position
pcalls discard errors entirely, 25 of them inlsp.lua's hot path (did_change,did_open, inlay/token pulls). A server going silent after a faileddid_changeis unobservable — including to the test suite. - Interaction islands with zero test contact:
menus/default.lua(6 of 11 commands never referenced anywhere in the tree),keymaps/default.lua(no suite pins the default binding table — a silent rebinding regression only surfaces if an unrelated suite happens to type that key),window.luaandfold.lua(10/10 registered commands unnamed in tests; their frontend/point-resolution wrapper bodies are bypassed by the Rust-level tests underneath),workers.cancel-at-pointandrecent-files(never mentioned). - Command-name coverage is the honest metric and it is low:
lsp.luaregisters 15 commands, 12 never named by any test;commands/default.luaregisters 80, 35 never named. builtin/api/packages.luais never loaded or parsed by anything; its single "test" greps it as a string for three substrings.lean_abbrev.lua(1,883 lines, 11% of the Lua tree) has ~5 assertions; generated data, low risk, but zero drift detection against its generator.
2.5 No fuzzing, and two ideal targets
No cargo-fuzz/AFL/libfuzzer anywhere. The two highest-value targets are
exactly the shapes fuzzing is best at: pmacs_protocol::transport:: read_message (length-prefixed decoder with a MAX_FRAME_BYTES guard;
12 hand tests) and src/ansi.rs (VT escape parser; 44 hand tests).
Both parse attacker-adjacent input (a compromised frontend socket; PTY
output from arbitrary child processes). The existing 30 s
fuzz_no_crashes in tests/acceptance.rs fuzzes buffer edits only.
3. Test-quality defects (races, hangs, silent lies)
Ranked; each is a known-bitten shape recurring in a new place.
RuntimeHandles::dropjoins a non-interruptible reader with no timeout (src/process.rs:631-642+:1874). This is the real basedpyright hang: a PATH'd server leaving a descendant holding the stdout write end blocksread()forever, below every test-level deadline. It is a product defect the skip merely hides; the fix (give non-group readers theO_NONBLOCK+poll loop that group readers already have) is named as a deferral atsrc/process.rs:1937-1940. Fixing it retires the--skip basedpyrightclause from the gates and the latent CI stall (§4).wait_for_filewithout a non-empty guard (tests/vterm_stage2_acceptance.rs:723) — returns on the first successful read, including the zero-byte window of the non-atomic writers it waits on; four call sites then assert exact bytes. The fixed variant (&& !bytes.is_empty()) already exists attests/bottom_panel_stage1_acceptance.rs:2307— someone was bitten and patched only the local copy. The vterm file's own panic message ("published but malformed") describes the race it causes. Cheapest high-value fix in this audit.- Fixed-iteration
settle()as the sole synchronization before strict assertions about a real forked LSP child — 16–20 ms total, no predicate, no deadline:tests/lsp_multi_root_acceptance.rs:125,tests/lsp_dispatch_seams_acceptance.rs:105,tests/lean4_server_acceptance.rs:112; ~58settle()calls gating ~68 tests. The correct predicate-plus-deadline pump exists attests/journey_acceptance.rs:63,tests/dired_acceptance.rs:107, and (exemplary, with diagnostic dumps)tests/m10_11_acceptance.rs:316. drain_until/drain_lsp_untilreturn partial results on timeout instead of panicking, and 15 call sites discard the result (let _ = drain_lsp_until(...)). A test whose awaited event never arrives proceeds unsynchronized and fails elsewhere — or passes for the wrong reason.tests/m9_4_acceptance.rs:121already does it right (panic with the job id). Also note these helpers are effectful (ticking reaps children,take_eventsconsumes) — the knowndrain_untiltrap — and at least one assertion (tests/m4_acceptance.rs:1070-1084) is load-bearing on the side effect of a discarded call.- Git fixtures inherit the ambient global gitconfig (9 files, e.g.
tests/m8_7_acceptance.rs:87,tests/m7_review_acceptance.rs:30). A developer withcommit.gpgsign = truegets an unbounded pinentry hang insidegit commit. OneGIT_CONFIG_GLOBAL=/dev/null+GIT_CONFIG_NOSYSTEM=1pair per fixture closes it. - Bare sleeps with strict assertions after: 20 sleep-then-assert
sites outside poll loops, notably
tests/m5_5_acceptance.rs:184(300 ms for daemon slot-clear),tests/m4_acceptance.rs:7093(120 ms against a 75 ms coalescing window — 45 ms of margin), and negative assertions after fixed waits (tests/lean4_server_acceptance.rs:493acc28,tests/m8_7_acceptance.rs:786) that pass if the thing simply hasn't happened yet — each needs a positive-control sentinel. - Unit tests that require serial execution without declaring it:
src/file_io.rs:427mutates process-wide cwd (set_current_dir) while concurrent tests resolve relative paths throughcurrent_dir()fallbacks;/proc-global probes intests/worker_shutdown_acceptance.rsand the M6.6 RSS gate. - Smaller but real: no
set_read_timeoutintests/gpu_invocation_acceptance.rs:165's hand-rolledwait_for_daemon(the shared harness's version attests/common/daemon.rs:200has one, with a comment saying why); a 2 s 100%-CPU busy-spin attests/statusline_segments_acceptance.rs:1016that manufactures load for its siblings; leaked orphan daemons on timeout intests/m5_7_acceptance.rs:211plus a PID-reuse-unsafekill(pid,0)probe;PmacsPty::output()clones an unbounded buffer on every 20 ms poll (tests/common/pty.rs:55);sleep(1100ms)in a unit test (src/file_io.rs:420).
The serialization contract is CI-only and it cuts both ways.
--test-threads=1 appears at five points in ci.yml and nowhere in
the documented local gates, .cargo/, or any hook. So the process-
lifecycle races CI's comment admits to dodging are structurally never
caught (masked in CI, dismissed as local flakes), while
serial-dependent tests (the settle() family, /proc probes, the cwd
test) pass in CI and flake for whoever runs the documented local gate.
4. Tooling and infrastructure gaps
scripts/bitehas no positive control. It never runs the tests against the current tree, so a test that fails everywhere — e.g. broken, or environmentally skipped-then-failing — reportsbite: OK. Given §1.2's silent skips, a bite claim on a PATH-gated test is meaningless. ~5-line fix: requirecargo test "$@"to pass on the unmodified tree first.docs/agent-handoff.md(§5, "A fix must be COMMITTED before it is bitten") misdescribes bite's mechanism — verified: it claims restore-by-git checkout --(revert to HEAD, destroying uncommitted work), but the script has restored from amktempcopy viacp+trapsince its only commit. The commit-first rule stays sound for two different reasons (SIGKILL bypasses the trap; biting an old version of your own uncommitted claim is circular), but the stated mechanism will mislead an agent reasoning about what is safe. Fix the doc, keep the rule.- Bite-verification is honor-system. It's step 4 of the working method and appears in eight framing docs as prose claims, but nothing re-checks a bite after the code moves. (A CI mode is probably overkill; recording the bitten ref+command in the framing doc would at least make claims re-runnable.)
- No coverage measurement, Rust or Lua. No tarpaulin/llvm-cov/
grcov/luacov anywhere. For Lua it's worse than absent: Rust line
coverage of
include_str!chunks would report the eval line, not which Lua branches ran. §2.4's command-name coverage is currently the only measurable proxy. - No enforcement layer between "documented gates" and "what runs":
the gate list exists as prose in three places (CLAUDE.md, AGENTS.md,
handoff §3 — the handoff adds a full-workspace sweep the other two
omit). No script runs the suite, so drift among the three copies is
invisible. A
scripts/gatesthat executes the canonical list would make the prose testable.
5. CI: correctness hardening
(Findings that change what CI certifies; speedups are §6.)
Branch protection is off— DONE. Every job was advisory; a red run merged as easily as a green one. All 12 contexts are now required, rather than the cheap-jobs-only starter suggested here.strictis off (a PR need not rebase every timemainmoves, which this repository's ledger contention makes expensive) andenforce_adminsis off (the maintainer retains an override).- No job timeouts except m6 (15 min). Everything else inherits
360 min. The day a runner image ships any of the PATH-gated tools
(§1.2), the basedpyright-class hang burns 6 h × 4 matrix legs with
no signal.
timeout-minutes: 25on every job is free insurance. - The crdt lane (already scoped in
docs/active-work.md): CI leg for--features luajit,crdt, GPU-requiring crdt suites ontogpu-renderwithPMACS_REQUIRE_GPU=1stated as a requirement (or a37 runs vacuously), clippy-crdt fixed first. Not re-scoped here. - Arm the skips it already runs: install gopls/clangd/zsh/fish/
lua on the ubuntu test leg (one apt line + one
go install/npm line) and addPMACS_REQUIRE_LSP/PMACS_REQUIRE_SHELLSenv guards mirroringPMACS_REQUIRE_GPU, set only on legs where the tools are installed. This converts 15 permanently-vacuous tests into real CI coverage. - A scheduled (cron) job for what per-PR CI can't afford: the
3600 s soak, full M6 cancel/ingest profiles, the
--ignoredperf smokes nothing runs (§1.3), and N repetitions of the known load-sensitive suites as a flake canary. Nightly, ubuntu-only,workflow_dispatchfor manual runs. This also gives intermittent failures a place to burn in instead of surfacing mid-review. - One deliberately-parallel leg (default test threads, ubuntu, allowed-to-fail at first) so the races serialization currently masks become visible on purpose instead of as local flakes.
pmacs-protocolclippy — add-p pmacs-protocolto the CI clippy job (local--workspacecovers it; CI never has).
6. CI: speedups and efficiency
Measured baseline (run 30454133846, warm cache): wall ~14.5 min;
critical path is Test (macos-latest/luajit) at ~14.6 min, macos/lua54
~10 min, ubuntu test legs ~6.7 min, perf gates 2–3.5 min, everything
else ≤1.5 min.
- No
concurrencygroup — force-pushes and the ledger-contention rebase treadmill leave superseded runs burning to completion. Addconcurrency: {group: ci-${{ github.ref }}, cancel-in-progress: true}guarded to PRs. Likely the single largest minutes saving given the project's rebase-heavy flow, and macOS minutes are the expensive ones. - Split serial from parallel on the test job.
--test-threads=1serializes ~3,200 tests for the sake of a handful of process-lifecycle suites. Splitting intocargo test --lib(parallel)- integration targets (serial) would cut the critical-path macOS leg substantially; nextest (below) is the cleaner version of the same move.
- Consider cargo-nextest for the test job: per-test process
isolation retires the cwd/
/proc/env hazards of §3.7 and enables safe parallelism; per-test timeouts retire the hang class at the runner level; automatic retries with failure reporting make the known load-flakes visible instead of rerun-by-hand; JUnit output for free. This is the one tool that converts several §3 classes from "fix each site" to "fixed by construction". - Trim the macOS matrix if minutes matter: the lua-flavor cfg
audit found zero flavor-gated tests — the 2×2 matrix only varies
mlua's backend. macOS × lua54 has caught real macOS bugs
(
m4_5config-sink race was lua54/macOS-shaped), so don't drop macOS — but macOS × one flavor + ubuntu × both retains the platform signal at ¾ the macOS cost. Decide from flake history, not a priori. - Redundant work, minor:
cargo build --all-targetsbeforecargo test --all-targetsis a near-no-op (incremental) but adds log noise;-p pmacs-protocolruns identically on all 4 legs (comment acknowledges; it's seconds); the gpu-render apt-get runs every job (~20 s; cacheable viaactions/cacheon the deb pool if it ever grows). - Perf gates on shared runners already run loosened profiles (§1.4) and are the most variance-exposed jobs. If they start flaking, prefer trend-tracking (store the measured numbers as artifacts, gate on regression-vs-window) over further threshold loosening — a threshold loose enough to never flake certifies nothing.
7. What is deliberately out of scope
- Windows: no
cfg(windows)code exists; unix-only sockets. Not a testing gap until the product targets it. src/bin/pmacs_fake_lsp.rs/pmacs_fake_mcp.rs(3,145 untested lines): test fixtures; testing the fakes is circular. Their fidelity is instead covered by §1.2's real-server tests — which is an argument for §5.4, not for unit-testing the fakes.- The lean4/latex suites' stub-based design (
lake_stub, vendored grammar): verified deliberate and CI-real; not a gap.
8. Candidate lanes for the arc (priority order)
- CRDT CI lane — already scoped in
docs/active-work.md; first task there is deliberate-vs-accidental classification. Blockers: clippy-crdt red, a37 vacuity, first-run flake budget. - Silent-skip arming (§1.2, §5.4) — install tools +
PMACS_REQUIRE_*guards. Small, mechanical, converts 15 vacuous tests to real ones. Independent of lane 1. - CI hardening/speedup (§5.1–2, §6.1–3) — timeouts, concurrency, branch protection, serial/parallel split or nextest. No product code.
- The reader-join hang (§3.1) — the one product fix in this list;
retires the basedpyright skip everywhere. Already a named deferral
in
src/process.rs. - Race-shape cleanup sweep (§3.2–6) — port the already-invented fixes (non-empty guard, predicate pumps, panic-on-timeout, git env pinning) to the degraded copies. Mostly mechanical; each fix should be bite-verified, which motivates doing lane 6 first or alongside.
scripts/bitepositive control + handoff correction (§4) — tiny, but raises the trust ceiling on every other lane's verification claims.- Lua error-channel decision (§2.4) — either define
pmacs.errorin production or delete the 35 dead references and standardize onset_status; then a sweep of the 56 discarded pcalls. This is a product-coherence question (COHERENCE.md owns the framing) as much as a testing one. - Protocol-crate self-sufficiency (§2.1) — move/duplicate the
round-trip + negotiation pins into
pmacs-protocol; add its clippy to CI. Mechanical but wide. - Fuzz targets (§2.5) —
read_message+ ANSI parser, run on the cron job (lane 3's infrastructure). - Coverage floor for the untested-file list (§2.2, §2.3) and Lua interaction-island pins (§2.4: menus, default keymap, window/fold wrappers) — largest and least mechanical; needs its own framing to avoid writing biteless tests, per the project's own "new tests need their own bite" history.
Appendix: re-measurement commands
# Dark-test census (re-measure, never quote):
cargo test --all-targets --no-default-features --features luajit -- --list | grep -c ': test'
cargo test --all-targets --no-default-features --features luajit,crdt -- --list | grep -c ': test'
# Ignored tests that no job runs:
grep -rn '#\[ignore' src/ tests/ --include='*.rs'
# Silent-skip sites:
grep -rn 'which_binary\|not on PATH; skipping' tests/
# pmacs.error references (production never assigns it):
grep -rn 'pmacs\.error\b' builtin/ --include='*.lua'
# Discarded statement-position pcalls:
grep -rn '^\s*pcall(' builtin/ --include='*.lua'
# Per-job CI timing for a run:
gh run view <run-id> --json jobs --jq '.jobs[] | "\(.name): \(.startedAt) -> \(.completedAt)"'