package cockpit import ( "encoding/json" "os" "path/filepath" "strings" "testing" ) // loadHandover reads the real shipped handover source (the // internal/playbooks data file) and unmarshals it into a Playbook. Reading // the file directly, rather than importing internal/playbooks, keeps the // cockpit package free of the import cycle while still exercising the real // source through the machinery. func loadHandover(t *testing.T) Playbook { t.Helper() b, err := os.ReadFile(filepath.Join("..", "playbooks", "data", "handover.json")) if err != nil { t.Fatalf("read handover source: %v", err) } var pb Playbook if err := json.Unmarshal(b, &pb); err != nil { t.Fatalf("parse handover source: %v", err) } return pb } func TestClaudeRender_Structure(t *testing.T) { pb := loadHandover(t) out, err := claudeRenderer{}.Render(pb) if err != nil { t.Fatalf("Render: %v", err) } got := string(out) if !strings.HasPrefix(got, "---\n") { t.Errorf("output does not open with frontmatter fence:\n%s", got) } if !strings.HasSuffix(got, "\n") { t.Error("output does not end with a trailing newline") } for _, want := range []string{ "name: handover\n", "description: " + pb.Description + "\n", "allowed-tools: ", "# Handover\n", "## Step 0 — ", "## Step 1 — ", "## Step 2 — ", "## Step 3 — ", "## Step 4 — ", "## Output\n", } { if !strings.Contains(got, want) { t.Errorf("rendered SKILL.md missing %q:\n%s", want, got) } } // The allowed-tools line carries the composed allowlist, single-line. allowLine := frontmatterValue(t, out, "allowed-tools") for _, want := range []string{ "Read", "Write", "Grep", "Glob", "Agent", "Task", "AskUserQuestion", "Bash(git status:*)", "Bash(git stash list:*)", "Bash(date:*)", "Bash(head:*)", } { if !strings.Contains(allowLine, want) { t.Errorf("allowed-tools missing %q: %s", want, allowLine) } } // The safety warning is derived from Intent and must name every // forbidden phrase verbatim, so the prose is provably in sync with the // gate's denylist. for _, phrase := range pb.Intent.Forbidden { if !strings.Contains(got, phrase) { t.Errorf("safety warning missing forbidden phrase %q", phrase) } } } // frontmatterValue returns the value of a single-line frontmatter key. func frontmatterValue(t *testing.T, content []byte, key string) string { t.Helper() for _, line := range strings.Split(string(content), "\n") { if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == key { return strings.TrimSpace(v) } } t.Fatalf("frontmatter key %q not found", key) return "" } func TestClaudeRender_Deterministic(t *testing.T) { pb := loadHandover(t) a, err := claudeRenderer{}.Render(pb) if err != nil { t.Fatal(err) } b, err := claudeRenderer{}.Render(pb) if err != nil { t.Fatal(err) } if string(a) != string(b) { t.Error("Render is not deterministic for the same Playbook") } } func TestClaudeRender_RejectsMultilineFrontmatter(t *testing.T) { pb := loadHandover(t) pb.Description = "line one\nline two" r := claudeRenderer{} if _, err := r.Render(pb); err == nil { t.Error("expected an error for a multi-line description") } } func TestComposeAllowedTools_OrderAndSpelling(t *testing.T) { pb := Playbook{ Capabilities: []Capability{ {Kind: "tool", Name: "Read"}, {Kind: "bash", Verb: "git status", Scope: "*"}, {Kind: "bash", Verb: "date"}, // default scope "*" }, } got := composeAllowedTools(pb) want := []string{"Read", "Bash(git status:*)", "Bash(date:*)"} if len(got) != len(want) { t.Fatalf("got %v, want %v", got, want) } for i := range want { if got[i] != want[i] { t.Errorf("entry %d = %q, want %q", i, got[i], want[i]) } } } func TestDeriveTitle(t *testing.T) { cases := map[string]string{ "handover": "Handover", "doc-drift": "Doc Drift", "bug_sweep": "Bug Sweep", } for in, want := range cases { if got := deriveTitle(in); got != want { t.Errorf("deriveTitle(%q) = %q, want %q", in, got, want) } } } func TestClaudeRelPath(t *testing.T) { got := claudeRenderer{}.RelPath(Playbook{Name: "handover"}) want := ".claude/skills/handover/SKILL.md" if got != want { t.Errorf("RelPath = %q, want %q", got, want) } }