From 93e9a8a45cc49b61c3a5790c4cc55167e9c68012 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Thu, 8 Oct 2026 12:25:24 +0200 Subject: [PATCH] test(14.3-04): pin the bootstrap script's behaviour matrix and shellcheck - TestScriptBehavior: 25 cases on a stub PATH (real git, stub go/docker/summer) against a bare fake framework remote: usage and name errors, existing app, Go missing/old/devel, git required, Docker warning, newest release tag, SUMMERCMS_REF, fast-forward re-runs, refusals with HEAD unchanged, summer called from the parent with stdin closed, cd line and PATH hint - TestScriptShellcheck: shellcheck -s bash new.sh, skipped only without shellcheck --- modules/sunrise/script_behavior_test.go | 629 ++++++++++++++++++++++++ 1 file changed, 629 insertions(+) create mode 100644 modules/sunrise/script_behavior_test.go diff --git a/modules/sunrise/script_behavior_test.go b/modules/sunrise/script_behavior_test.go new file mode 100644 index 0000000..5024ae9 --- /dev/null +++ b/modules/sunrise/script_behavior_test.go @@ -0,0 +1,629 @@ +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 + 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, + ) + // 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...)...) + 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) + } + } +} + +// 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) + } + }}, + {"GOBIN off PATH prints the hint", func(t *testing.T, h *scriptHarness) { + res := h.mustRun("demo") + expectContains(t, "stdout", res.stdout, "not on your PATH", `export PATH="`+h.gobin+`:$PATH"`) + }}, + {"GOBIN on PATH prints no hint", func(t *testing.T, h *scriptHarness) { + h.gobinOnPath = true + res := h.mustRun("demo") + if strings.Contains(res.stdout, "export PATH") || strings.Contains(res.stdout, "not on your PATH") { + t.Fatalf("hint printed although GOBIN is on PATH:\n%s", res.stdout) + } + }}, + {"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) < 18 { + 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) + } +}