// Package harness runs the disposability loop (0001 §11): ingest real
// stores, snapshot every rendered surface, rm -rf the derived data
// directory, re-ingest from the stores, snapshot again, and semantically
// diff. Any diff is hostage state and fails the build. The fleet version
// runs two stores through one process — shared NATS, per-repo indexes —
// and asserts they cannot bleed into each other.
package harness
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/events"
"craftdesign.group/quarry/internal/index"
"craftdesign.group/quarry/internal/ingest"
"craftdesign.group/quarry/internal/oltp"
"craftdesign.group/quarry/internal/ops"
"craftdesign.group/quarry/internal/semdiff"
"craftdesign.group/quarry/internal/web"
"craftdesign.group/quarry/internal/zitcli"
)
type surface struct {
path string
kind semdiff.Kind
}
// surfaces enumerates what gets snapshotted for one repo, plus the
// fleet-level pages.
func surfaces(repos []string) []surface {
out := []surface{
{"/", semdiff.HTML},
{"/api/v1/repos", semdiff.JSON},
{"/api/v1/version", semdiff.JSON},
{"/api/v1/openapi.json", semdiff.JSON},
}
for _, r := range repos {
p := "/r/" + r
a := "/api/v1/r/" + r
out = append(out,
surface{p + "/timeline", semdiff.HTML},
surface{p + "/wiki", semdiff.HTML},
surface{p + "/wiki/Guide", semdiff.HTML},
surface{p + "/wiki/Guide/history", semdiff.HTML},
surface{a + "/wiki/Guide", semdiff.JSON},
surface{a + "/wiki/Guide/history", semdiff.JSON},
surface{p + "/ticket", semdiff.HTML},
surface{p + "/search?q=README", semdiff.HTML},
surface{a + "/timeline", semdiff.JSON},
surface{a + "/wiki", semdiff.JSON},
surface{a + "/ticket", semdiff.JSON},
surface{a + "/search?q=README", semdiff.JSON},
)
}
return out
}
func zrun(t *testing.T, dir string, args ...string) {
t.Helper()
cmd := exec.Command("zit", args...)
cmd.Dir = dir
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("zit %v: %v\n%s", args, err, out)
}
}
// buildStore creates one store under test: a zit repository with a few
// commits carrying a distinguishing seed word.
func buildStore(t *testing.T, seed string) string {
t.Helper()
if _, err := exec.LookPath("zit"); err != nil {
t.Skip("zit not on PATH")
}
repo := t.TempDir()
zrun(t, repo, "init")
for _, f := range []struct{ name, body, msg string }{
{"README.md", "# store " + seed + "\n", "add README " + seed},
{"src/main.go", "package " + seed + "\n", "add main"},
{"README.md", "# store " + seed + "\nmore\n", "extend README"},
} {
if err := os.MkdirAll(filepath.Dir(filepath.Join(repo, f.name)), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(repo, f.name), []byte(f.body), 0o644); err != nil {
t.Fatal(err)
}
zrun(t, repo, "add", f.name)
zrun(t, repo, "commit", "-m", f.msg)
}
// Non-checkin artifacts too (zit#71 verbs): the disposability rule
// covers every rendered surface, wiki and tickets included.
wiki := exec.Command("zit", "wiki", "commit", "Guide", "--user", seed, "--mimetype", "text/x-markdown")
wiki.Dir = repo
wiki.Stdin = strings.NewReader("# Guide\n\n" + seed + " wiki body\n")
if out, err := wiki.CombinedOutput(); err != nil {
t.Fatalf("zit wiki commit: %v\n%s", err, out)
}
zrun(t, repo, "ticket", "new", "--user", seed, "title="+seed+" ticket", "status=Open")
return repo
}
// ingestAndServe is the from-nothing build: one embedded NATS and one
// index per repo, all under dataDir (so the wipe destroys stream,
// cursors, and every table), each store flowing watcher → events → fold
// under its own project code, both registry consumers on top.
func ingestAndServe(t *testing.T, repos map[string]string, dataDir string) *httptest.Server {
t.Helper()
return ingestAndServeAuth(t, repos, dataDir, nil)
}
func ingestAndServeAuth(t *testing.T, repos map[string]string, dataDir string, verify api.Verify) *httptest.Server {
t.Helper()
return ingestAndServeAuthMod(t, repos, dataDir, verify, nil, nil, nil)
}
// ingestAndServeAuthMod additionally wires the moderation trust seam
// and the AI hook, both nil-safe.
func ingestAndServeAuthMod(t *testing.T, repos map[string]string, dataDir string, verify api.Verify,
trusted func(account, repo string) bool,
hook func(context.Context, oltp.Staged) (api.HookVerdict, error),
notify api.NotifyPlane) *httptest.Server {
t.Helper()
ev, err := events.Start(filepath.Join(dataDir, "nats"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(ev.Close)
// The vote plane is wired exactly as the server wires it (one OLTP
// under dataDir), so vote state dies with the wipe like all forge
// state — and its absence would silently unregister forum.vote.
odb, err := oltp.Open(filepath.Join(dataDir, "oltp.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { odb.Close() })
deps := api.Deps{Version: "0.0.0-harness", Repos: api.NewRepos(nil), Votes: odb, Forum: odb,
TrustedAuthor: trusted, ForumHook: hook, Notify: notify}
// RepoMeta mirrors main's wiring so topic search behaves as deployed.
deps.RepoMeta = func() map[string]api.RepoMeta {
rows, err := odb.Repos()
if err != nil {
return nil
}
m := make(map[string]api.RepoMeta, len(rows))
for _, row := range rows {
m[row.Name] = api.RepoMeta{Homepage: row.Homepage, Topics: strings.Fields(row.Topics)}
}
return m
}
for name, path := range repos {
ix, err := index.Open(filepath.Join(dataDir, name+".db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { ix.Close() })
cli := zitcli.New(path)
pipe := ingest.NewPipeline(cli, ev, ix, name, "harness")
// Leaves must be wired here exactly as the server wires it, or
// the harness proves things about a quarry that does not exist:
// leaf status comes from zit, and an unwired provider silently
// marks nothing.
zc := cli
deps.Repos.Put(name, &api.Repo{Index: ix, Zit: cli, Sync: pipe.Sync,
Leaves: func() (map[string]bool, error) { return zc.Leaves(context.Background()) }})
if _, err := pipe.Sync(); err != nil {
t.Fatal(err)
}
}
r := ops.NewRegistry()
if err := api.RegisterAll(r, deps); err != nil {
t.Fatal(err)
}
apiRouter, err := api.Router(r, "0.0.0-harness", verify)
if err != nil {
t.Fatal(err)
}
mux := http.NewServeMux()
mux.Handle("/api/", apiRouter)
mux.Handle("/", web.Handler(r, nil, nil, web.Verify(verify)))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func snapshot(t *testing.T, srv *httptest.Server, path string) []byte {
t.Helper()
resp, err := srv.Client().Get(srv.URL + path)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("%s: status %d", path, resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return body
}
func TestDisposability(t *testing.T) {
stores := map[string]string{
"alpha": buildStore(t, "alphaseed"),
"beta": buildStore(t, "betaseed"),
}
sf := surfaces([]string{"alpha", "beta"})
dataA := filepath.Join(t.TempDir(), "data")
if err := os.MkdirAll(dataA, 0o755); err != nil {
t.Fatal(err)
}
first := ingestAndServe(t, stores, dataA)
// Per-repo check-in and file surfaces are discovered live; their
// paths are content-addressed hashes, identical across rebuilds.
for _, repo := range []string{"alpha", "beta"} {
var tl struct {
Entries []struct {
Kind string `json:"kind"`
Hash string `json:"hash"`
} `json:"entries"`
}
if err := json.Unmarshal(snapshot(t, first, "/api/v1/r/"+repo+"/timeline?y=ci"), &tl); err != nil {
t.Fatal(err)
}
if len(tl.Entries) == 0 {
t.Fatalf("%s: no check-ins", repo)
}
for _, e := range tl.Entries {
if e.Kind != "ci" {
continue
}
sf = append(sf,
surface{"/r/" + repo + "/checkin/" + e.Hash, semdiff.HTML},
surface{"/r/" + repo + "/checkin/" + e.Hash + "/diff", semdiff.HTML},
surface{"/api/v1/r/" + repo + "/checkin/" + e.Hash, semdiff.JSON})
}
var tip struct {
Files []struct {
Name string `json:"name"`
} `json:"files"`
}
if err := json.Unmarshal(snapshot(t, first, "/api/v1/r/"+repo+"/checkin/"+tl.Entries[0].Hash), &tip); err != nil {
t.Fatal(err)
}
for _, f := range tip.Files {
sf = append(sf,
surface{"/r/" + repo + "/file/" + tl.Entries[0].Hash + "/" + f.Name, semdiff.HTML},
surface{"/api/v1/r/" + repo + "/file/" + tl.Entries[0].Hash + "/" + f.Name, semdiff.JSON})
}
}
// Isolation: repo A's content is invisible from repo B's surfaces.
var cross struct {
Count int `json:"count"`
}
if err := json.Unmarshal(snapshot(t, first, "/api/v1/r/beta/search?q=alphaseed"), &cross); err != nil {
t.Fatal(err)
}
if cross.Count != 0 {
t.Fatalf("repo isolation broken: alpha content searchable from beta (%d hits)", cross.Count)
}
if err := json.Unmarshal(snapshot(t, first, "/api/v1/r/alpha/search?q=alphaseed"), &cross); err != nil {
t.Fatal(err)
}
if cross.Count == 0 {
t.Fatal("alpha content not searchable from alpha")
}
before := map[string][]byte{}
for _, s := range sf {
before[s.path] = snapshot(t, first, s.path)
}
first.Close()
// The wipe (0001 §11 step 2): every derived byte goes — both
// indexes, the event stream, the cursors.
if err := os.RemoveAll(dataA); err != nil {
t.Fatal(err)
}
dataB := filepath.Join(t.TempDir(), "data")
if err := os.MkdirAll(dataB, 0o755); err != nil {
t.Fatal(err)
}
second := ingestAndServe(t, stores, dataB)
for _, s := range sf {
after := snapshot(t, second, s.path)
d, err := semdiff.Diff(s.kind, before[s.path], after)
if err != nil {
t.Fatalf("%s: %v", s.path, err)
}
if d != "" {
t.Errorf("%s: semantic diff after wipe-and-reingest — hostage state:\n%s", s.path, d)
}
}
}
// TestSemanticNotByteEquality pins why the harness diffs semantically:
// a rendering-order change must pass, missing data must fail (0001 §11).
func TestSemanticNotByteEquality(t *testing.T) {
d, err := semdiff.Diff(semdiff.JSON,
[]byte(`{"b":1,"a":"2026-08-14T10:00:00+02:00"}`),
[]byte(`{"a":"2026-08-14 08:00:00Z","b":1}`))
if err != nil || d != "" {
t.Fatalf("harmless drift flagged: %q %v", d, err)
}
d, err = semdiff.Diff(semdiff.JSON, []byte(`{"a":1,"b":2}`), []byte(`{"a":1}`))
if err != nil || d == "" {
t.Fatal("dropped data not flagged")
}
if !strings.Contains(d, "b") {
t.Fatalf("diff should name the loss: %s", d)
}
}