Artifact c55bdcd6334997e1a0fbebaf03d675ba0c99991794d36b05de71437ef1ea6f3f:
- File
docs/site/content/docs/guides/testing.md
— part of check-in
[7377cb7c97]
at
2026-10-23 21:25:06
on branch main
— docs: size from the measured TinyGo build of the whole API
go-libfossil now builds with TinyGo, sync and signing included (examples/tinygo). The landing page reads the measured sizes from data/wasm.json instead of an outside project's engine-only build, and the install page says how to build it and what differs under TinyGo. (user: [email protected], size: 14091) [annotate] [blame] [check-ins using] [more...]
title: Testing weight: 50
aliases: ["/docs/testing/"]
This guide describes libfossil's test strategy for contributors adding tests or investigating failures. For the module layout referenced throughout, see Architecture.
Overview
libfossil's test suite has these layers:
- Unit tests — standard
go test ./...coverage for individual packages under the root module andinternal/.... - Conformance tests —
./conformance, which checks the library against the Fossil Internet-Drafts inconformance/rfcs/, requirement by requirement, and against realfossilandzitbinaries where they are present. See Conformance. - DST simulation tests — deterministic simulation scenarios under
./dst/...that drive multiple repos through a virtual clock with fault injection and invariant checks. - Fuzz targets — Go native fuzz tests for the parsers and codecs.
- Dual-driver matrix — the whole suite runs against two SQLite drivers
(
moderncandncruces) to catch driver-specific behavior.
Running tests
The Makefile wraps the common targets:
make test— runsgo test ./... -count=1 -timeout=120swith the defaultmoderncdriver.make test-drivers— runs the full suite twice:go test ./... -count=1 -timeout=120sfollowed bygo test -tags test_ncruces ./... -count=1 -timeout=120s.make test-otel— runs the out-of-workspace OTel submodule withcd observer/otel && GOWORK=off go test ./... -count=1.make test-all— chainstest-drivers,test-otel, andgo build ./cmd/libfossil/.make ci— mirrors.github/workflows/test.yml. It usesGOWORK=off, runs the packages other thandstwith a 120-second timeout, runs./dst/...with a 300-second timeout, runs the driver submodule tests,go vetand the CLI build, then repeats the tests under-tags test_ncrucesand runs the OTel submodule. Run it before opening a PR.make release VERSION=vX.Y.Zruns it before it tags.
make setup-hooks points core.hooksPath at .githooks/. The installed
pre-commit hook runs both drivers with -short, go vet, the OTel submodule,
the CLI build, and a check that the generated SDK reference pages are in sync,
in roughly 45 seconds. Skip with git commit --no-verify only in emergencies.
Tests that compare against real binaries skip when the binary is missing.
REQUIRE_FOSSIL_BIN=1 turns a missing fossil into a failure, and CI sets
it. fossil keeps a global config in $HOME, so run those tests with a
scratch HOME if you do not want your own touched.
Fuzzing
There are seven fuzz targets. Each is also a regular test over its seed
corpus, so go test runs the seeds without -fuzz.
| Target | Package | Exercises |
|---|---|---|
FuzzUnified |
internal/diff |
Unified diff output. |
FuzzTableSyncPayload |
internal/sync |
Extension-table sync payload parsing. |
FuzzParseXDelete |
internal/xfer |
The xdelete card parser. |
FuzzDecode |
internal/xfer |
The xfer message decoder and its size limits. |
FuzzArtifactRoundTrip |
conformance |
Artifact parse and re-encode. |
FuzzDeltaApply |
conformance |
Applying arbitrary deltas. |
FuzzDeltaRoundTrip |
conformance |
Delta create then apply. |
Run one with, for example:
go test ./conformance -run '^$' -fuzz FuzzDeltaApply -fuzztime 60s
CI does not run -fuzz, and no Makefile target or script runs the fuzz
targets or a seed sweep. They are run by hand.
Corpus parity gate
TestCloneCorpusParity (root package, clone_corpus_parity_test.go) clones one
repository two ways — with the canonical fossil binary and with
libfossil.Clone — and compares what each derived. It is opt-in:
LIBFOSSIL_CORPUS_PARITY=1 go test -run TestCloneCorpusParity -v .
Without the variable it skips, so make test is unchanged in both runtime and
outcome. It also needs a fossil binary and skips without one (or fails, under
REQUIRE_FOSSIL_BIN=1). Budget roughly a second and under a megabyte of temp
space; everything lands in t.TempDir() and is removed on exit.
It exists because the per-package parity tests compare our derivation against fossil's for fixtures built in-test, which only covers the shapes someone thought to build. Issue #184 — a silent 100% loss of the ticket-change artifact shape — passed that suite and was found by an external benchmark instead.
The gate asserts two things:
- Per-
event-type counts match, not just the total. This distinction is the point: on the real Fossil repository the totals read 23,396 vs 27,099, a 14% shortfall easy to wave off, while the per-type breakdown put ticket events at 96 vs 3,808. - Row totals match for
blob,event,mlink,plink,tagxrefandleaf, reported in both directions. A surplus matters as much as a shortfall — ourtagxrefran +17,119 over fossil's on the real repository, which is what writing a tag without finishing the work it marks looks like.
Failures name the table or type and the delta, e.g.
event type "t": got 0, want 10 (-10).
The corpus is built by the canonical binary at test time rather than downloaded: seconds and kilobytes instead of the ~10 minutes and ~1 GB the real Fossil repository costs, no network dependency, and contents that are a property of the test rather than of whatever a remote server holds today. It covers check-ins (including a branch and a merge), ticket creations and updates, a wiki page, a technote, an attachment, and a tag control artifact.
requireCorpusShapes classifies every blob by its card set and fails if any
shape falls below its expected floor. Without that check a corpus that quietly
stopped producing ticket artifacts would let the gate pass while covering
nothing — the exact blind spot it exists to remove.
Not covered: forum posts (f events), because fossil exposes no CLI to create
them; the scale-dependent behaviour of a large repository; and per-row content
comparison — this gate counts rows, and internal/manifest diffs them.
Driver matrix
libfossil abstracts SQLite behind db/ and two driver submodules. Both must
pass identical tests:
modernc(default) — pure-Go port of SQLite. Used by every build that doesn't set a tag. Registered viainternal/testdriver/modernc.gounder//go:build !test_ncruces && !test_mattn.ncruces(wasm-capable) — selected with-tags test_ncruces, suitable for WASM targets. Registered viainternal/testdriver/ncruces.gounder//go:build test_ncruces.
GitHub Actions (.github/workflows/test.yml) runs test, test-ncruces,
build-wasm, build-tinygo, test-otel and test-docs as parallel jobs. The first two
install the pinned fossil 2.28, build every package, then run the unit tests,
DST and the driver submodule tests under modernc and ncruces respectively.
build-wasm compiles the packages for js/wasm with the ncruces tag,
build-tinygo builds examples/tinygo with TinyGo 0.43, and
test-docs builds this site with Hugo. For the canonical local invocation,
use make test-drivers or make ci rather than hand-rolling tag flags.
DST (Deterministic Simulation Testing)
DST is inspired by FoundationDB and TigerBeetle: run many repos in a single
process under a simulated clock, inject faults, and replay a failing seed
deterministically. The harness lives in dst/simulator.go and
dst/invariants.go.
Seeds and reproducibility
dst.SimConfig.Seed drives every random source used by the simulator: the
event queue, network fault decisions, and a SeededBuggify PRNG that gates
fault-injection sites. Any failure on seed N reproduces exactly on seed N.
dst/scenario_test.go exposes three flags driving TestDST:
go test ./dst -run 'TestDST$' -seed=42 -level=hostile -steps=10000
TestDST skips unless at least one flag is set. It builds a master repo
with 50 artifacts, 5 unversioned files and 3 private blobs, starts a
simulator with 3 leaves, and checks safety invariants as it runs.
-seed=<int64>— seed passed toSimConfig.Seed. InTestDST, 0 means seed 1; other scenarios use their own fixed seeds.-level={normal,adversarial,hostile}— picks aseveritystruct settingDropRateandBuggify:normalis no drops and no BUGGIFY,adversarialis 10% drops with BUGGIFY,hostileis 20% drops with BUGGIFY. An empty or unknown level isnormal.-steps=<int>— caps the number of events processed (default 10000 inTestDST).
Seed-parameterized scenarios (e.g. TestCloneDSTSeedSweep in
dst/clone_test.go, which runs seeds 0 to 19) loop over seeds via t.Run(fmt.Sprintf("seed_%d", seed),
...). To reproduce a single sub-seed: go test ./dst -run
TestCloneDSTSeedSweep/seed_7 -v.
Invariants
dst/invariants.go defines per-node and cross-node checks. Simulator.CheckSafety
runs the per-node set after every SafetyCheckInterval steps; convergence
checks run once the sim has quiesced:
- Safety (anytime):
CheckBlobIntegrity(UUID matches hash of expanded content),CheckDeltaChains(delta.srcidresolves to a blob),CheckNoOrphanPhantoms,CheckUVIntegrity,CheckTableSyncIntegrity.CheckTagxrefIntegrityis a separate check that scenarios call directly. - Convergence (after fault-free sync):
CheckConvergence(leaves match master blob-for-blob),CheckSubsetOf,CheckUVConvergence,CheckTableSyncConvergence,CheckTombstoneConvergence.
Failures return a *InvariantError{Invariant, NodeID, Detail} naming the
failing invariant and the offending node. The scenarios add the seed and
level to the failure message, so a log line is enough to replay the run.
The checks run with BUGGIFY suspended, so they see what is persisted and
not faults injected into their own reads.
BUGGIFY
BUGGIFY marks code points where faults can be injected — shrunk send
budgets, dropped or rejected cards, bad nonces, byte-flipped content. About
twenty sites exist in internal/sync, internal/blob and
internal/content. In production
the check is a single boolean read; under DST the seeded PRNG decides whether
to fire.
Two styles appear in the tree:
- Process-global, used outside the sync path.
simio.Buggify(probability)consults a process-global state toggled bysimio.EnableBuggify(seed)/DisableBuggify()(seesimio/buggify.go). Example frominternal/content/content.go:
// BUGGIFY: flip a byte in expanded content to exercise the hash check
// below under DST.
if check && simio.Buggify(0.01) && len(content) > 0 {
corrupted := make([]byte, len(content))
copy(corrupted, content)
corrupted[0] ^= 0xFF
content = corrupted
verified = false
}
- Scoped, passed through session options.
BuggifyCheckeris an interface (Check(site string, probability float64) bool) defined ininternal/sync/session.goand re-exported from the root aslibfossil.BuggifyChecker. Sync code threads a checker through so each site has a stable name:
// BUGGIFY: drop the last file card to simulate partial send.
if s.opts.Buggify != nil && s.opts.Buggify.Check("sync.buildFileCards.skip", 0.05) && len(cards) > 0 {
cards = cards[:len(cards)-1]
}
dst.SeededBuggify in dst/simulator.go implements BuggifyChecker with a
seeded *rand.Rand (it ignores the site name). Simulator.New enables both styles when SimConfig.Buggify
is true, wiring independent PRNG streams into the client, the server-side
MockFossil, and the global simio state.
Interop with fossil 2.28 and zit
Two other implementations check libfossil's output.
Fossil. CI downloads the official fossil 2.28 release tarball
(FOSSIL_VERSION in .github/workflows/test.yml) and verifies its SHA-256
and its reported version, so every run compares against the same build.
Tests in conformance/, cli/ and the root package create repositories with
stock fossil, have it open and verify what libfossil wrote, and compare
the rows libfossil derives with the rows fossil derives. Locally they use
the fossil on PATH; fossil version should report 2.28.
zit. zit is a Zig reimplementation of Fossil. The TestZit* tests in
conformance/*zit*_test.go (20 of them) cover the newer drafts: sub-manifest
trees (zit_interop_test.go, zit_writer_test.go), sparse cones and sparse
stores, and horizons (serve, structural seeds, fallback, push check, cursor,
nodes, history URL and merge base). Each runs both ways where it can:
go-libfossil clones from and pushes to zit serve, and zit clones from and
pushes to go-libfossil's handler. They need a zit on PATH (or ZIT_BIN)
at a recent enough commit and skip otherwise. REQUIRE_ZIT_BIN=1 makes a
missing or older zit a failure. CI has no zit, so there the bundle tests
(TestZitTreeBundle, TestZitWriterBundle) read recorded copies of what
zit wrote from conformance/testdata/. ZIT_REGENERATE=1 rewrites those
copies from a live zit.
Signed check-ins are covered the same way: TestSignedCheckin has fossil
and zit clone and read libfossil's signed check-ins, and checks that a
check-in zit signs verifies in libfossil.
Writing new tests
- Prefer table-driven tests in unit code; include success and failure rows.
- If you touch sync, merge, clone, or checkin, add a DST scenario under
dst/and run it across a seed range (seeTestCloneDSTSeedSweepfor the pattern). - Mark any new fault-sensitive code point with BUGGIFY. Use the scoped
BuggifyCheckerstyle when the site lives inside the sync session; reservesimio.Buggifyfor code that runs outside an explicit session scope. - Assert invariants. If the new behavior warrants one, add it to
dst/invariants.goand wire it intoCheckSafetyor a convergence helper. - Confirm tests pass under both drivers (
make test-drivers) before requesting review.