e2e/cli_test.go

e6cd75b5f28bacf51620bb531320c30fd4e66bfd
gitbay/e2e/cli_test.go history · blame · raw

235 lines · 8953 bytes

4 symbols in this file
  1package e2e
  2
  3import (
  4	"encoding/json"
  5	"fmt"
  6	"os"
  7	"os/exec"
  8	"path/filepath"
  9	"strings"
 10	"testing"
 11)
 12
 13// cli runs the forge binary with an isolated config home.
 14type cli struct {
 15	bin       string
 16	configDir string
 17	inst      *instance
 18	key       string
 19	env       []string // appended last, so it overrides the defaults
 20}
 21
 22func (c *cli) run(t *testing.T, dir, stdin string, args ...string) (string, string, int) {
 23	t.Helper()
 24	cmd := exec.Command(c.bin, args...)
 25	cmd.Dir = dir
 26	cmd.Env = append(os.Environ(),
 27		"XDG_CONFIG_HOME="+c.configDir,
 28		"GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
 29		"GIT_AUTHOR_NAME=t", "GIT_AUTHOR_EMAIL=t@example.test",
 30		"GIT_COMMITTER_NAME=t", "GIT_COMMITTER_EMAIL=t@example.test",
 31		"EDITOR=", // no editor in tests: bodies come from flags
 32	)
 33	cmd.Env = append(cmd.Env, c.env...)
 34	if stdin != "" {
 35		cmd.Stdin = strings.NewReader(stdin)
 36	}
 37	var out, errOut strings.Builder
 38	cmd.Stdout = &out
 39	cmd.Stderr = &errOut
 40	err := cmd.Run()
 41	code := 0
 42	if ee, ok := err.(*exec.ExitError); ok {
 43		code = ee.ExitCode()
 44	} else if err != nil {
 45		t.Fatalf("gitbay %v: %v", args, err)
 46	}
 47	return out.String(), errOut.String(), code
 48}
 49
 50func (c *cli) must(t *testing.T, dir, stdin string, args ...string) string {
 51	t.Helper()
 52	out, errOut, code := c.run(t, dir, stdin, args...)
 53	if code != 0 {
 54		t.Fatalf("gitbay %v: exit %d\nstdout: %s\nstderr: %s", args, code, out, errOut)
 55	}
 56	return out
 57}
 58
 59func TestCLI(t *testing.T) {
 60	t.Parallel()
 61	inst := startInstance(t)
 62	aliceKey := inst.newKey(t, "alice")
 63	inst.admin(t, "admin", "user", "create", "alice",
 64		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
 65
 66	c := &cli{
 67		bin:       buildGitbayCLI(t),
 68		configDir: t.TempDir(),
 69		inst:      inst,
 70		key:       aliceKey,
 71	}
 72
 73	// Configure the instance, with ssh options so the test's throwaway key
 74	// and known_hosts are used.
 75	c.must(t, "", "", "remote", "add", "test", "127.0.0.1",
 76		"--port", fmt.Sprint(inst.port),
 77		"--ssh-option", "-i", "--ssh-option", aliceKey,
 78		"--ssh-option", "-oIdentitiesOnly=yes",
 79		"--ssh-option", "-oStrictHostKeyChecking=no",
 80		"--ssh-option", "-oUserKnownHostsFile="+filepath.Join(inst.sshDir, "kh"),
 81		"--ssh-option", "-oBatchMode=yes",
 82		"--default")
 83	if out := c.must(t, "", "", "remote", "list"); !strings.Contains(out, "test\tgit@127.0.0.1") || !strings.Contains(out, "(default)") {
 84		t.Fatalf("remote list: %s", out)
 85	}
 86
 87	// whoami through the CLI, JSON passthrough intact.
 88	out := c.must(t, "", "", "auth", "whoami", "--json")
 89	var env struct {
 90		ProtocolVersion int `json:"protocol_version"`
 91		Data            struct {
 92			Username string `json:"username"`
 93		} `json:"data"`
 94	}
 95	if err := json.Unmarshal([]byte(out), &env); err != nil || env.Data.Username != "alice" || env.ProtocolVersion != 1 {
 96		t.Fatalf("whoami via CLI: %v %s", err, out)
 97	}
 98
 99	// Repo create + clone through the CLI.
100	c.must(t, "", "", "repo", "create", "alice/proj")
101
102	// Secret values travel on stdin through the CLI (piped, no flag).
103	c.must(t, "", "hunter2\n", "repo", "secret", "set", "alice/proj", "TOKEN")
104	if out := c.must(t, "", "", "repo", "secret", "list", "alice/proj"); !strings.Contains(out, "TOKEN") || strings.Contains(out, "hunter2") {
105		t.Fatalf("secret list via CLI: %s", out)
106	}
107
108	work := t.TempDir()
109	c.must(t, work, "", "repo", "clone", "alice/proj")
110	dir := filepath.Join(work, "proj")
111	if _, err := os.Stat(filepath.Join(dir, ".git")); err != nil {
112		t.Fatal("clone did not produce a repo")
113	}
114
115	// Push some content (plain git, using the clone's remote).
116	cliGitEnv := inst.gitEnv(aliceKey)
117	os.WriteFile(filepath.Join(dir, "README"), []byte("hi\n"), 0o644)
118	mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "main")
119	mustGit(t, dir, cliGitEnv, "add", ".")
120	mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "init")
121	mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "main")
122
123	// Inside the clone, the repo argument is inferred from origin.
124	c.must(t, dir, "", "issue", "create", "--title", "inferred repo works", "--body", "body")
125	out = c.must(t, dir, "", "issue", "list")
126	if !strings.Contains(out, "inferred repo works") {
127		t.Fatalf("issue list in clone: %s", out)
128	}
129	// Explicit owner/name still works from anywhere.
130	out = c.must(t, "", "", "issue", "show", "alice/proj", "1")
131	if !strings.Contains(out, "inferred repo works") {
132		t.Fatalf("issue show explicit: %s", out)
133	}
134	// Outside a clone with no explicit repo: usage error, not a hang.
135	_, errOut, code := c.run(t, "", "", "issue", "list")
136	if code != 2 || !strings.Contains(errOut, "none inferable") {
137		t.Fatalf("bare issue list outside clone: exit %d, %s", code, errOut)
138	}
139
140	// Exit codes pass through: missing issue is 3.
141	if _, _, code := c.run(t, dir, "", "issue", "show", "99"); code != 3 {
142		t.Fatalf("missing issue via CLI: exit %d, want 3", code)
143	}
144
145	// MR flow: branch, push, create (inferred), checkout, merge.
146	mustGit(t, dir, cliGitEnv, "checkout", "-q", "-b", "feature")
147	os.WriteFile(filepath.Join(dir, "f.txt"), []byte("feature\n"), 0o644)
148	mustGit(t, dir, cliGitEnv, "add", ".")
149	mustGit(t, dir, cliGitEnv, "commit", "-q", "-m", "feature work")
150	mustGit(t, dir, cliGitEnv, "push", "-q", "origin", "feature")
151	// A noun's --help is the server's reference for that noun, not
152	// cobra's flagless subcommand list (#130); a verb's --help carries
153	// its flags.
154	if out := c.must(t, dir, "", "issue", "--help"); !strings.Contains(out, "WRITE\n") || !strings.Contains(out, "issue <verb> --help for flags.") {
155		t.Fatalf("issue --help is not the server's reference:\n%s", out)
156	}
157	if out := c.must(t, dir, "", "issue", "create", "--help"); !strings.Contains(out, "issue create <owner/name>") || !strings.Contains(out, "--title <t>") {
158		t.Fatalf("issue create --help carries no flags:\n%s", out)
159	}
160
161	// Inside the clone on the branch, --source is the checked-out branch
162	// and --target the default branch; neither needs typing (#101).
163	c.must(t, dir, "", "mr", "create", "--title", "via cli")
164	if out := c.must(t, dir, "", "mr", "show", "1", "--json"); !strings.Contains(out, `"source":"feature"`) || !strings.Contains(out, `"target_ref":"main"`) {
165		t.Fatalf("mr create did not infer source and target:\n%s", out)
166	}
167
168	// mr checkout uses the clone's own git; the MR ref comes from origin.
169	mustGit(t, dir, cliGitEnv, "checkout", "-q", "main")
170	cmd := exec.Command(c.bin, "mr", "checkout", "1")
171	cmd.Dir = dir
172	cmd.Env = append(cliGitEnv, "XDG_CONFIG_HOME="+c.configDir)
173	if out, err := cmd.CombinedOutput(); err != nil {
174		t.Fatalf("mr checkout: %v\n%s", err, out)
175	}
176	branch := strings.TrimSpace(mustGit(t, dir, cliGitEnv, "rev-parse", "--abbrev-ref", "HEAD"))
177	if branch != "mr/1" {
178		t.Fatalf("mr checkout branch = %s", branch)
179	}
180	if _, err := os.Stat(filepath.Join(dir, "f.txt")); err != nil {
181		t.Fatal("mr checkout content missing")
182	}
183
184	c.must(t, dir, "", "mr", "merge", "1")
185	out = c.must(t, dir, "", "mr", "show", "1")
186	if !strings.Contains(out, "merged") {
187		t.Fatalf("mr not merged: %s", out)
188	}
189
190	// keys add reads the public key from CLI stdin.
191	secondKey := inst.newKey(t, "alice2")
192	pub, _ := os.ReadFile(secondKey + ".pub")
193	c.must(t, "", string(pub), "auth", "keys", "add", "--scope", "git")
194	if out = c.must(t, "", "", "auth", "keys", "list"); len(strings.Split(strings.TrimSpace(out), "\n")) != 2 {
195		t.Fatalf("keys list: %s", out)
196	}
197
198	// forge init: new local project, repo created server-side, origin set.
199	proj := filepath.Join(t.TempDir(), "newthing")
200	os.MkdirAll(proj, 0o755)
201	c.must(t, proj, "", "init", "--private")
202	originOut := mustGit(t, proj, cliGitEnv, "remote", "get-url", "origin")
203	if !strings.Contains(originOut, "/alice/newthing.git") {
204		t.Fatalf("init origin: %s", originOut)
205	}
206	if out = c.must(t, "", "", "repo", "show", "alice/newthing"); !strings.Contains(out, "private") {
207		t.Fatalf("init-created repo: %s", out)
208	}
209
210	// A clone whose origin points at a FOREIGN host must not hijack the
211	// command: the configured default instance is used, and repo inference
212	// is dropped rather than guessed across hosts.
213	foreign := filepath.Join(t.TempDir(), "f")
214	os.MkdirAll(foreign, 0o755)
215	mustGit(t, foreign, cliGitEnv, "init", "-q")
216	mustGit(t, foreign, cliGitEnv, "remote", "add", "origin", "git@github.example:someone/thing.git")
217	out = c.must(t, foreign, "", "auth", "whoami")
218	if strings.TrimSpace(out) != "alice" {
219		t.Fatalf("foreign-origin whoami hit the wrong host: %q", out)
220	}
221	_, errOut2, code2 := c.run(t, foreign, "", "issue", "list")
222	if code2 != 2 || !strings.Contains(errOut2, "none inferable") {
223		t.Fatalf("foreign-origin repo inference: exit %d, %s", code2, errOut2)
224	}
225
226	// Man pages and completions generate.
227	manDir := t.TempDir()
228	c.must(t, "", "", "man", "--dir", manDir)
229	if entries, _ := os.ReadDir(manDir); len(entries) < 10 {
230		t.Fatalf("man pages: only %d generated", len(entries))
231	}
232	if out = c.must(t, "", "", "completion", "zsh"); !strings.Contains(out, "compdef") {
233		t.Fatal("zsh completion missing")
234	}
235}