docs/internal/testing.md
Purpose: map Fresh's testing infrastructure — its layers, the headless scenario framework, the determinism strategy, the ANSI capture backend, and the meta-testing/migration efforts — distinguishing what is implemented from what is planned.
Fresh layers tests by faithfulness vs. speed/coupling. From lowest coupling (fast, refactor-proof) to highest (slow, terminal-faithful):
| Layer | Drives | Asserts on |
|---|---|---|
Unit / #[cfg(test)] | direct calls | internal invariants |
| Property (proptest) | random op sequences | buffer == Vec<u8> oracle |
| Semantic scenarios | the EditorTestApi (headless) | typed observables |
| Imperative e2e | the imperative harness (send_key/render) | screen scrape + buffer |
| Integration | real sockets / FS / SSH | end-to-end behavior |
| Visual regression | rendered frames → SVG/byte | pixel/cell snapshots |
The active architectural direction is to collapse the imperative e2e suite (screen scraping, terminal IO, render timing) into the semantic suite (typed observables over a headless editor). That migration is the central story of this doc; see §3, §6.
The imperative harness is the workhorse of the older regime and still backs every scenario runner underneath.
FileSystem, slow-FS injection, a fake devcontainer,
and the grammar registry.CrosstermBackend whose bytes are parsed back through an embedded
vt100::Parser for terminal-faithful assertions (screen-to-string and
cursor-position helpers).TestTimeSource
(§4); an accessor exposes it. FS-metric accessors count FS hits for slow-FS
tests.&mut dyn EditorTestApi — the bridge to the scenario
framework (§3).Enabling shadow validation makes the harness maintain a plain String that
mirrors every edit; the buffer-content assertion then checks both the real
piece-tree buffer and the shadow agree. This is a cheap differential catch for
piece-tree bugs. It is distinct from the scenario ShadowModel framework (§7),
which is a corpus-wide differential.
EditorTestApi)The EditorTestApi is the single observation surface for semantic tests.
The contract: semantic tests bind only to this module — never to the editor
application type, the model layer, or the view layer. Production internals can
be refactored freely; this trait is the only thing that must stay stable.
EditorTestApi trait is implemented on the editor application type. The
impl lives in the same file as the trait plus the projection types, so the
entire test-facing surface reviews as one unit.Serialize/Deserialize snapshots. They carry only
the fields tests assert on; internal cursor fields (sticky column, block
anchor) are intentionally hidden so production refactors don't break tests.HashMap iteration order.The observable surface is organized into "classes," each tagged by the migration phase that added it:
| Class | Observables | Phase |
|---|---|---|
| A — pure state | buffer_text, primary_caret, carets, selection_text, is_modified | 1 |
| B — layout | viewport top byte, terminal width/height, gutter width, hardware cursor position, visible byte range, top line number, scrollbar geometry | 2 |
| C — modal | modal snapshot (popup stacks + minibuffer prompt) | 3 |
| D — workspace | buffer count, active buffer path, buffer paths | 7 |
| E — input | mouse-click dispatch, full-redraw request | 9 |
| F — markers | marker seeding, marker positions, file-changed notification, event-log length | — |
| G — composite diff | side-by-side diff creation, hunk navigation, layout flush | — |
| H — layout seeding | virtual-line seeding, margin annotation, status message, margin width | — |
Notable design points encoded in the API:
Two shell linters run in CI (and locally) to keep the contract honest:
crossterm::*, the model layer, the view layer, and the config
module are allowed only in "harness-direct" files that explicitly use the
imperative harness (the documented escape hatch for cross-state claims and
projection types with no API counterpart, such as marker IDs).anti_* test (see §6.2).This is recorded as the project's first design decision.
Problem: wall-clock time makes tests slow and non-deterministic.
Decision: a TimeSource trait with two impls:
RealTimeSource — real instant, real thread::sleep, real UTC clock.TestTimeSource — holds an atomic count of logical nanoseconds since
creation. now() returns the base instant plus logical elapsed; sleep(d)
does not sleep, it just advances the counter; the date accessor derives a
calendar date from logical days elapsed. So time-driven code (animations,
debounces, auto-save) runs instantly yet observably "passes" time.Trade-off — selective, not total, abstraction: abstract time where it's
testable; keep real time where it fundamentally must be (the main loop's event
poll, signal-handler sleeps). Services receive a shared Arc<dyn TimeSource>
by composition.
This trait is the production hook that let the temporal scenario phase land
with zero new production code: the harness advance-time helper advances the
same TestTimeSource the editor already reads, and the clock-advance input
event routes through it. The abstraction is the already-existing
TestTimeSource, not a separate mock clock — the migration design's
"MockClock" naming is aspirational; the implemented type is TestTimeSource.
The capture backend is a ratatui::Backend impl that, instead of writing to a
real terminal, captures all output as ANSI bytes into an in-memory Vec<u8>.
Its primary production role is the client/server architecture: the server
renders into a capture backend and ships the ANSI byte stream to thin clients.
It is also the mechanism behind ANSI-snapshot-style testing.
Key behaviors:
ESC[0m) only when a modifier is removed, then the
minimal set of SGR params. A color-params helper handles the 16-color,
256-indexed, and truecolor paths. Redundant-SGR / missing-reset bugs are
exactly what a byte-level snapshot catches.TerminalIoScenario, Phase 8)The scenario framework's terminal-faithful layer doesn't assert on raw bytes —
it renders through the real CrosstermBackend, parses the emitted ANSI back
through vt100, and asserts on the resulting round-trip grid of visible rows
plus hardware cursor. This catches escape-emission and incremental-redraw bugs
without committing to a specific byte sequence — the byte stream is an
implementation detail, the displayed grid is the contract.
The framework lives alongside the semantic tests. Core idea: a test is a
value (initial state, action sequence, expected observable), not a
script. One value feeds three drivers with no extra wiring:
Scenario value
/ | \
regression proptest shadow-model
runner generator differential
Observable trait extracts a typed, serializable snapshot from a live
harness after all events dispatch. It is implemented for the buffer state,
modal state, workspace state, round-trip grid, styled frame, and tuples for
cross-cutting scenarios.InputEvent type is a superset of Action, adding mouse, prompt
open/filter/confirm/cancel, clock advance, LSP injection, FS external-edit,
and a semantic Wait. No variant is a raw KeyCode — even mouse projects
through render state, not crossterm.check_* (returning a
ScenarioFailure) with a panicking assert_* wrapper: buffer, layout,
modal, workspace, input, temporal, terminal-IO, persistence, and
marker-roundtrip runners. ScenarioFailure is Serialize/Deserialize for
typed CI signal.A single buffer-action runner is the one way a buffer scenario's actions are evaluated, and it always renders — a frame before the first action and after every action — exactly as the real event loop does. Rationale: layout-dependent actions (move-down, move-line-end, select-line-end) silently no-op without a prior render, a footgun that bit a real change. Always-rendering removes it, lets one unified corpus hold both logical and layout-dependent scenarios, and means the layout scenario differs from the buffer scenario only in what it asserts. Cost: a few ms per frame, a modest percentage added to the semantic suite — accepted.
(A stale docstring inside the buffer runner says "the runner never calls render," but the always-render evaluation it delegates to does. The meta-testing doc's "always render" describes current behavior; the docstring predates that decision.)
The design enumerates ten scenario types mapped onto the e2e files. Live status:
| Phase | Type | Status |
|---|---|---|
| 1 | data-model lockdown | landed |
| 2 | LayoutScenario (+LayoutShadow) | landed (minimal render snapshot) |
| 3 | ModalScenario | landed (real modal state from popup manager) |
| 4 | StyleScenario | skeleton — needs render() split into layout/style/emit |
| 5 | LspScenario | skeleton — needs an LSP-transport seam at the LSP manager |
| 6 | PersistenceScenario | landed (real FS via harness temp dir + FileSystem trait) |
| 7 | WorkspaceScenario | landed |
| 8 | TerminalIoScenario | landed (vt100 round-trip) |
| 9 | InputScenario | landed minimal (left-click) |
| 10 | TemporalScenario | landed (TestTimeSource) |
| 11/12 | PluginScenario/GuiScenario | dropped — low test volume vs. heavy hooks |
Skeleton runners are honest: their check_* panics with the precise production
hook still needed rather than silently passing. The data shapes (LSP traffic,
styled frame, cell role) already exist and serialize into the corpus, so the
JSON schema is stable ahead of the runner.
The two still-blocked phases (4, 5) require real production refactors; phases
6/8/10 appeared blocked but production already had the right traits
(FileSystem, the vt100 parser, TimeSource) — only the runner needed wiring.
The semantic tests already hold many migrated_* files plus domain files,
spanning buffer/cursor/selection, multi-cursor, undo/redo (including bulk-edit
and save-point boundary), auto-indent, case conversion, unicode/grapheme,
paste round-trip, search-modal flows, workspace buffer-count, dabbrev,
line-wrap/scroll layout, side-by-side diff, virtual lines/margins, and
numbered issue regressions. Migration found and pinned a catalogue of real
behavioral asymmetries — e.g. a move-line-end screen-column off-by-one, an
upper-case-without-selection upcasing the word under cursor, macro playback
becoming a single undo group. The discipline: pin observed behavior even when
surprising, add a finding entry, so an intentional change must update both.
A shadow model is an alternate implementation of step: BufferState × Action → BufferState. The corpus differential runs every applicable scenario through
both the live editor and each shadow and asserts equal observables, reporting
typed shadow-disagreement failures.
ShadowModel trait exposes a name, a capabilities query, and an
evaluate step over initial text plus actions. The capabilities let the runner
skip scenarios a shadow can't simulate (e.g. a pure-state shadow skips
layout-dependent cursor moves).The corpus is a hand-curated, machine-readable list of buffer-scenario values. A dump test serializes the whole corpus to JSON (an ignored test run explicitly in CI) and a gating round-trip test ensures a schema change that breaks deserialization fails even when the dump isn't run. A separate driver runs the corpus-wide differential.
The meta-testing design adds a fourth driver: tests about the tests, gated behind an environment flag so the normal fast path is unchanged.
S1; reset; S2; reset; … under random permutations. Surfaces ambient-state leaks a
fresh-harness model can never see. Scoped to buffer-layer
text/cursor/selection scenarios — active reset can't clear undo log, modified
flag, config, markers, or clipboard.cargo-mutants on
production code. cargo-mutants is partially wired via a fast-mutants script
that excludes a known list of slow/timed-out tests to keep each per-mutant
cycle fast.The anti_* convention (one per migrated file, enforced by the
migration-convention linter) is the per-file complement to corpus-wide
minimization: it asserts the scenario's check_* returns an error when the
load-bearing action is dropped, guarding against silently-inert migrations.
Vec<u8> oracle; persisted regression files retain shrunk
counterexamples. Undo/redo marker round-trip and persistence/agent property
tests are similar.Two coexisting approaches:
StyleScenario, Phase 4): the design replaces byte-for-byte
theme snapshots with a style scenario over a styled frame of role-tagged
cells, diffed structurally as JSON. The acceptance criterion includes
deleting the byte-snapshot pipeline. Not yet implemented — blocked on the
render() → layout/style/emit refactor.The main CI workflow runs on PR plus pushes to the main branches, with concurrency cancellation:
fmt — formatting check.clippy — clippy across all features and targets; deliberately not
-D warnings (a toolchain bump that adds pedantic warnings shouldn't redden
the build); only error-level diagnostics fail, including the crate's deny of
let-underscore-must-use.doc — docs build on nightly with the docsrs cfg.schema — regenerates the config JSON schema and diffs it against the
checked-in file.check-no-plugins — a check with default features off and only the runtime
feature.test — matrix over ubuntu/macos/windows, running nextest with all features
and targets, no-fail-fast, locked. Linux runs under xvfb-run with lavapipe
software rendering for headless GPU tests, and installs an SSH server for the
remote-SSH test.The semantic-isolation and migration-convention lints and the meta-testing / mutation pass are described as CI jobs in the docs but are not present in the committed main CI workflow; the scripts exist, but their CI wiring is not in that workflow.
Other workflows are release/packaging and docs deployment, not test gates.
package.json, bun.lock, and
package-lock.json are for the VitePress docs site (the docs scripts), not
test runners. The only JS test is a standalone Node unit test for a
merge-conflict regex (run with node, not in CI). There is no JS/bun e2e
suite.TestTimeSource: the migration design names a "MockClock";
the implemented determinism type is TestTimeSource (§4). Same concept,
different name.The trajectory is: imperative, terminal-coupled e2e → headless, typed,
data-as-tests scenarios that triple-leverage each written test (regression +
proptest seed + shadow differential). Determinism comes from TestTimeSource
(time), per-harness temp dirs / the FileSystem trait (FS), sorted observables
(hash-order), and semantic waits (no wall-clock sleeps). The remaining blockers
are two production refactors — split render() for the style scenario, add an
LSP transport seam for the LSP scenario — after which visual regression and LSP
join the same unified corpus and the imperative/semantic split dissolves.