01fb5a2703
There is no CI: the instance has no act_runner and none is planned, so releases are cut by hand. That makes `make check` the only thing standing between a mistake and the tracker, and it is one command: gofmt, vet, the suite with the cache defeated, `go mod verify`, a vendored build, and `kettle gen skills --check`. The last one is the invariant worth having — the plugin's SKILL.md command reference is generated from the binary's registry, so a flag that changed cannot ship with documentation that recommends the old one. `cli/cmd/release` publishes to Gitea using the same SDK the binary already vendors, which is a pleasing thing to be able to say: nothing third-party handles the artifacts. It is a second binary rather than a `kettle` subcommand on purpose — `kettle`'s command tree is what generates the plugin's skills, so a verb there ships to every operator, and publishing a release is build infrastructure. It is idempotent end to end: an existing release for the tag is reused, an asset of the same name is replaced rather than doubled, and a retried run converges instead of duplicating. `make release` refuses three things, each with its own message: a dirty working tree, a TAG that is not what `git describe` reports, and a tag the remote does not have. A release built from uncommitted code is unreproducible and nobody finds out until they need to reproduce it. `kettle version` reports the stamp, the toolchain and the VCS revision. The default is `dev`, and a hand build says so and means it — a binary out of somebody's working tree is not a release and must not claim to be one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
561 lines
19 KiB
Go
561 lines
19 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)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|