Artifact dad17d00730d38b69aed6816968233603cc1c3f44d35e7fb21289157282a51f1:
- File
internal/manifest/crosslink_fossilbin_test.go
— part of check-in
[f804878bd3]
at
2026-07-22 23:58:46
on branch trunk
— test: route every fossil-binary gate through one REQUIRE_FOSSIL_BIN helper (#125)
* test: route fossil-binary tests through one REQUIRE_FOSSIL_BIN helper
REQUIRE_FOSSIL_BIN=1 turns a missing fossil binary into a test failure instead of an invisible skip, but only internal/manifest honored it. The six other fossil-dependent sites each did their own exec.LookPath + unconditional t.Skip, so on those a run with no fossil was byte-identical to a passing one.
Add testutil.RequireFossilBin, which resolves the binary the same way FossilBinary does (FOSSIL_BIN override, then PATH) and either skips or fails based on REQUIRE_FOSSIL_BIN. Route testutil.NewTestRepo, both internal/delta CLI round-trips, the four internal/merge validation tests, the internal/xfer sanity check, and the ls-branch-tip repro through it, deleting their bespoke lookup+skip logic. CI already sets REQUIRE_FOSSIL_BIN=1 on the unit-test steps, so this makes all seven sites fail loud on absence while local runs without fossil stay opt-in.
* test: route every fossil-binary gate through RequireFossilBin
The first pass left six sites still deciding for themselves whether a missing fossil binary is a skip or a failure. Three of them (internal/sync's startFossilServer and its two integration tests, manifest's two rebuild tests, the root TestPhaseB_Integration, and hash's TestSHA1_FossilValidation) skipped unconditionally, so with REQUIRE_FOSSIL_BIN=1 and no fossil on PATH those packages still reported ok -- the exact silent pass the flag exists to prevent. The other three had hand-rolled copies of the flag check, which is the duplication that let the original drift happen.
All of them now call testutil.RequireFossilBin, and HasFossil is gone: it was a second, skip-only way to ask the same question, and it is what produced the sites that ignored the flag.
Also validate the FOSSIL_BIN override with LookPath, so a stale path reports "fossil not found" instead of surfacing later as a raw fork/exec error.
* test: resolve fossil binary via RequireFossilBin at remaining call sites
* fix: correct stale module path missed by rebase auto-merge
Three imports added by earlier commits in this branch predated the rebase target and had no corresponding line on main to conflict against, so git's auto-merge carried the old module path through verbatim. (user: [email protected], size: 6191) [annotate] [blame] [check-ins using]
package manifest import ( "os/exec" "path/filepath" "strings" "testing" "github.com/danmestas/go-libfossil/db" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/simio" "github.com/danmestas/go-libfossil/testutil" ) // crosslinkDerivedTables are the tables a Crosslink sweep is responsible for. // Emptying them puts a fully-built repository back in the state a // freshly-transferred clone is in immediately before crosslinking. var crosslinkDerivedTables = []string{ "event", "plink", "leaf", "mlink", "tagxref", "forumpost", "attachment", "backlink", "cherrypick", } // TestFossilBinaryReadsCrosslinkedRepo drives the accelerated crosslink path // end to end and checks two things our own tests cannot: that a repository // whose relational tables were written by Crosslink -- expanding artifacts // through the memoizing content cache rather than one full chain walk per // blob -- is readable by canonical Fossil, and that what Crosslink wrote // matches what Fossil itself derives from the same blobs. // // Method: build a history deep enough that the commit path deltifies it, // empty the derived tables, run Crosslink over the untouched blobs, hand the // result to the fossil binary, and finally let `fossil rebuild` re-derive the // same tables from the same blobs so the two derivations can be compared. // // The rebuild comes last on purpose: it drops the tables fossil creates on // demand, and Crosslink's candidate query names forumpost, so Crosslink // cannot currently run against a repository straight out of a canonical // rebuild. That is a separate gap, not something this test should paper over. func TestFossilBinaryReadsCrosslinkedRepo(t *testing.T) { bin := testutil.RequireFossilBin(t) run := func(args ...string) string { t.Helper() out, err := exec.Command(bin, args...).CombinedOutput() if err != nil { t.Fatalf("fossil %s failed: %v\n%s", strings.Join(args, " "), err, out) } return string(out) } path := filepath.Join(t.TempDir(), "crosslinked.fossil") r, err := repo.Create(path, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } incrementalHistory(t, r, 3, 25, 400) s := collectStorageStats(t, r) if s.deltaEncoded == 0 { t.Fatal("no deltas were produced; this test would prove nothing") } if err := r.Close(); err != nil { t.Fatalf("repo.Close: %v", err) } d, err := db.Open(path) if err != nil { t.Fatalf("db.Open: %v", err) } for _, tbl := range crosslinkDerivedTables { if _, err := d.Exec("DELETE FROM " + tbl); err != nil { t.Fatalf("clear %s: %v", tbl, err) } } if err := d.Close(); err != nil { t.Fatalf("close: %v", err) } r2, err := repo.Open(path) if err != nil { t.Fatalf("repo.Open: %v", err) } linked, err := Crosslink(r2) if err != nil { t.Fatalf("Crosslink: %v", err) } if linked == 0 { t.Fatal("Crosslink linked nothing") } if err := r2.Close(); err != nil { t.Fatalf("repo.Close: %v", err) } got := snapshotDerived(t, path) integrity := run("test-integrity", "-R", path) if !strings.Contains(integrity, "0 errors") { t.Fatalf("fossil test-integrity did not report 0 errors:\n%s", integrity) } if !strings.Contains(integrity, "low-level database integrity-check: ok") { t.Fatalf("fossil reported a low-level database problem:\n%s", integrity) } // timeline reads event and plink, the tables Crosslink just rewrote. timeline := run("timeline", "-R", path, "-n", "5") if !strings.Contains(timeline, "revision 24") { t.Fatalf("fossil timeline does not show the tip check-in:\n%s", timeline) } if stats := run("rebuild", path, "--stats"); !strings.Contains(stats, "Artifacts:") { t.Fatalf("fossil rebuild produced no statistics") } // Fossil has now re-derived the same tables from the same blobs. The two // derivations must agree row for row on the columns compared below. // // That is narrower than full-table equivalence: the event digest covers // objid, type, user and comment but not mtime or tagid, and tagxref, // backlink, attachment, cherrypick and forumpost are emptied above but // never compared. tagxref is the notable gap -- it holds the // order-sensitive state that visiting candidates in delta-chain order // would disturb. reference := snapshotDerived(t, path) for _, key := range []string{"event", "plink", "leaf", "mlink"} { if got[key] != reference[key] { t.Errorf("%s differs from what fossil derived\n fossil: %s\n crosslink: %s", key, reference[key], got[key]) } } } // snapshotDerived returns a per-table digest of the crosslink-derived tables, // restricted to the columns Crosslink is responsible for writing. func snapshotDerived(t *testing.T, path string) map[string]string { t.Helper() queries := map[string]string{ "event": `SELECT group_concat(v, '|') FROM (SELECT objid || ':' || type || ':' || coalesce(user,'') || ':' || coalesce(comment,'') AS v FROM event ORDER BY objid)`, "plink": `SELECT group_concat(v, '|') FROM (SELECT pid || '>' || cid || ':' || isprim AS v FROM plink ORDER BY pid, cid)`, "leaf": `SELECT group_concat(rid, '|') FROM (SELECT rid FROM leaf ORDER BY rid)`, "mlink": `SELECT group_concat(v, '|') FROM (SELECT mid || ':' || fid || ':' || pid || ':' || fnid || ':' || coalesce(pfnid,'') || ':' || coalesce(mperm,'') AS v FROM mlink ORDER BY mid, fnid, fid)`, } d, err := db.Open(path) if err != nil { t.Fatalf("snapshotDerived open: %v", err) } defer d.Close() out := make(map[string]string, len(queries)) for name, q := range queries { var v any if err := d.QueryRow(q).Scan(&v); err != nil { t.Fatalf("snapshotDerived %s: %v", name, err) } s, ok := v.(string) if !ok || s == "" { // A NULL digest means the table is empty. Left unset, an empty // table would compare equal to another empty table and the // comparison would pass without having examined a single row. t.Fatalf("snapshotDerived %s: empty digest; the table has no rows "+ "and the comparison would be vacuous", name) } out[name] = s } return out }