package sunrise import ( "bytes" "errors" "os" "os/exec" "path/filepath" "strings" "testing" ) // scriptHarness runs new.sh with bash on a PATH that holds only a symlink to // the real git plus stub go and docker commands. The go stub answers // `go env GOVERSION|GOBIN|GOPATH` from the harness and, for // `go -C install ./cmd/summer`, writes a stub summer into the Go bin // directory that records its working directory, arguments and stdin. type scriptHarness struct { t *testing.T bash string script string root string // per-case temp root work string // the directory the script runs in tools string // the PATH directory logs string // stub call records gobin string gopath string remote *fakeRemote goVersion string // go env GOVERSION useGOBIN bool // go env GOBIN prints gobin; otherwise empty, GOPATH/bin is used gobinOnPath bool unsetShell bool // run the script with SHELL unset instead of the empty default env []string // extra environment } type scriptResult struct { code int stdout, stderr string } // stdinProbe is piped into every run: the script must never let a child // read it. const stdinProbe = "SHOULD-NOT-BE-READ\n" // fwModule is the framework module path the script requires. const fwModule = "git.golem15.com/golem15/summercms" // gitTestEnv isolates git from the user's configuration and supplies an // identity for the commits the harness itself makes. func gitTestEnv(home string) []string { return []string{ "HOME=" + home, "XDG_CONFIG_HOME=" + filepath.Join(home, ".config"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=" + os.DevNull, // Detached auto maintenance would write into temp repos during cleanup. "GIT_CONFIG_COUNT=2", "GIT_CONFIG_KEY_0=maintenance.auto", "GIT_CONFIG_VALUE_0=false", "GIT_CONFIG_KEY_1=gc.auto", "GIT_CONFIG_VALUE_1=0", "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.com", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.com", "LC_ALL=C", } } // fakeRemote is a bare framework repository fed from a work clone. type fakeRemote struct { t *testing.T env []string bare string src string n int tags map[string]string // tag -> commit } func (r *fakeRemote) git(dir string, args ...string) string { r.t.Helper() cmd := exec.Command("git", args...) cmd.Dir = dir cmd.Env = append(os.Environ()[:0:0], append([]string{"PATH=" + os.Getenv("PATH")}, r.env...)...) out, err := cmd.CombinedOutput() if err != nil { r.t.Fatalf("git %v in %s: %v\n%s", args, dir, err, out) } return strings.TrimSpace(string(out)) } // commit adds a commit on main and returns its hash. func (r *fakeRemote) commit() string { r.t.Helper() r.n++ if err := os.WriteFile(filepath.Join(r.src, "VERSION"), []byte(strings.Repeat("x", r.n)+"\n"), 0o644); err != nil { r.t.Fatal(err) } r.git(r.src, "add", "-A") r.git(r.src, "commit", "-q", "-m", "commit") r.git(r.src, "push", "-q", "origin", "main") return r.git(r.src, "rev-parse", "HEAD") } // tag commits and tags the new commit, then pushes the tag. func (r *fakeRemote) tag(tag string) string { r.t.Helper() c := r.commit() r.git(r.src, "tag", tag) r.git(r.src, "push", "-q", "origin", "refs/tags/"+tag) r.tags[tag] = c return c } // moveTag recreates tag on a new commit upstream. func (r *fakeRemote) moveTag(tag string) string { r.t.Helper() c := r.commit() r.git(r.src, "tag", "-f", tag) r.git(r.src, "push", "-q", "--force", "origin", "refs/tags/"+tag) r.tags[tag] = c return c } func newFakeRemote(t *testing.T, root string, env []string) *fakeRemote { t.Helper() r := &fakeRemote{t: t, env: env, bare: filepath.Join(root, "remote.git"), src: filepath.Join(root, "remote-src"), tags: map[string]string{}} r.git(root, "init", "-q", "--bare", "--initial-branch=main", r.bare) r.git(root, "init", "-q", "--initial-branch=main", r.src) if err := os.WriteFile(filepath.Join(r.src, "go.mod"), []byte("module "+fwModule+"\n\ngo 1.27.0\n"), 0o644); err != nil { t.Fatal(err) } r.git(r.src, "remote", "add", "origin", r.bare) r.git(r.src, "add", "-A") r.git(r.src, "commit", "-q", "-m", "init") r.git(r.src, "push", "-q", "origin", "main") r.tag("v0.1.0") r.tag("v0.2.0") r.tag("v0.10.0") // A pre-release newer than every release: never selected. r.tag("v1.0.0-rc1") return r } func newScriptHarness(t *testing.T) *scriptHarness { t.Helper() bash := lookBash(t) gitBin, err := exec.LookPath("git") if err != nil { t.Skip("git not installed") } gitBin, err = filepath.Abs(gitBin) if err != nil { t.Fatal(err) } chmod, err := exec.LookPath("chmod") if err != nil { t.Skip("chmod not found") } root := t.TempDir() h := &scriptHarness{ t: t, bash: bash, root: root, work: filepath.Join(root, "work"), tools: filepath.Join(root, "tools"), logs: filepath.Join(root, "logs"), gobin: filepath.Join(root, "gobin"), gopath: filepath.Join(root, "gopath"), goVersion: "go1.27.0", useGOBIN: true, } for _, d := range []string{h.work, h.tools, h.logs, h.gobin, filepath.Join(h.gopath, "bin"), filepath.Join(root, "home")} { if err := os.MkdirAll(d, 0o755); err != nil { t.Fatal(err) } } h.script = filepath.Join(root, "new.sh") if err := os.WriteFile(h.script, Script(), 0o644); err != nil { t.Fatal(err) } if err := os.Symlink(gitBin, filepath.Join(h.tools, "git")); err != nil { t.Fatal(err) } summerStub := "#!" + bash + ` { printf 'cwd=%s\n' "$(pwd)"; printf 'arg=%s\n' "$@"; } >"$STUB_LOG/summer.call" data="" while IFS= read -r line || [[ -n $line ]]; do data+="$line"; done printf '%s' "$data" >"$STUB_LOG/summer.stdin" ` goStub := "#!" + bash + ` printf '%s\n' "$*" >>"$STUB_LOG/go.calls" case "$1 ${2:-}" in "env GOVERSION") printf '%s\n' "$STUB_GOVERSION"; exit 0 ;; "env GOBIN") printf '%s\n' "$STUB_GOBIN"; exit 0 ;; "env GOPATH") printf '%s\n' "$STUB_GOPATH"; exit 0 ;; esac if [[ $1 == -C && $3 == install && $4 == ./cmd/summer ]]; then printf 'GOWORK=%s dir=%s\n' "${GOWORK-unset}" "$2" >>"$STUB_LOG/go.install" bin="$STUB_GOBIN" [[ -n $bin ]] || bin="${STUB_GOPATH%%:*}/bin" printf '%s' "$SUMMER_STUB" >"$bin/summer" '` + chmod + `' 0755 "$bin/summer" exit 0 fi printf 'unexpected go %s\n' "$*" >&2 exit 3 ` dockerStub := "#!" + bash + "\nprintf '%s\\n' \"$*\" >>\"$STUB_LOG/docker.calls\"\nexit 0\n" write := func(name, body string) { if err := os.WriteFile(filepath.Join(h.tools, name), []byte(body), 0o755); err != nil { t.Fatal(err) } } write("go", goStub) write("docker", dockerStub) h.env = append(h.env, "SUMMER_STUB="+summerStub) h.remote = newFakeRemote(t, root, append(gitTestEnv(filepath.Join(root, "home")), "GIT_CEILING_DIRECTORIES="+root)) return h } // without removes a tool from the stub PATH. func (h *scriptHarness) without(tool string) { h.t.Helper() if err := os.Remove(filepath.Join(h.tools, tool)); err != nil { h.t.Fatal(err) } } // run executes the script with args in h.work. func (h *scriptHarness) run(args ...string) scriptResult { h.t.Helper() path := h.tools if h.gobinOnPath { path += ":" + h.gobin } gobin := "" if h.useGOBIN { gobin = h.gobin } env := append(gitTestEnv(filepath.Join(h.root, "home")), "PATH="+path, "PWD="+h.work, "GIT_CEILING_DIRECTORIES="+h.root, "SUMMERCMS_REPO="+h.remote.bare, "STUB_LOG="+h.logs, "STUB_GOVERSION="+h.goVersion, "STUB_GOBIN="+gobin, "STUB_GOPATH="+h.gopath, // bash fills an unset SHELL from the passwd entry, so this default // keeps cases independent of the developer's login shell; a case // sets its own SHELL through h.env, which comes later and wins. "SHELL=", ) // The script must not depend on the harness identity: drop it. filtered := env[:0] for _, kv := range env { if strings.HasPrefix(kv, "GIT_AUTHOR_") || strings.HasPrefix(kv, "GIT_COMMITTER_") { continue } filtered = append(filtered, kv) } env = append(filtered, h.env...) cmd := exec.Command(h.bash, append([]string{h.script}, args...)...) if h.unsetShell { // Unset SHELL inside bash, after startup filled it, then source the // script ($0) with the case arguments so it runs with SHELL unset. cmd = exec.Command(h.bash, append([]string{"-c", `unset SHELL; . "$0" "$@"`, h.script}, args...)...) } cmd.Dir = h.work cmd.Env = env cmd.Stdin = strings.NewReader(stdinProbe) var stdout, stderr bytes.Buffer cmd.Stdout = &stdout cmd.Stderr = &stderr err := cmd.Run() res := scriptResult{stdout: stdout.String(), stderr: stderr.String()} var exitErr *exec.ExitError switch { case err == nil: case errors.As(err, &exitErr): res.code = exitErr.ExitCode() default: h.t.Fatalf("run script: %v", err) } return res } // mustRun runs the script and requires success. func (h *scriptHarness) mustRun(args ...string) scriptResult { h.t.Helper() res := h.run(args...) if res.code != 0 { h.t.Fatalf("script exit %d\nstdout:\n%s\nstderr:\n%s", res.code, res.stdout, res.stderr) } return res } func (h *scriptHarness) checkout() string { return filepath.Join(h.work, "summercms") } func (h *scriptHarness) head() string { h.t.Helper() return h.remote.git(h.checkout(), "rev-parse", "HEAD") } func (h *scriptHarness) log(name string) string { data, err := os.ReadFile(filepath.Join(h.logs, name)) if err != nil { return "" } return string(data) } func (h *scriptHarness) exists(rel string) bool { _, err := os.Lstat(filepath.Join(h.work, rel)) return err == nil } func expectCode(t *testing.T, res scriptResult, code int) { t.Helper() if res.code != code { t.Fatalf("exit %d, want %d\nstdout:\n%s\nstderr:\n%s", res.code, code, res.stdout, res.stderr) } } func expectContains(t *testing.T, what, got string, wants ...string) { t.Helper() for _, want := range wants { if !strings.Contains(got, want) { t.Fatalf("%s lacks %q:\n%s", what, want, got) } } } func expectLacks(t *testing.T, what, got string, unwanted ...string) { t.Helper() for _, u := range unwanted { if strings.Contains(got, u) { t.Fatalf("%s contains %q:\n%s", what, u, got) } } } // hintDir returns dir escaped the way new.sh escapes it inside the double // quotes of the printed PATH hint: backslash, double quote and dollar for // every shell, plus backtick and a single-quoted ! for POSIX shells. func hintDir(dir string, fish bool) string { if fish { return strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`).Replace(dir) } return strings.NewReplacer(`\`, `\\`, `"`, `\"`, `$`, `\$`, "`", "\\`", `!`, `"'!'"`).Replace(dir) } // hint is the PATH hint new.sh prints at the end of stdout. func hint(gobin, note, line string) string { return "\n==> summer is installed in " + gobin + ", which is not on your PATH. Run this (" + note + "):\n" + line + "\n" } // exportHint and fishHint are the two hint forms for gobin. func exportHint(gobin, note string) string { return hint(gobin, note, ` export PATH="`+hintDir(gobin, false)+`:$PATH"`) } func fishHint(gobin string) string { return hint(gobin, "fish_add_path also keeps it for new fish sessions", ` fish_add_path "`+hintDir(gobin, true)+`"`) } // expectHintSuffix asserts stdout ends with the hint. func expectHintSuffix(t *testing.T, stdout, want string) { t.Helper() if !strings.HasSuffix(stdout, want) { t.Fatalf("stdout does not end with the hint\ngot:\n%q\nwant suffix:\n%q", stdout, want) } } // metaGobin creates and selects a Go bin directory whose name holds a // space, backslash, double quote, dollar, backtick and !. func metaGobin(t *testing.T, h *scriptHarness) { t.Helper() h.gobin = filepath.Join(h.root, "go bin\\x\"y$HOME`id`!z") if err := os.MkdirAll(h.gobin, 0o755); err != nil { t.Fatal(err) } } // expectNothingStarted asserts no clone and no go or summer call happened. func expectNothingStarted(t *testing.T, h *scriptHarness) { t.Helper() if h.exists("summercms") { t.Fatal("the framework was cloned") } if c := h.log("go.calls"); c != "" { t.Fatalf("go was called:\n%s", c) } if c := h.log("summer.call"); c != "" { t.Fatalf("summer was called:\n%s", c) } } // expectRefusedUnchanged asserts a refusal with exit 1 that left HEAD at // before and never reached go install or summer. func expectRefusedUnchanged(t *testing.T, h *scriptHarness, res scriptResult, before string, msg string) { t.Helper() expectCode(t, res, 1) expectContains(t, "stderr", res.stderr, msg) if got := h.head(); got != before { t.Fatalf("HEAD moved from %s to %s", before, got) } if c := h.log("summer.call"); c != "" { t.Fatalf("summer was called after a refusal:\n%s", c) } } // resetLogs clears stub records between runs of one case. func (h *scriptHarness) resetLogs() { h.t.Helper() for _, n := range []string{"go.calls", "go.install", "summer.call", "summer.stdin", "docker.calls"} { _ = os.Remove(filepath.Join(h.logs, n)) } } func TestScriptBehavior(t *testing.T) { cases := []struct { name string run func(t *testing.T, h *scriptHarness) }{ {"help prints usage and exits 0", func(t *testing.T, h *scriptHarness) { res := h.run("--help") expectCode(t, res, 0) expectContains(t, "stdout", res.stdout, "Usage: curl -fsSL https://summercms.io/new | bash -s -- ", "SUMMERCMS_REF") expectNothingStarted(t, h) }}, {"no argument is a usage error", func(t *testing.T, h *scriptHarness) { res := h.run() expectCode(t, res, 2) expectContains(t, "stderr", res.stderr, "Usage:") expectNothingStarted(t, h) }}, {"two arguments are a usage error", func(t *testing.T, h *scriptHarness) { res := h.run("demo", "extra") expectCode(t, res, 2) expectNothingStarted(t, h) }}, {"upper-case name is rejected", func(t *testing.T, h *scriptHarness) { res := h.run("Demo") expectCode(t, res, 2) expectContains(t, "stderr", res.stderr, "invalid application name") expectNothingStarted(t, h) }}, {"name with shell metacharacters is rejected", func(t *testing.T, h *scriptHarness) { res := h.run("demo;touch pwned") expectCode(t, res, 2) if h.exists("pwned") { t.Fatal("the name was executed") } expectNothingStarted(t, h) }}, {"existing app stops before any clone", func(t *testing.T, h *scriptHarness) { if err := os.Mkdir(filepath.Join(h.work, "sm-demo-app"), 0o755); err != nil { t.Fatal(err) } res := h.run("demo") expectCode(t, res, 1) expectContains(t, "stderr", res.stderr, "sm-demo-app already exists") expectNothingStarted(t, h) }}, {"Go missing names go.dev/dl", func(t *testing.T, h *scriptHarness) { h.without("go") res := h.run("demo") expectCode(t, res, 1) expectContains(t, "stderr", res.stderr, "Go is not installed", "https://go.dev/dl/") expectNothingStarted(t, h) }}, {"Go older than 1.27 is refused", func(t *testing.T, h *scriptHarness) { h.goVersion = "go1.26.3" res := h.run("demo") expectCode(t, res, 1) expectContains(t, "stderr", res.stderr, "found go1.26.3", "https://go.dev/dl/") if h.exists("summercms") { t.Fatal("cloned despite an old Go") } }}, {"devel Go version only warns", func(t *testing.T, h *scriptHarness) { h.goVersion = "devel go1.28-0123456789ab" res := h.mustRun("demo") expectContains(t, "stderr", res.stderr, "warning: could not read the Go version") if h.log("summer.call") == "" { t.Fatal("summer was not called") } }}, {"git missing is an error", func(t *testing.T, h *scriptHarness) { h.without("git") res := h.run("demo") expectCode(t, res, 1) expectContains(t, "stderr", res.stderr, "git is not installed") expectNothingStarted(t, h) }}, {"Docker missing only warns", func(t *testing.T, h *scriptHarness) { h.without("docker") res := h.mustRun("demo") expectContains(t, "stderr", res.stderr, "warning: Docker is not installed") if h.log("summer.call") == "" { t.Fatal("summer was not called") } }}, {"fresh clone at the newest release", func(t *testing.T, h *scriptHarness) { res := h.mustRun("demo") if got, want := h.head(), h.remote.tags["v0.10.0"]; got != want { t.Fatalf("HEAD = %s, want v0.10.0 %s (numeric order, pre-release ignored)", got, want) } cmd := exec.Command("git", "-C", h.checkout(), "symbolic-ref", "-q", "HEAD") if err := cmd.Run(); err == nil { t.Fatal("checkout is on a branch, want detached") } fw := filepath.Join(h.work, "summercms") call := h.log("summer.call") want := "cwd=" + h.work + "\narg=new\narg=demo\narg=--framework\narg=" + fw + "\n" if call != want { t.Fatalf("summer call:\n%s\nwant:\n%s", call, want) } if got := h.log("summer.stdin"); got != "" { t.Fatalf("summer read stdin %q", got) } if got, want := h.log("go.install"), "GOWORK=off dir="+fw+"\n"; got != want { t.Fatalf("go install = %q, want %q", got, want) } expectContains(t, "stdout", res.stdout, " cd "+filepath.Join(h.work, "sm-demo-app")+"\n") expectContains(t, "stderr", res.stderr, "framework target: v0.10.0") }}, {"SUMMERCMS_REF selects a tag", func(t *testing.T, h *scriptHarness) { h.env = append(h.env, "SUMMERCMS_REF=v0.2.0") res := h.mustRun("demo") if got, want := h.head(), h.remote.tags["v0.2.0"]; got != want { t.Fatalf("HEAD = %s, want v0.2.0 %s", got, want) } expectContains(t, "stderr", res.stderr, "framework target: v0.2.0") }}, {"SUMMERCMS_REF starting with a dash is refused", func(t *testing.T, h *scriptHarness) { h.env = append(h.env, "SUMMERCMS_REF=--upload-pack=x") res := h.run("demo") expectCode(t, res, 1) expectContains(t, "stderr", res.stderr, "SUMMERCMS_REF must be a tag or commit") if h.log("summer.call") != "" { t.Fatal("summer was called") } }}, {"re-run moves forward to a new release", func(t *testing.T, h *scriptHarness) { h.mustRun("demo") next := h.remote.tag("v0.11.0") h.resetLogs() res := h.mustRun("demo") if got := h.head(); got != next { t.Fatalf("HEAD = %s, want v0.11.0 %s", got, next) } expectContains(t, "stderr", res.stderr, "updating the framework checkout", "framework target: v0.11.0") }}, {"re-run at the newest release changes nothing", func(t *testing.T, h *scriptHarness) { h.mustRun("demo") before := h.head() h.resetLogs() res := h.mustRun("demo") if got := h.head(); got != before { t.Fatalf("HEAD moved from %s to %s", before, got) } expectContains(t, "stderr", res.stderr, "already at the target") }}, {"tracked change refuses and is kept", func(t *testing.T, h *scriptHarness) { h.mustRun("demo") before := h.head() mod := filepath.Join(h.checkout(), "go.mod") edited := "module " + fwModule + "\n\ngo 1.27.0\n// local edit\n" if err := os.WriteFile(mod, []byte(edited), 0o644); err != nil { t.Fatal(err) } h.resetLogs() res := h.run("demo") expectRefusedUnchanged(t, h, res, before, "uncommitted changes to tracked files") if got, _ := os.ReadFile(mod); string(got) != edited { t.Fatalf("go.mod changed: %q", got) } }}, {"branch checkout refuses", func(t *testing.T, h *scriptHarness) { h.mustRun("demo") h.remote.git(h.checkout(), "switch", "-q", "-c", "mine") before := h.head() h.resetLogs() res := h.run("demo") expectRefusedUnchanged(t, h, res, before, "is on branch mine") if got := h.remote.git(h.checkout(), "symbolic-ref", "--short", "HEAD"); got != "mine" { t.Fatalf("branch = %s", got) } }}, {"non-fast-forward refuses", func(t *testing.T, h *scriptHarness) { h.mustRun("demo") h.remote.git(h.checkout(), "commit", "-q", "--allow-empty", "-m", "local work") before := h.head() h.resetLogs() res := h.run("demo") expectRefusedUnchanged(t, h, res, before, "is not a fast-forward") }}, {"moved upstream tag refuses", func(t *testing.T, h *scriptHarness) { h.mustRun("demo") before := h.head() moved := h.remote.moveTag("v0.10.0") h.resetLogs() res := h.run("demo") if res.code == 0 { t.Fatalf("script followed a moved tag\nstderr:\n%s", res.stderr) } if got := h.head(); got != before { t.Fatalf("HEAD moved from %s to %s", before, got) } if got := h.remote.git(h.checkout(), "rev-parse", "refs/tags/v0.10.0^{commit}"); got == moved || got != before { t.Fatalf("local v0.10.0 = %s, want the original %s (upstream moved to %s)", got, before, moved) } if h.log("summer.call") != "" { t.Fatal("summer was called") } }}, {"another module in the directory refuses", func(t *testing.T, h *scriptHarness) { dir := h.checkout() if err := os.Mkdir(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module example.com/other\n"), 0o644); err != nil { t.Fatal(err) } res := h.run("demo") expectCode(t, res, 1) expectContains(t, "stderr", res.stderr, "is not a SummerCMS framework checkout", "example.com/other") if h.log("summer.call") != "" || h.log("go.install") != "" { t.Fatal("the script went on after refusing the directory") } }}, {"framework directory that is not a git repository refuses", func(t *testing.T, h *scriptHarness) { dir := h.checkout() if err := os.Mkdir(dir, 0o755); err != nil { t.Fatal(err) } if err := os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module "+fwModule+"\n"), 0o644); err != nil { t.Fatal(err) } res := h.run("demo") expectCode(t, res, 1) expectContains(t, "stderr", res.stderr, "is not a git repository") if _, err := os.Stat(filepath.Join(dir, ".git")); !os.IsNotExist(err) { t.Fatalf("the script created .git: %v", err) } }}, {"empty SHELL prints the generic hint", func(t *testing.T, h *scriptHarness) { res := h.mustRun("demo") expectHintSuffix(t, res.stdout, "\n==> summer is installed in "+h.gobin+", which is not on your PATH. Run this (and add it to your shell profile):\n export PATH=\""+h.gobin+":$PATH\"\n") }}, {"unset SHELL prints the generic hint", func(t *testing.T, h *scriptHarness) { h.unsetShell = true res := h.mustRun("demo") expectLacks(t, "stderr", res.stderr, "unbound variable") expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to your shell profile")) }}, {"unknown SHELL prints the generic hint", func(t *testing.T, h *scriptHarness) { h.env = append(h.env, "SHELL=/bin/tcsh") res := h.mustRun("demo") expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to your shell profile")) }}, {"fish SHELL prints fish_add_path", func(t *testing.T, h *scriptHarness) { h.env = append(h.env, "SHELL=/usr/bin/fish") res := h.mustRun("demo") expectHintSuffix(t, res.stdout, fishHint(h.gobin)) expectContains(t, "stdout", res.stdout, "fish_add_path also keeps it for new fish sessions", ` fish_add_path "`+h.gobin+`"`) expectLacks(t, "stdout", res.stdout, "export PATH", "shell profile") }}, {"zsh SHELL names ~/.zshrc", func(t *testing.T, h *scriptHarness) { h.env = append(h.env, "SHELL=/bin/zsh") res := h.mustRun("demo") expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.zshrc")) expectLacks(t, "stdout", res.stdout, "fish_add_path") }}, {"bash SHELL names ~/.bashrc", func(t *testing.T, h *scriptHarness) { h.env = append(h.env, "SHELL=/usr/local/bin/bash") res := h.mustRun("demo") expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.bashrc")) expectLacks(t, "stdout", res.stdout, "fish_add_path") }}, {"metacharacters in the dir are escaped for zsh", func(t *testing.T, h *scriptHarness) { metaGobin(t, h) h.env = append(h.env, "SHELL=/bin/zsh") res := h.mustRun("demo") want := " export PATH=\"" + hintDir(h.root, false) + "/" + (`go bin\\x\"y\$HOME\` + "`" + `id\` + "`" + `"'!'"z`) + ":$PATH\"\n" expectContains(t, "stdout", res.stdout, want) expectHintSuffix(t, res.stdout, exportHint(h.gobin, "and add it to ~/.zshrc")) }}, {"metacharacters in the dir are escaped for fish", func(t *testing.T, h *scriptHarness) { metaGobin(t, h) h.env = append(h.env, "SHELL=/usr/bin/fish") res := h.mustRun("demo") want := " fish_add_path \"" + hintDir(h.root, true) + "/" + (`go bin\\x\"y\$HOME` + "`id`!z") + "\"\n" expectContains(t, "stdout", res.stdout, want) expectHintSuffix(t, res.stdout, fishHint(h.gobin)) }}, {"GOBIN on PATH prints no hint", func(t *testing.T, h *scriptHarness) { h.gobinOnPath = true h.env = append(h.env, "SHELL=/usr/bin/fish") res := h.mustRun("demo") expectLacks(t, "stdout", res.stdout, "export PATH", "fish_add_path", "not on your PATH") }}, {"empty GOBIN installs into GOPATH/bin", func(t *testing.T, h *scriptHarness) { h.useGOBIN = false res := h.mustRun("demo") bin := filepath.Join(h.gopath, "bin") if _, err := os.Stat(filepath.Join(bin, "summer")); err != nil { t.Fatalf("summer not installed in GOPATH/bin: %v", err) } expectContains(t, "stderr", res.stderr, "installing summer into "+bin) expectContains(t, "stdout", res.stdout, `export PATH="`+bin+`:$PATH"`) }}, } if len(cases) < 32 { t.Fatalf("only %d cases", len(cases)) } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { tc.run(t, newScriptHarness(t)) }) } } // TestScriptShellcheck runs shellcheck on new.sh; it is skipped only when // shellcheck is not installed. func TestScriptShellcheck(t *testing.T) { sc, err := exec.LookPath("shellcheck") if err != nil { t.Skip("shellcheck not installed") } out, err := exec.Command(sc, "-s", "bash", "new.sh").CombinedOutput() if err != nil { t.Fatalf("shellcheck -s bash new.sh: %v\n%s", err, out) } }