Go 192 lines
package cockpit
import (
"bytes"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
)
// syncSet is a small, stable playbook set for Sync tests: the real handover
// source plus a synthetic one, so multi-playbook behavior is observable.
func syncSet(t *testing.T) []Playbook {
t.Helper()
return []Playbook{loadHandover(t), synthPlaybook("zeta")}
}
func findKind(rep SyncReport, kind string) *SyncFinding {
for i := range rep.Findings {
if rep.Findings[i].Kind == kind {
return &rep.Findings[i]
}
}
return nil
}
// TestSync_EmptyLedgerClean: a cockpit that was never generated (no ledger,
// only a default selection) returns a silent clean — the load-bearing
// empty-ledger gate that keeps the post-merge builtin a no-op.
func TestSync_EmptyLedgerClean(t *testing.T) {
cfg := testConfig(t)
rep, err := Sync(cfg, syncSet(t))
if err != nil {
t.Fatal(err)
}
if !rep.Clean || len(rep.Findings) != 0 {
t.Fatalf("empty-ledger Sync = %+v, want clean with no findings", rep)
}
}
// TestSync_DriftedAfterHandEdit: a hand-edit to an emitted artifact is
// reported as a drifted finding.
func TestSync_DriftedAfterHandEdit(t *testing.T) {
cfg := testConfig(t)
set := syncSet(t)
if err := SaveSelection(cfg, Selection{Targets: []string{"claude"}}); err != nil {
t.Fatal(err)
}
for _, pb := range set {
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatalf("generate %s: %v", pb.Name, err)
}
}
dst := filepath.Join(cfg.UserDir, claudeRenderer{}.RelPath(set[0]))
if err := os.WriteFile(dst, []byte("edited\n"), 0o644); err != nil {
t.Fatal(err)
}
rep, err := Sync(cfg, set)
if err != nil {
t.Fatal(err)
}
if rep.Clean {
t.Fatal("Sync reported clean over a hand-edited artifact")
}
if findKind(rep, "drifted") == nil {
t.Fatalf("no drifted finding: %+v", rep.Findings)
}
}
// TestSync_MissingAfterTargetAdd: activating a target without generating it
// surfaces a missing finding for that target.
func TestSync_MissingAfterTargetAdd(t *testing.T) {
cfg := testConfig(t)
set := syncSet(t)
for _, pb := range set {
if _, err := Generate(cfg, pb, "claude"); err != nil {
t.Fatal(err)
}
}
if err := SaveSelection(cfg, Selection{Targets: []string{"claude", "cursor"}}); err != nil {
t.Fatal(err)
}
rep, err := Sync(cfg, set)
if err != nil {
t.Fatal(err)
}
if rep.Clean {
t.Fatal("Sync clean despite an un-emitted active target")
}
missing := false
for _, f := range rep.Findings {
if f.Target == "cursor" && f.Kind == "missing" {
missing = true
}
}
if !missing {
t.Fatalf("no cursor missing finding: %+v", rep.Findings)
}
}
// TestSync_OrphanDedupByTarget: a deselected per-playbook target with K
// ledger records collapses to exactly one orphan finding.
func TestSync_OrphanDedupByTarget(t *testing.T) {
cfg := testConfig(t)
set := syncSet(t)
if err := SaveSelection(cfg, Selection{Targets: []string{"cursor"}}); err != nil {
t.Fatal(err)
}
for _, pb := range set {
if _, err := Generate(cfg, pb, "cursor"); err != nil {
t.Fatalf("generate cursor/%s: %v", pb.Name, err)
}
}
// Deselect cursor; its K artifacts are now orphaned.
if err := SaveSelection(cfg, Selection{Targets: []string{"claude"}}); err != nil {
t.Fatal(err)
}
rep, err := Sync(cfg, set)
if err != nil {
t.Fatal(err)
}
orphans := 0
for _, f := range rep.Findings {
if f.Kind == "orphan" {
orphans++
if f.Target != "cursor" {
t.Errorf("orphan finding target = %q, want cursor", f.Target)
}
}
}
if orphans != 1 {
t.Fatalf("orphan findings = %d, want exactly 1 (dedup by target); findings = %+v", orphans, rep.Findings)
}
}
var rfc3339Date = regexp.MustCompile(`\d{4}-\d{2}-\d{2}T\d{2}:`)
// TestRenderersDeterministic_NoHostStrings is the derive-at-fire guard:
// every renderer produces byte-identical output on a repeat render and bakes
// no host-specific or volatile value into the artifact (which would make
// verify drift forever).
func TestRenderersDeterministic_NoHostStrings(t *testing.T) {
set := []Playbook{synthPlaybook("alpha"), synthPlaybook("beta")}
for _, target := range Targets() {
r, ok := rendererFor(target)
if !ok {
t.Fatalf("no renderer for %q", target)
}
var out []byte
if agg, isAgg := isAggregate(r); isAgg {
b1, err := agg.RenderAll(set)
if err != nil {
t.Fatalf("%s RenderAll: %v", target, err)
}
b2, err := agg.RenderAll(set)
if err != nil {
t.Fatalf("%s RenderAll: %v", target, err)
}
if !bytes.Equal(b1, b2) {
t.Errorf("%s RenderAll is not deterministic", target)
}
out = b1
} else {
b1, err := r.Render(set[0])
if err != nil {
t.Fatalf("%s Render: %v", target, err)
}
b2, err := r.Render(set[0])
if err != nil {
t.Fatalf("%s Render: %v", target, err)
}
if !bytes.Equal(b1, b2) {
t.Errorf("%s Render is not deterministic", target)
}
out = b1
}
s := string(out)
for _, bad := range []string{"/Users/", "/home/", "$USER"} {
if strings.Contains(s, bad) {
t.Errorf("%s output contains host string %q (derive-at-fire violated)", target, bad)
}
}
if rfc3339Date.MatchString(s) {
t.Errorf("%s output contains a baked timestamp (derive-at-fire violated)", target)
}
}
}