package cmd_test // The CLI is tested the way the Python suite it replaces was: the binary is // built once and run as a subprocess against a throwaway project somewhere // else entirely. // // That separation IS the contract. A tool is installed in one place and used on // projects in another, and the bug this discipline exists to catch — a store // resolved from the executable's own directory rather than from the tree it was // pointed at — is invisible to any test that runs the code in the directory it // lives in. // // Every fixture also strips CLAUDE_PROJECT_DIR unless the test is about it: it // is the first anchor of the walk, so the harness's own value would point every // fixture at this repository. import ( "os" "os/exec" "path/filepath" "strings" "testing" ) var kettle string func TestMain(m *testing.M) { dir, err := os.MkdirTemp("", "kettle-bin") if err != nil { panic(err) } defer os.RemoveAll(dir) kettle = filepath.Join(dir, "kettle") build := exec.Command("go", "build", "-o", kettle, "../../cmd/kettle") if out, err := build.CombinedOutput(); err != nil { panic("building kettle: " + err.Error() + "\n" + string(out)) } os.Exit(m.Run()) } type result struct { stdout, stderr string code int } func (r result) out() string { return r.stdout + r.stderr } // run invokes the binary in dir with a clean environment. func run(t *testing.T, dir string, args ...string) result { t.Helper() return runWith(t, dir, nil, "", args...) } // runWith is run plus extra environment and standard input. func runWith(t *testing.T, dir string, env []string, stdin string, args ...string) result { t.Helper() cmd := exec.Command(kettle, args...) cmd.Dir = dir cmd.Env = append(append(os.Environ(), "CLAUDE_PROJECT_DIR="), env...) if stdin != "" { cmd.Stdin = strings.NewReader(stdin) } var stdout, stderr strings.Builder cmd.Stdout, cmd.Stderr = &stdout, &stderr err := cmd.Run() code := 0 var ee *exec.ExitError if err != nil { if !asExitError(err, &ee) { t.Fatalf("running kettle %v: %v", args, err) } code = ee.ExitCode() } return result{stdout.String(), stderr.String(), code} } func mustRun(t *testing.T, dir string, args ...string) result { t.Helper() r := run(t, dir, args...) if r.code != 0 { t.Fatalf("kettle %v exited %d:\n%s", args, r.code, r.out()) } return r } // newProject makes an initialized project in a temp directory and returns it. func newProject(t *testing.T) string { t.Helper() dir, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } mustRun(t, dir, "init") return dir } func TestInitIsIdempotentAndGitignoresTheStore(t *testing.T) { dir := newProject(t) for _, d := range []string{".kettle/issues", ".kettle/payload"} { if fi, err := os.Stat(filepath.Join(dir, d)); err != nil || !fi.IsDir() { t.Errorf("%s was not created", d) } } // An `origin: local` issue is the only copy of that work, and what goes in // a shared history is the operator's call, not this command's. ignore, err := os.ReadFile(filepath.Join(dir, ".gitignore")) if err != nil || !strings.Contains(string(ignore), ".kettle/") { t.Errorf(".kettle/ was not gitignored: %q", ignore) } again := mustRun(t, dir, "init") if !strings.Contains(again.stdout, "already initialized") { t.Errorf("a second init should be a no-op, got:\n%s", again.stdout) } } func TestNoMarkerIsReportedNotGuessed(t *testing.T) { dir := t.TempDir() r := run(t, dir, "check") if r.code == 0 { t.Fatal("a directory that is not a project must not read as an empty store") } // The operator is owed the directories the search began from — that is how // they see whether it began where they meant it to. if !strings.Contains(r.stderr, "no .kettle/ found") || !strings.Contains(r.stderr, dir) { t.Errorf("the failure must name what it searched:\n%s", r.stderr) } } func TestTheGoldenPath(t *testing.T) { dir := newProject(t) mustRun(t, dir, "new", "--type", "task", "--title", "Wire sqlc into the appclick layer") const id = "wire-sqlc-into-the-appclick-layer" if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "ok "+id) { t.Errorf("a fresh issue from its own template must validate:\n%s", r.out()) } // Progress is counted off the body every time, never stored. mustRun(t, dir, "ac", id, "--check", "1") index, err := os.ReadFile(filepath.Join(dir, ".kettle", "issues", "INDEX.md")) if err != nil { t.Fatal(err) } if !strings.Contains(string(index), "| 1/2 |") { t.Errorf("the index did not pick up the ticked box:\n%s", index) } if r := mustRun(t, dir, "tree"); !strings.Contains(r.stdout, id) { t.Errorf("tree did not draw the issue:\n%s", r.stdout) } } func TestTickingABoxChangesOneByte(t *testing.T) { dir := newProject(t) mustRun(t, dir, "new", "--type", "task", "--title", "Tick one box") path := filepath.Join(dir, ".kettle", "issues", "tick-one-box.md") before, err := os.ReadFile(path) if err != nil { t.Fatal(err) } mustRun(t, dir, "ac", "tick-one-box", "--check", "1") after, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if len(before) != len(after) { t.Fatalf("length changed: %d -> %d", len(before), len(after)) } diff := 0 for i := range before { if before[i] != after[i] { diff++ } } if diff != 1 { t.Errorf("%d bytes changed, want 1 — a tick must not re-render the file", diff) } // And back again, byte for byte: the metadata block is rewritten by nobody. mustRun(t, dir, "ac", "tick-one-box", "--uncheck", "1") back, err := os.ReadFile(path) if err != nil { t.Fatal(err) } if string(back) != string(before) { t.Error("unticking did not restore the file byte for byte") } } func TestFlagsWorkAfterPositionalArguments(t *testing.T) { dir := newProject(t) mustRun(t, dir, "new", "--type", "task", "--title", "Order of arguments") // `kettle ac --check 1` is how everybody types it. A flag silently // read as a positional would tick nothing and report success. r := mustRun(t, dir, "ac", "order-of-arguments", "--check", "1") if !strings.Contains(r.stdout, "checked") { t.Errorf("the flag after the id was ignored:\n%s", r.out()) } } func TestTheStoreResolvesFromAnywhereInsideTheProject(t *testing.T) { dir := newProject(t) mustRun(t, dir, "new", "--type", "task", "--title", "Seen from below") deep := filepath.Join(dir, "internal", "adapters") if err := os.MkdirAll(deep, 0o755); err != nil { t.Fatal(err) } r := mustRun(t, deep, "check") if !strings.Contains(r.stdout, "seen-from-below") { t.Errorf("a subdirectory saw a different store:\n%s", r.out()) } } func TestADifferentProjectAnswersWithItsOwnStore(t *testing.T) { a, b := newProject(t), newProject(t) mustRun(t, a, "new", "--type", "task", "--title", "Belongs to A") mustRun(t, b, "new", "--type", "task", "--title", "Belongs to B") r := mustRun(t, b, "check") if strings.Contains(r.stdout, "belongs-to-a") { t.Errorf("project B saw project A's issues:\n%s", r.out()) } } func TestALocalIssueIsNeverEvictedEvenWhenNamed(t *testing.T) { dir := newProject(t) mustRun(t, dir, "new", "--type", "task", "--title", "Only copy there is") path := filepath.Join(dir, ".kettle", "issues", "only-copy-there-is.md") closeIssue(t, path) r := mustRun(t, dir, "evict", "only-copy-there-is") if _, err := os.Stat(path); err != nil { t.Fatal("a closed origin: local issue was deleted — that file IS the work") } if !strings.Contains(r.stdout, "kept") { t.Errorf("keeping it must be said out loud:\n%s", r.out()) } } func TestAClosedTrackedIssueIsEvictedWithItsSidecars(t *testing.T) { dir := newProject(t) mustRun(t, dir, "new", "--type", "task", "--title", "Done and elsewhere") store := filepath.Join(dir, ".kettle", "issues") path := filepath.Join(store, "done-and-elsewhere.md") closeIssue(t, path) setField(t, path, "origin", "gitea") sidecar := filepath.Join(store, "done-and-elsewhere.comments.md") if err := os.WriteFile(sidecar, []byte("# thread\n"), 0o644); err != nil { t.Fatal(err) } // A dry run touches nothing, and says so. dry := mustRun(t, dir, "evict", "--dry-run") if !strings.Contains(dry.stdout, "would evict") { t.Errorf("dry run said nothing:\n%s", dry.out()) } if _, err := os.Stat(path); err != nil { t.Fatal("a dry run deleted the issue") } mustRun(t, dir, "evict") if _, err := os.Stat(path); err == nil { t.Error("the issue survived eviction") } // The domain does not need to know what a comment thread is to know a file // named after this issue goes when it goes. if _, err := os.Stat(sidecar); err == nil { t.Error("the sidecar was left behind") } } func TestCheckExitsNonZeroOnAMalformedIssue(t *testing.T) { dir := newProject(t) mustRun(t, dir, "new", "--type", "task", "--title", "Loses its type") path := filepath.Join(dir, ".kettle", "issues", "loses-its-type.md") setField(t, path, "labels", "[]") r := run(t, dir, "check") if r.code != 1 { t.Errorf("exit = %d, want 1 — this is what makes check usable in a hook", r.code) } if !strings.Contains(r.stdout, "need exactly one type/* label") { t.Errorf("the finding was not reported:\n%s", r.out()) } } func TestNewRefusesToOverwriteAnExistingIssue(t *testing.T) { dir := newProject(t) mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice") // Without an explicit id the slug is allocated around the collision… mustRun(t, dir, "new", "--type", "task", "--title", "Same title twice") if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "same-title-twice-2.md")); err != nil { t.Error("the second issue did not get its own slug") } // …but an id typed by hand is taken literally, and taken means taken. r := run(t, dir, "new", "--type", "task", "--title", "Third", "--id", "same-title-twice") if r.code == 0 || !strings.Contains(r.stderr, "already exists") { t.Errorf("an explicit id must not overwrite:\n%s", r.out()) } } // An older layout is migrated in, and it is a MOVE: a store left behind at the // old path is a store somebody will edit by accident months later. func TestInitMigratesAnOlderStore(t *testing.T) { dir, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } old := filepath.Join(dir, ".tea", "issues") if err := os.MkdirAll(old, 0o755); err != nil { t.Fatal(err) } const body = "---\nid: from-the-old-store\nstate: open\nlabels: [type/task]\norigin: local\n---\n# From the old store\n\n## Summary\nx\n\n## Spec\nnone\n\n## Acceptance criteria\n- [ ] x\n" if err := os.WriteFile(filepath.Join(old, "from-the-old-store.md"), []byte(body), 0o644); err != nil { t.Fatal(err) } r := mustRun(t, dir, "init") if !strings.Contains(r.stdout, "moved 1 file(s)") { t.Errorf("the migration said nothing:\n%s", r.stdout) } if _, err := os.Stat(filepath.Join(dir, ".kettle", "issues", "from-the-old-store.md")); err != nil { t.Fatal("the issue did not arrive in the new store") } if _, err := os.Stat(old); err == nil { t.Error("the old store is still there — two stores is what the marker exists to prevent") } if r := mustRun(t, dir, "check"); !strings.Contains(r.stdout, "from-the-old-store") { t.Errorf("the migrated issue is not readable:\n%s", r.out()) } } // A migration never picks a winner. Two files of the same name are two versions // of one issue, and choosing quietly is how the wrong one survives. func TestInitRefusesToResolveAMigrationClash(t *testing.T) { dir := newProject(t) old := filepath.Join(dir, ".tea", "issues") if err := os.MkdirAll(old, 0o755); err != nil { t.Fatal(err) } mustRun(t, dir, "new", "--type", "task", "--title", "Both sides have this") if err := os.WriteFile(filepath.Join(old, "both-sides-have-this.md"), []byte("older\n"), 0o644); err != nil { t.Fatal(err) } r := run(t, dir, "init") if r.code == 0 { t.Fatal("a clash must stop the run") } if !strings.Contains(r.stderr, "both hold") || !strings.Contains(r.stderr, "nothing was changed") { t.Errorf("the clash was not explained:\n%s", r.stderr) } if _, err := os.Stat(filepath.Join(old, "both-sides-have-this.md")); err != nil { t.Error("the older file was moved anyway") } } func TestInitWritesTheConfigAndKeepsWhatItWasNotGiven(t *testing.T) { dir, err := filepath.EvalSymlinks(t.TempDir()) if err != nil { t.Fatal(err) } mustRun(t, dir, "init", "--login", "noodles", "--repo", "claude-skills/marketplace") cfg := filepath.Join(dir, ".kettle", "config.yaml") raw, err := os.ReadFile(cfg) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), "login: noodles") || !strings.Contains(string(raw), "repo: claude-skills/marketplace") { t.Fatalf("config did not record what it was given:\n%s", raw) } // Re-running init to change one setting must not drop the other. mustRun(t, dir, "init", "--repo", "claude-skills/other") raw, err = os.ReadFile(cfg) if err != nil { t.Fatal(err) } if !strings.Contains(string(raw), "login: noodles") { t.Errorf("the pinned login was dropped by an unrelated init:\n%s", raw) } if !strings.Contains(string(raw), "repo: claude-skills/other") { t.Errorf("the repository was not updated:\n%s", raw) } } func TestInitRefusesAMalformedRepo(t *testing.T) { dir := t.TempDir() r := run(t, dir, "init", "--repo", "marketplace") if r.code == 0 || !strings.Contains(r.stderr, "owner/name") { t.Errorf("a repo without an owner must be rejected before anything is written:\n%s", r.out()) } if _, err := os.Stat(filepath.Join(dir, ".kettle")); err == nil { t.Error("the marker was created despite the bad argument") } } // The project pins a login by NAME. The credential lives in one file per // machine, outside every working tree — a token in a repository ends up in a // commit, and a secret that has been pushed has to be rotated. func TestTokensNeverLandInTheProject(t *testing.T) { dir := newProject(t) home := t.TempDir() env := []string{"KETTLE_CONFIG_HOME=" + home} runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles", "--url", "https://git.example.com/") mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name") logins, err := os.ReadFile(filepath.Join(home, "logins.yaml")) if err != nil { t.Fatal("the token file was not written where it was told to go") } if !strings.Contains(string(logins), "s3cr3t-token") { t.Errorf("the token was not stored:\n%s", logins) } if fi, err := os.Stat(filepath.Join(home, "logins.yaml")); err != nil || fi.Mode().Perm() != 0o600 { t.Errorf("the token file must be 0600, got %v", fi.Mode().Perm()) } cfg, err := os.ReadFile(filepath.Join(dir, ".kettle", "config.yaml")) if err != nil { t.Fatal(err) } if strings.Contains(string(cfg), "s3cr3t-token") { t.Fatal("the token was written into the project — that file ends up in a commit") } // And nothing prints it back, either. shown := runWith(t, dir, env, "", "config") if strings.Contains(shown.out(), "s3cr3t-token") { t.Errorf("`kettle config` printed the token:\n%s", shown.out()) } if !strings.Contains(shown.stdout, "https://git.example.com") { t.Errorf("the resolved URL was not shown:\n%s", shown.out()) } if !strings.Contains(shown.stdout, "token (set)") { t.Errorf("whether a token was found must still be visible:\n%s", shown.stdout) } } func TestAuthListNeverPrintsATokenAndRemoveForgetsIt(t *testing.T) { dir := newProject(t) home := t.TempDir() env := []string{"KETTLE_CONFIG_HOME=" + home} runWith(t, dir, env, "s3cr3t-token\n", "auth", "add", "--name", "noodles", "--url", "https://git.example.com") listed := runWith(t, dir, env, "", "auth", "list") if strings.Contains(listed.out(), "s3cr3t-token") { t.Errorf("`auth list` printed a token:\n%s", listed.out()) } if !strings.Contains(listed.stdout, "noodles") { t.Errorf("`auth list` did not list the login:\n%s", listed.out()) } runWith(t, dir, env, "", "auth", "remove", "noodles") after := runWith(t, dir, env, "", "auth", "list") if strings.Contains(after.stdout, "noodles") { t.Errorf("the login survived removal:\n%s", after.stdout) } } // A pinned login that is not on this machine is a fixable mistake, and the // message has to say which file was read and what it holds. func TestAMissingLoginIsExplained(t *testing.T) { dir := newProject(t) home := t.TempDir() env := []string{"KETTLE_CONFIG_HOME=" + home} mustRun(t, dir, "init", "--login", "absent", "--repo", "owner/name") r := runWith(t, dir, env, "", "config") if r.code == 0 { t.Fatal("a login that does not exist must not resolve") } if !strings.Contains(r.stderr, `no login "absent"`) || !strings.Contains(r.stderr, "kettle auth add") { t.Errorf("the failure must name the file and the fix:\n%s", r.stderr) } } func closeIssue(t *testing.T, path string) { t.Helper() setField(t, path, "state", "closed") } func setField(t *testing.T, path, key, value string) { t.Helper() raw, err := os.ReadFile(path) if err != nil { t.Fatal(err) } lines := strings.Split(string(raw), "\n") for i, line := range lines { if strings.HasPrefix(line, key+": ") { lines[i] = key + ": " + value } } if err := os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0o644); err != nil { t.Fatal(err) } } func asExitError(err error, target **exec.ExitError) bool { ee, ok := err.(*exec.ExitError) if ok { *target = ee } return ok }