Artifact 43333e415f0dae44e0bbf3e19fca9b1ff61556eeff1801cb6378980e0150e9f0:
- File
cli/repo_serve_test.go
— part of check-in
[6273a0750d]
at
2026-07-22 23:03:24
on branch trunk
— fix: correct go.mod module path to go-libfossil (#76) (#117)
* fix: correct go.mod module path to go-libfossil (#76)
The module directive still declared github.com/danmestas/libfossil after the repository was renamed to go-libfossil. pkg.go.dev/godoc scraping matches the declared module path against its hosting path, so the stale path broke SDK doc generation.
Rename the module path across all four in-repo modules (root, the two db/driver submodules, observer/otel, and docs/site) and every internal import, plus the release workflow, the doc-generation script, and the docs.
No tag has been published under the go-libfossil path yet, so the cross-module requires cannot resolve from the proxy. Add replace directives pointing the in-repo requires at their local directories so GOWORK=off builds and the CI matrix resolve without a published version. These replaces are ignored by downstream consumers and can be dropped once new-path tags are cut.
* Guard releases against replace directives; regenerate SDK docs
The go-libfossil rename bridges the in-repo modules with `replace` directives. Go refuses a main module that carries them, so a tag cut while they are present breaks `go install .../cmd/libfossil@<tag>` and every `@version` fetch of the driver submodules. Add a Release workflow step that fails the job before anything is published if any tracked go.mod still contains a replace directive, so the bridge is a CI-enforced TODO rather than a silent landmine. The step comment records what unblocks a release: re-tag the submodules with the corrected module paths, bump the root requires, then drop the replace blocks.
Regenerate the SDK reference docs. The previous pass only substituted URLs over the pre-existing generated files, leaving stale signatures (Repo.Timeline was still documented as taking LogOpts rather than TimelineOpts). Note that gomarkdoc must be run outside a git worktree — inside one it silently strips every source link.
Also reword the README rename note, which read as though the import path had not changed, and re-sort the ncruces require block. (user: [email protected], size: 14667) [annotate] [blame] [check-ins using]
package cli_test import ( "bytes" "context" "fmt" "math/rand" "net" "net/http" "os" "os/exec" "path/filepath" "strconv" "sync/atomic" "syscall" "testing" "time" "github.com/alecthomas/kong" libfossil "github.com/danmestas/go-libfossil" "github.com/danmestas/go-libfossil/cli" "github.com/danmestas/go-libfossil/internal/repo" "github.com/danmestas/go-libfossil/internal/sync" "github.com/danmestas/go-libfossil/internal/xfer" "github.com/danmestas/go-libfossil/testutil" _ "github.com/danmestas/go-libfossil/internal/testdriver" ) // NOTE: TestRepoServeCmdRun and every test that goes through serveRepo send // a real SIGINT to the test binary's own process (syscall.Kill) to exercise // RepoServeCmd's Ctrl-C shutdown path end-to-end, rather than faking it by // cancelling an injected context. That is only safe because // nothing else in package cli_test is listening for signals or running // concurrently with them at the same time: no test in this package calls // t.Parallel(), and no other test installs its own signal.Notify or // signal.NotifyContext. If a future test in this package adds either, the // SIGINT sent here can be delivered to (or race with) that unrelated // handler instead, producing flakiness that is hard to diagnose from the // failure alone. Keep both invariants true for this package, or convert // these tests to inject a cancellable context instead of signaling the // process. // serveTestCLI mirrors the wiring in cmd/libfossil/main.go closely enough to // exercise kong flag parsing for RepoServeCmd without depending on the main // package. type serveTestCLI struct { cli.Globals Repo cli.RepoCmd `cmd:""` } func freeAddr(t *testing.T) string { t.Helper() ln, err := net.Listen("tcp", "127.0.0.1:0") if err != nil { t.Fatal(err) } addr := ln.Addr().String() ln.Close() return addr } func TestRepoServeCmdDefaultAddr(t *testing.T) { var c serveTestCLI parser, err := kong.New(&c) if err != nil { t.Fatalf("kong.New: %v", err) } if _, err := parser.Parse([]string{"repo", "serve"}); err != nil { t.Fatalf("parse: %v", err) } if c.Repo.Serve.Addr != "127.0.0.1:8080" { t.Errorf("default Addr = %q, want 127.0.0.1:8080", c.Repo.Serve.Addr) } } func TestRepoServeCmdAddrFlagOverride(t *testing.T) { var c serveTestCLI parser, err := kong.New(&c) if err != nil { t.Fatalf("kong.New: %v", err) } if _, err := parser.Parse([]string{"repo", "serve", "--addr", "0.0.0.0:9191"}); err != nil { t.Fatalf("parse: %v", err) } if c.Repo.Serve.Addr != "0.0.0.0:9191" { t.Errorf("Addr = %q, want 0.0.0.0:9191", c.Repo.Serve.Addr) } } // TestRepoServeCmdRun starts the server via the CLI command, confirms a real // network round trip against it, then sends SIGINT (as Ctrl-C would) and // verifies Run returns cleanly rather than hanging. func TestRepoServeCmdRun(t *testing.T) { tmp := t.TempDir() repoPath := filepath.Join(tmp, "serve.fossil") r, err := libfossil.Create(repoPath, libfossil.CreateOpts{User: "test"}) if err != nil { t.Fatalf("Create: %v", err) } r.Close() addr := freeAddr(t) g := &cli.Globals{Repo: repoPath} c := &cli.RepoServeCmd{Addr: addr} done := make(chan error, 1) go func() { done <- c.Run(g) }() // Poll until the server accepts connections, then confirm it answers // with the xfer server probe page over an actual network round trip. var resp *http.Response deadline := time.Now().Add(5 * time.Second) for { resp, err = http.Get("http://" + addr + "/") if err == nil { break } if time.Now().After(deadline) { t.Fatalf("server never came up: %v", err) } time.Sleep(20 * time.Millisecond) } resp.Body.Close() if resp.StatusCode != http.StatusOK { t.Fatalf("GET status = %d, want 200", resp.StatusCode) } // Ctrl-C: send SIGINT to our own process. signal.NotifyContext inside // Run should translate this into context cancellation. if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil { t.Fatalf("signal self: %v", err) } select { case runErr := <-done: if runErr != nil { t.Fatalf("Run returned error after interrupt: %v", runErr) } case <-time.After(5 * time.Second): t.Fatal("Run did not return after interrupt (hang)") } } // TestRepoServeCmdCanonicalFossilClone proves the served repository is // actually compatible with a stock fossil client, not just this library's // own transport. Skips if the canonical fossil binary isn't on PATH. func TestRepoServeCmdCanonicalFossilClone(t *testing.T) { bin := requireFossilBin(t) tmp := t.TempDir() repoPath := filepath.Join(tmp, "serve.fossil") r, err := libfossil.Create(repoPath, libfossil.CreateOpts{User: "test"}) if err != nil { t.Fatalf("Create: %v", err) } _, _, err = r.Commit(libfossil.CommitOpts{ Files: []libfossil.FileToCommit{ {Name: "hello.txt", Content: []byte("hello from serve\n")}, }, Comment: "initial commit", User: "test", }) if err != nil { r.Close() t.Fatalf("Commit: %v", err) } r.Close() addr := serveRepo(t, repoPath) clonePath := filepath.Join(tmp, "clone.fossil") cmd := exec.Command(bin, "clone", "http://"+addr, clonePath) out, err := cmd.CombinedOutput() if err != nil { t.Fatalf("canonical fossil clone failed: %v\n%s", err, out) } if _, err := os.Stat(clonePath); err != nil { t.Fatalf("cloned repository missing: %v", err) } } // requireFossilBin returns the canonical fossil binary, skipping the test if // it is absent. A skip is invisible without -v, so a run with no fossil // binary would otherwise be byte-identical to a passing one -- for the single // criterion our own transport cannot substitute for. CI sets // REQUIRE_FOSSIL_BIN=1 to turn a missing binary into a failure (issue #86). func requireFossilBin(t *testing.T) string { t.Helper() bin := testutil.FossilBinary() if bin == "" { if os.Getenv("REQUIRE_FOSSIL_BIN") == "1" { t.Fatal("REQUIRE_FOSSIL_BIN=1 but no fossil binary on PATH") } t.Skip("fossil binary not on PATH; cannot verify canonical interoperability") } if out, err := exec.Command(bin, "version").CombinedOutput(); err == nil { t.Logf("using canonical fossil binary %s: %s", bin, bytes.TrimSpace(out)) } else { t.Logf("using canonical fossil binary %s (version check failed: %v)", bin, err) } return bin } // serveRepo starts the CLI serve command over repoPath and returns its // address once it accepts connections. The server is shut down at test end. func serveRepo(t *testing.T, repoPath string) string { t.Helper() addr := freeAddr(t) g := &cli.Globals{Repo: repoPath} c := &cli.RepoServeCmd{Addr: addr} done := make(chan error, 1) go func() { done <- c.Run(g) }() t.Cleanup(func() { // SIGINT is Run's shutdown path only while its signal.NotifyContext // is installed. If Run has already returned, the handler is gone and // SIGINT's default action kills the whole test binary -- turning a // server failure into a truncated process death with no verdict. // Report that case instead of signalling into it. select { case err := <-done: t.Errorf("serve exited before the test finished: %v", err) return default: } syscall.Kill(os.Getpid(), syscall.SIGINT) <-done }) deadline := time.Now().Add(10 * time.Second) for { conn, dialErr := net.DialTimeout("tcp", addr, 200*time.Millisecond) if dialErr == nil { conn.Close() return addr } if time.Now().After(deadline) { t.Fatalf("server never came up: %v", dialErr) } time.Sleep(20 * time.Millisecond) } } // writeCorpus commits payloadBytes of pseudo-random file content into r, // fileBytes per file and filesPerCommit files per commit. Content is // incompressible and per-file distinct so nothing collapses into a shared // blob, which is what makes the corpus size a faithful proxy for the wire // bytes a clone of it must carry. func writeCorpus(t *testing.T, r *libfossil.Repo, payloadBytes, fileBytes, filesPerCommit int) { t.Helper() if payloadBytes <= 0 || fileBytes <= 0 || filesPerCommit <= 0 { t.Fatalf("writeCorpus: sizes must be positive, got %d/%d/%d", payloadBytes, fileBytes, filesPerCommit) } files := (payloadBytes + fileBytes - 1) / fileBytes rng := rand.New(rand.NewSource(1)) batch := make([]libfossil.FileToCommit, 0, filesPerCommit) flush := func(n int) { if len(batch) == 0 { return } if _, _, err := r.Commit(libfossil.CommitOpts{ Files: batch, Comment: fmt.Sprintf("corpus commit %d", n), User: "test", }); err != nil { t.Fatalf("Commit: %v", err) } batch = batch[:0] } for i := range files { content := make([]byte, fileBytes) if _, err := rng.Read(content); err != nil { t.Fatalf("rng.Read: %v", err) } batch = append(batch, libfossil.FileToCommit{ Name: fmt.Sprintf("corpus/file%05d.bin", i), Content: content, }) if len(batch) == filesPerCommit { flush(i / filesPerCommit) } } flush(files / filesPerCommit) } // serveCountingClones starts the same server stack `repo serve` runs -- // sync.ServeHTTP over sync.HandleSync -- with a wrapper that counts the clone // batches the server emits, and returns the address and that counter. // // The count is taken on the server because no client-side number is portable. // fossil prints "Round-trips: N" once per round, carriage-return separated, so // on 2.23 the first occurrence is always 1 regardless of how many rounds ran; // 2.28 added a TTY guard that collapses it to a single final line. Parsing that // output made the assertion mean different things on different clients, and CI // (2.23) disagreed with this machine (2.28) on an identical server. Counting // batches the server actually emitted is the property under test anyway. // // This bypasses cli.RepoServeCmd, whose Run is a five-line wrapper around the // same ServeHTTP call. TestRepoServeCmdCanonicalFossilClone still drives the // command itself end to end. func serveCountingClones(t *testing.T, repoPath string) (addr string, batches *atomic.Int64) { t.Helper() src, err := repo.Open(repoPath) if err != nil { t.Fatalf("repo.Open: %v", err) } t.Cleanup(func() { src.Close() }) batches = &atomic.Int64{} h := func(ctx context.Context, r *repo.Repo, req *xfer.Message) (*xfer.Message, error) { for _, c := range req.Cards { if _, ok := c.(*xfer.CloneCard); ok { batches.Add(1) break } } return sync.HandleSync(ctx, r, req) } addr = freeAddr(t) ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) go func() { done <- sync.ServeHTTP(ctx, addr, src, h) }() t.Cleanup(func() { cancel() <-done }) deadline := time.Now().Add(10 * time.Second) for { conn, dialErr := net.DialTimeout("tcp", addr, 200*time.Millisecond) if dialErr == nil { conn.Close() return addr, batches } if time.Now().After(deadline) { t.Fatalf("server never came up: %v", dialErr) } time.Sleep(20 * time.Millisecond) } } // artifactCount reports the public artifacts a repository holds, as canonical // fossil counts them. func artifactCount(t *testing.T, bin, repoPath string) int { t.Helper() out, err := exec.Command(bin, "sqlite3", "--no-repository", repoPath, "SELECT count(*) FROM blob WHERE size>=0;").Output() if err != nil { t.Fatalf("counting artifacts in %s: %v", repoPath, err) } n, err := strconv.Atoi(string(bytes.TrimSpace(out))) if err != nil { t.Fatalf("unparseable artifact count %q: %v", out, err) } return n } // TestRepoServeCmdCanonicalFossilMultiBatchClone is the interop half of #92: // a real fossil client cloning a corpus from a libfossil server, sized past // the server's clone pagination boundary so the multi-round path runs. // // Method: build a corpus whose content exceeds sync.DefaultCloneBatchBytes // several times over, serve it, clone with the canonical binary, then assert // on three independent things -- how many clone batches the server emitted // (counted server-side; otherwise the pagination path may never have been // reached and the rest proves nothing), that the clone holds every artifact // the source held, and that canonical's own `test-integrity` accepts it. // // Corpus size is a parameter because the interesting behavior is at the batch // boundary, not at any particular size. func TestRepoServeCmdCanonicalFossilMultiBatchClone(t *testing.T) { bin := requireFossilBin(t) for _, tc := range []struct { name string payloadBytes int fileBytes int // batchesMax is the discriminating assertion for #88. The corpus of // many small artifacts is a fraction of one round's byte budget, so a // size-bounded server drains it in a single clone batch no matter how // many artifacts it holds. A count-bounded server needs one batch per // 200 artifacts, which is the ceiling #88 describes. Zero means no // upper bound is asserted. batchesMax int batchesMin int }{ {"single-batch", sync.DefaultCloneBatchBytes / 4, 64 * 1024, 0, 1}, {"multi-batch", sync.DefaultCloneBatchBytes * 3, 64 * 1024, 0, 3}, {"many-small-artifacts", 1 << 20, 512, 2, 1}, } { t.Run(tc.name, func(t *testing.T) { tmp := t.TempDir() repoPath := filepath.Join(tmp, "serve.fossil") r, err := libfossil.Create(repoPath, libfossil.CreateOpts{User: "test"}) if err != nil { t.Fatalf("Create: %v", err) } writeCorpus(t, r, tc.payloadBytes, tc.fileBytes, 256) r.Close() sourceArtifacts := artifactCount(t, bin, repoPath) addr, batches := serveCountingClones(t, repoPath) clonePath := filepath.Join(tmp, "clone.fossil") out, err := exec.Command(bin, "clone", "http://"+addr, clonePath).CombinedOutput() if err != nil { t.Fatalf("canonical fossil clone failed: %v\n%s", err, out) } got := int(batches.Load()) t.Logf("corpus %d bytes, %d artifacts, server emitted %d clone batches", tc.payloadBytes, sourceArtifacts, got) if got < tc.batchesMin { t.Errorf("server emitted %d clone batches, want >= %d; the %s path was not exercised", got, tc.batchesMin, tc.name) } if tc.batchesMax > 0 && got > tc.batchesMax { t.Errorf("server emitted %d clone batches for %d artifacts, want <= %d; "+ "it is paginating by artifact count, not output size (#88)", got, sourceArtifacts, tc.batchesMax) } if got := artifactCount(t, bin, clonePath); got != sourceArtifacts { t.Errorf("clone holds %d artifacts, source held %d", got, sourceArtifacts) } integrity, err := exec.Command(bin, "test-integrity", "-R", clonePath).CombinedOutput() if err != nil { t.Fatalf("fossil test-integrity failed: %v\n%s", err, integrity) } t.Logf("test-integrity: %s", bytes.TrimSpace(integrity)) }) } }