e2e/cli_test.go
235 lines · 8953 bytes
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}