Artifact 1cbc573bd32280e1adb6a2dbbfbb2bd8a498ae619718bb3794dad6e681a197e1:
- File
internal/sync/integration_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: 8825) [annotate] [blame] [check-ins using]
package sync import ( "context" "fmt" "net" "os" "os/exec" "path/filepath" "strings" "testing" "time" "github.com/danmestas/go-libfossil/internal/content" libfossil "github.com/danmestas/go-libfossil/internal/fsltype" "github.com/danmestas/go-libfossil/internal/manifest" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/simio" "github.com/danmestas/go-libfossil/testutil" ) // startFossilServer starts a fossil server on a free port and returns the URL // and a cleanup function. func startFossilServer(t *testing.T, repoPath string) string { t.Helper() bin := testutil.RequireFossilBin(t) // Find a free port ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatalf("listen: %v", err) } port := ln.Addr().(*net.TCPAddr).Port ln.Close() cmd := exec.Command(bin, "server", fmt.Sprintf("--port=%d", port), repoPath) cmd.Stdout = os.Stderr cmd.Stderr = os.Stderr if err := cmd.Start(); err != nil { t.Fatalf("fossil server start: %v", err) } // Wait for the server to accept connections (poll up to 5s) deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 200*time.Millisecond) if err == nil { conn.Close() goto ready } time.Sleep(100 * time.Millisecond) } cmd.Process.Kill() cmd.Wait() t.Fatalf("fossil server did not become ready on port %d within 5s", port) ready: serverURL := fmt.Sprintf("http://127.0.0.1:%d", port) // Register cleanup via t.Cleanup so it runs before TempDir removal (LIFO). t.Cleanup(func() { cmd.Process.Kill() cmd.Wait() // Remove WAL/SHM files that fossil processes leave behind, // so t.TempDir() cleanup doesn't fail with "directory not empty". dir := filepath.Dir(repoPath) entries, _ := os.ReadDir(dir) for _, e := range entries { name := e.Name() if strings.HasSuffix(name, "-wal") || strings.HasSuffix(name, "-shm") || strings.HasSuffix(name, "-journal") { os.Remove(filepath.Join(dir, name)) } } }) return serverURL } // getProjectCode reads the project-code from a fossil repo. func getProjectCode(t *testing.T, repoPath string) string { t.Helper() bin := testutil.FossilBinary() out, err := exec.Command(bin, "sql", "-R", repoPath, "SELECT value FROM config WHERE name='project-code'", ).Output() if err != nil { t.Fatalf("get project-code: %v", err) } code := string(out) // Trim whitespace for len(code) > 0 && (code[len(code)-1] == '\n' || code[len(code)-1] == '\r' || code[len(code)-1] == ' ') { code = code[:len(code)-1] } return code } // getServerCode reads the server-code from a fossil repo. func getServerCode(t *testing.T, repoPath string) string { t.Helper() bin := testutil.FossilBinary() out, err := exec.Command(bin, "sql", "-R", repoPath, "SELECT value FROM config WHERE name='server-code'", ).Output() if err != nil { // server-code may not exist; return empty return "" } code := string(out) for len(code) > 0 && (code[len(code)-1] == '\n' || code[len(code)-1] == '\r' || code[len(code)-1] == ' ') { code = code[:len(code)-1] } return code } func TestIntegrationPushToFossilServer(t *testing.T) { bin := testutil.RequireFossilBin(t) dir, err := os.MkdirTemp("", "TestIntegrationPush*") if err != nil { t.Fatalf("MkdirTemp: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) // 1. Create a Go-managed local repo with a checkin localPath := filepath.Join(dir, "local.fossil") r, err := repo.Create(localPath, "testuser", simio.CryptoRand{}, "") if err != nil { t.Fatalf("repo.Create: %v", err) } defer r.Close() _, _, err = manifest.Checkin(r, manifest.CheckinOpts{ Files: []manifest.File{ {Name: "hello.txt", Content: []byte("hello from libfossil")}, }, Comment: "initial checkin from go", User: "testuser", Time: time.Date(2026, 3, 15, 12, 0, 0, 0, time.UTC), }) if err != nil { t.Fatalf("Checkin: %v", err) } r.Close() // 2. Clone the local repo with fossil to create a matching remote // This ensures project-code and server-code match. remotePath := filepath.Join(dir, "remote.fossil") cloneCmd := exec.Command(bin, "clone", localPath, remotePath) cloneOut, err := cloneCmd.CombinedOutput() if err != nil { t.Fatalf("fossil clone: %v\n%s", err, cloneOut) } // 3. Create nobody user and grant write capabilities for testing // (Go-created repos don't have the standard nobody/anonymous users that fossil new creates) exec.Command(bin, "user", "new", "nobody", "", "cghijknorswy", "-R", remotePath).Run() exec.Command(bin, "user", "capabilities", "nobody", "cghijknorswy", "-R", remotePath).Run() // 4. Read project-code and server-code from the remote (they match local after clone) projCode := getProjectCode(t, remotePath) srvCode := getServerCode(t, remotePath) if projCode == "" { t.Fatal("project-code is empty") } // 4. Start fossil server on the remote serverURL := startFossilServer(t, remotePath) // 5. Re-open local repo and push via our sync engine r2, err := repo.Open(localPath) if err != nil { t.Fatalf("repo.Open: %v", err) } defer r2.Close() transport := &HTTPTransport{URL: serverURL} ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() result, err := Sync(ctx, r2, transport, SyncOpts{ Push: true, Pull: false, ProjectCode: projCode, ServerCode: srvCode, User: "", Password: "", }) // Log results regardless of error — this is informational since fossil // server behavior can be unpredictable in test environments. t.Logf("Push result: rounds=%d filesSent=%d filesRecvd=%d errors=%v err=%v", result.Rounds, result.FilesSent, result.FilesRecvd, result.Errors, err) if err != nil { t.Logf("NOTE: push to fossil server returned error (may be expected): %v", err) // Don't hard-fail; the unit tests with mock transport already validate engine logic. return } if result.Rounds < 1 { t.Errorf("expected at least 1 round, got %d", result.Rounds) } t.Logf("Push completed in %d rounds, sent %d files", result.Rounds, result.FilesSent) } func TestIntegrationPullFromFossilServer(t *testing.T) { bin := testutil.RequireFossilBin(t) dir, err := os.MkdirTemp("", "TestIntegrationPull*") if err != nil { t.Fatalf("MkdirTemp: %v", err) } t.Cleanup(func() { os.RemoveAll(dir) }) // 1. Create a remote repo with fossil new remotePath := filepath.Join(dir, "remote.fossil") newCmd := exec.Command(bin, "new", remotePath) newOut, err := newCmd.CombinedOutput() if err != nil { t.Fatalf("fossil new: %v\n%s", err, newOut) } // 2. Start fossil server on the remote serverURL := startFossilServer(t, remotePath) // 3. Clone it to local with fossil clone (so project/server codes match) localPath := filepath.Join(dir, "local.fossil") cloneCmd := exec.Command(bin, "clone", serverURL, localPath) cloneOut, err := cloneCmd.CombinedOutput() if err != nil { t.Fatalf("fossil clone: %v\n%s", err, cloneOut) } // 4. Read project-code and server-code from local clone projCode := getProjectCode(t, localPath) srvCode := getServerCode(t, localPath) if projCode == "" { t.Fatal("project-code is empty") } // 5. Open local with repo.Open and pull via our sync engine r, err := repo.Open(localPath) if err != nil { t.Fatalf("repo.Open: %v", err) } defer r.Close() transport := &HTTPTransport{URL: serverURL} ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() result, syncErr := Sync(ctx, r, transport, SyncOpts{ Push: false, Pull: true, ProjectCode: projCode, ServerCode: srvCode, User: "", Password: "", }) t.Logf("Pull result: rounds=%d filesSent=%d filesRecvd=%d errors=%v err=%v", result.Rounds, result.FilesSent, result.FilesRecvd, result.Errors, syncErr) if syncErr != nil { t.Logf("NOTE: pull from fossil server returned error (may be expected): %v", syncErr) return } // 6. Verify any received blobs pass content.Verify rows, err := r.DB().Query("SELECT rid FROM blob WHERE size >= 0") if err != nil { t.Fatalf("query blobs: %v", err) } defer rows.Close() verified := 0 for rows.Next() { var rid int64 if err := rows.Scan(&rid); err != nil { t.Fatalf("scan rid: %v", err) } if err := content.Verify(r.DB(), libfossil.FslID(rid)); err != nil { // Blobs stored by fossil clone may use Fossil's compression format // (4-byte size prefix) which our blob.Decompress doesn't handle yet. t.Logf("content.Verify rid=%d: %v", rid, err) } verified++ } if err := rows.Err(); err != nil { t.Fatalf("rows error: %v", err) } t.Logf("Pull completed in %d rounds, received %d files, verified %d blobs", result.Rounds, result.FilesRecvd, verified) }