Go 157 lines
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)
}
}