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