- new.sh reads the basename of SHELL (via ${SHELL:-} under set -u) and
prints fish_add_path "<dir>" for fish, which keeps it for new sessions,
export PATH="<dir>:$PATH" naming ~/.zshrc or ~/.bashrc for zsh and
bash, and today's generic export line with "your shell profile" for
any other, empty or unset shell
- dq_escape escapes the install directory for the target shell's double
quotes: backslash, double quote and dollar always, plus backtick and a
single-quoted ! for POSIX shells; a per-character loop, so bash 3.2
and 5.2 behave the same
- the behaviour harness defaults SHELL to empty so cases do not depend on
the developer's login shell, adds an unset-SHELL mode and covers fish,
zsh, bash, empty, unset, unknown and two metacharacter directories
- TestBootstrapEndToEnd pins SHELL=/bin/bash
- sunrise README and docs/setup/installation.md describe the per-shell
hint; the manual PATH section gains fish_add_path (go env GOPATH)/bin
736 lines
25 KiB
Go
736 lines
25 KiB
Go
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 <dir> 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 -- <name>", "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)
|
|
}
|
|
}
|