package zitcli
import (
"craftdesign.group/quarry/internal/fault"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
// fakeZit puts a script named "zit" first on PATH for this test.
func fakeZit(t *testing.T, script string) {
t.Helper()
dir := t.TempDir()
p := filepath.Join(dir, "zit")
if err := os.WriteFile(p, []byte("#!/bin/sh\n"+script+"\n"), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", dir+string(os.PathListSeparator)+os.Getenv("PATH"))
if _, err := exec.LookPath("zit"); err != nil {
t.Fatalf("fake zit not on PATH: %v", err)
}
}
// When the child dies partway through the stream the reader sees an EOF
// — but the EOF is the shape of the silence, not the reason for it. The
// reason is on the child's stderr, and reporting the symptom alone is
// how one store failing to ingest became ten thousand identical journal
// rows naming neither the store nor the cause.
func TestFramesReportsWhyTheChildStopped(t *testing.T) {
fakeZit(t, `printf 'artifact aaaa 2\nhi\n'; echo "store is locked by another process" >&2; exit 1`)
c := &Client{repo: "tcl"}
var seen int
_, err := c.framesEach(Cursor{}, false, func(Artifact) error { seen++; return nil })
if err == nil {
t.Fatal("a child that exited 1 mid-stream was reported as success")
}
if seen != 1 {
t.Errorf("artifacts delivered = %d, want the one that arrived before the failure", seen)
}
got := err.Error()
if !strings.Contains(got, "store is locked by another process") {
t.Errorf("the child's stderr is missing, so the journal names no cause:\n %s", got)
}
// The store is named in the JOURNAL, which is what this assertion
// always meant — its own message says "the journal names no cause".
// It used to read err.Error(), the string a CLIENT receives, and
// those parted company when the store path became log-only: it
// describes the forge's filesystem rather than the request, and a
// 404 body carrying it was readable by anyone who could provoke the
// error. Same intent, correct reader.
var named bool
for _, fld := range fault.FieldsOf(err) {
if fld.Key == "store" && strings.Contains(fld.Value, "tcl") {
named = true
}
}
if !named {
t.Errorf("the store is not named in the journal fields:\n %s", got)
}
if strings.Contains(got, "tcl") {
t.Errorf("the store path reached the CLIENT's error:\n %s", got)
}
}
// The other direction: when WE abandon the stream, the child's exit is
// our own signal and the parse error is the real story. Killing it and
// then blaming its status would invent a cause.
func TestFramesKeepsItsOwnErrorWhenItAbandonsTheStream(t *testing.T) {
fakeZit(t, `printf 'nonsense line\n'; sleep 30`)
c := &Client{repo: "tcl"}
_, err := c.framesEach(Cursor{}, false, func(Artifact) error { return nil })
if err == nil {
t.Fatal("an unparseable frame was accepted")
}
if got := err.Error(); !strings.Contains(got, "unexpected frame line") {
t.Errorf("the parse error was replaced by the kill's status:\n %s", got)
}
}
// A clean stream still parses, so the fix did not move the happy path.
func TestFramesStillReadsAGoodStream(t *testing.T) {
fakeZit(t, `printf 'artifact aaaa 2\nhi\ncursor S123 V456\n'`)
c := &Client{repo: "tcl"}
var got []Artifact
cur, err := c.framesEach(Cursor{}, false, func(a Artifact) error {
got = append(got, a)
return nil
})
if err != nil {
t.Fatalf("a good stream failed: %v", err)
}
if len(got) != 1 || string(got[0].Bytes) != "hi" {
t.Errorf("artifacts = %+v", got)
}
if cur.Since != "S123" || cur.Verify != "V456" {
t.Errorf("cursor = %+v", cur)
}
}