f18a633185
The plugin required `tea`, Gitea's own CLI, for everything that is not an issue: releases, pull requests, milestones, branches, actions, webhooks. That put a second binary, a second set of logins nothing here could see, and 400 lines documenting somebody else's flags outside anything this repository can test. One command over the transport that already existed removes all three. Transport: `post` — the hand-rolled request the SDK cannot express, written for the dependency endpoint — is generalized to an exported `Do`, and `post` is three lines on top of it. Same http.Client, so the same RoundTripper files the body under .kettle/payload/, the same `token …` header authenticates it, and a non-2xx is the same *APIError. It does not paginate, does not reformat the answer, and names no domain concept, so the layering test is untouched. The endpoint rule is `tea api`'s, so an endpoint table written for that tool still works — with one restriction it did not have: a full URL must be on this instance. Every request carries the project's token in a header, and a URL on another host would hand the token to whatever was typed. Command: `kettle api <endpoint>` in a new `api` group, so the generator writes plugins/kettle/skills/api/SKILL.md — group, directory and /kettle:api are one word. No --repo and no --login, for the reason no sync command has them: a cross-repository address is an address, and another instance is KETTLE_URL. `-X DELETE` needs `--yes`; a flag typed on purpose is an operator's decision. Scopes: a token minted for issues carries write:issue and answers 403 on the first request outside issues, naming no scope. Gitea cannot be asked what a token may do — its own token listing needs a password — so `auth add --scopes` records it, `auth list` and `config` show it, and a 403 says which category it is likely to be. Documentation only; nothing is checked against it. skills/use — the tea reference, 239 lines of it — becomes skills/api: what to ask for, which endpoints paginate, and how to write a body. Every mention of `tea` as a requirement is gone from the manifests, the READMEs, the runner and the four other skills; what survives is the back-compat with the old plugin, which is a decision and not a debt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
595 lines
20 KiB
Go
595 lines
20 KiB
Go
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 <id> --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)
|
|
}
|
|
}
|
|
|
|
// What a token was minted with is written down because the instance will not
|
|
// say: Gitea's own token listing needs a password, not a token. It is
|
|
// documentation — nothing is checked against it — and the one thing it must not
|
|
// do is read as "none" when nobody wrote it down.
|
|
func TestScopesAreRecordedAndShownButNeverInvented(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", "--scopes", "write:issue, write:repository")
|
|
mustRun(t, dir, "init", "--login", "noodles", "--repo", "owner/name")
|
|
|
|
listed := runWith(t, dir, env, "", "auth", "list")
|
|
if !strings.Contains(listed.stdout, "write:issue, write:repository") {
|
|
t.Errorf("`auth list` does not show what was recorded:\n%s", listed.out())
|
|
}
|
|
if strings.Contains(listed.out(), "s3cr3t-token") {
|
|
t.Errorf("`auth list` printed a token:\n%s", listed.out())
|
|
}
|
|
shown := runWith(t, dir, env, "", "config")
|
|
if !strings.Contains(shown.stdout, "scopes write:issue, write:repository") {
|
|
t.Errorf("`config` does not show the scopes beside the token they belong to:\n%s", shown.stdout)
|
|
}
|
|
|
|
// A login nobody recorded scopes for says so in those words. "—" would read
|
|
// as "no scopes", which is the sentence that gets a working token re-minted.
|
|
runWith(t, dir, env, "other-token\n", "auth", "add", "--name", "bare", "--url", "https://git.example.com")
|
|
bare := runWith(t, dir, env, "", "auth", "list")
|
|
if !strings.Contains(bare.stdout, "(not recorded)") {
|
|
t.Errorf("a login with no scopes written down must say so:\n%s", bare.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)
|
|
}
|
|
}
|
|
|
|
// The version is "dev" until a build stamps it, and the STAMPING is what is
|
|
// tested here rather than the printing.
|
|
//
|
|
// A `-X` whose symbol path is one character wrong is not an error: the linker
|
|
// ignores it and the binary goes on reporting "dev" for the rest of its life,
|
|
// which is discovered by an operator holding a release that will not say what
|
|
// it is. So this builds with the flag the Makefile uses and reads the answer
|
|
// back out of the binary.
|
|
func TestVersionSaysDevUntilABuildStampsIt(t *testing.T) {
|
|
dir := t.TempDir()
|
|
|
|
r := mustRun(t, dir, "version")
|
|
if !strings.Contains(r.stdout, "dev") || !strings.Contains(r.stdout, "built") {
|
|
t.Errorf("a build from source must say what it is:\n%s", r.out())
|
|
}
|
|
// A version needs no project: it is a fact about the binary, and the
|
|
// question is asked most often by somebody whose project is not resolving.
|
|
if short := mustRun(t, dir, "version", "--short"); strings.TrimSpace(short.stdout) != "dev" {
|
|
t.Errorf("--short printed %q, want dev", short.stdout)
|
|
}
|
|
|
|
const stamp = "v9.9.9-from-the-test"
|
|
stamped := filepath.Join(t.TempDir(), "kettle")
|
|
build := exec.Command("go", "build",
|
|
"-ldflags", "-X git.noodles.cam/claude-skills/marketplace/cli/internal/cmd.Version="+stamp,
|
|
"-o", stamped, "../../cmd/kettle")
|
|
if out, err := build.CombinedOutput(); err != nil {
|
|
t.Fatalf("building a stamped binary: %v\n%s", err, out)
|
|
}
|
|
out, err := exec.Command(stamped, "version", "--short").Output()
|
|
if err != nil {
|
|
t.Fatalf("running the stamped binary: %v", err)
|
|
}
|
|
if got := strings.TrimSpace(string(out)); got != stamp {
|
|
t.Errorf("the stamped binary reports %q, want %q — the -X symbol path is wrong", got, stamp)
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|