e2e/mrweb_test.go
603 lines · 25492 bytes
1package e2e
2
3import (
4 "encoding/json"
5 "net/http"
6 "net/url"
7 "os"
8 "path/filepath"
9 "regexp"
10 "strconv"
11 "strings"
12 "testing"
13)
14
15// login returns a browser holding a session for the given key's account.
16func (i *instance) login(t *testing.T, key string) *http.Client {
17 t.Helper()
18 out, errOut, code := i.ssh(t, key, "", "web", "login", "--json")
19 if code != 0 {
20 t.Fatalf("web login: %s", errOut)
21 }
22 var env struct {
23 Data struct {
24 URL string `json:"url"`
25 } `json:"data"`
26 }
27 json.Unmarshal([]byte(out), &env)
28 c := newBrowser(t)
29 path := env.Data.URL[strings.Index(env.Data.URL, "/login"):]
30 if status, _ := browserGet(t, c, i.base()+path); status != 200 {
31 t.Fatalf("login landed: %d", status)
32 }
33 return c
34}
35
36// TestMRWebReviewLoop drives review, thread resolution, and merge from the
37// browser. Every action runs the same control command the CLI runs, so the
38// test also proves the merge gates apply to web merges.
39func TestMRWebReviewLoop(t *testing.T) {
40 t.Parallel()
41 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
42 aliceKey := inst.newKey(t, "alice")
43 bobKey := inst.newKey(t, "bob")
44 inst.admin(t, "admin", "user", "create", "alice",
45 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
46 inst.admin(t, "admin", "user", "create", "bob",
47 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
48
49 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
50 t.Fatalf("repo create: %s", errOut)
51 }
52 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "access", "grant", "alice/lib", "bob", "write"); code != 0 {
53 t.Fatalf("grant: %s", errOut)
54 }
55 // Unresolved review threads block merges, so the gate is observable.
56 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "settings", "require-resolved", "alice/lib", "on"); code != 0 {
57 t.Fatalf("require-resolved: %s", errOut)
58 }
59
60 env := inst.gitEnv(aliceKey)
61 work := t.TempDir()
62 mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
63 dir := filepath.Join(work, "w")
64 os.WriteFile(filepath.Join(dir, "lib.txt"), []byte("v1\n"), 0o644)
65 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
66 mustGit(t, dir, env, "add", ".")
67 mustGit(t, dir, env, "commit", "-q", "-m", "base")
68 mustGit(t, dir, env, "push", "-q", "origin", "main")
69
70 // Bob proposes a change and leaves a review thread on it.
71 bobEnv := inst.gitEnv(bobKey)
72 bobWork := t.TempDir()
73 mustGit(t, bobWork, bobEnv, "clone", inst.sshURL("alice/lib"), "w")
74 bobDir := filepath.Join(bobWork, "w")
75 mustGit(t, bobDir, bobEnv, "checkout", "-q", "-b", "feature", "origin/main")
76 os.WriteFile(filepath.Join(bobDir, "feature.txt"), []byte("bob's work\n"), 0o644)
77 mustGit(t, bobDir, bobEnv, "add", ".")
78 mustGit(t, bobDir, bobEnv, "commit", "-q", "-m", "add feature")
79 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "feature")
80 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "create", "alice/lib",
81 "--source", "feature", "--target", "main", "--title", "'add feature'"); code != 0 {
82 t.Fatalf("mr create: %s", errOut)
83 }
84 if _, errOut, code := inst.ssh(t, bobKey, "", "mr", "diff-comment", "alice/lib", "1",
85 "--path", "feature.txt", "--line", "1", "--message", "'is this right?'"); code != 0 {
86 t.Fatalf("diff-comment: %s", errOut)
87 }
88
89 mrURL := inst.base() + "/alice/lib/mrs/1"
90 alice := inst.login(t, aliceKey)
91
92 // The controls are on the page, and carry the thread to resolve.
93 _, body := browserGet(t, alice, mrURL)
94 for _, want := range []string{`value="approve"`, `action="/alice/lib/mrs/1/merge"`} {
95 if !strings.Contains(body, want) {
96 t.Fatalf("MR page missing %q", want)
97 }
98 }
99 // Review threads live on the diff view, where their lines are.
100 _, diffBody := browserGet(t, alice, mrURL+"?view=diff")
101 m := regexp.MustCompile(`name="thread" value="(\d+)"`).FindStringSubmatch(diffBody)
102 if m == nil {
103 t.Fatalf("no thread control on the diff view:\n%s", diffBody)
104 }
105 threadID := m[1]
106
107 // Approve from the browser; the CLI sees the review.
108 if status, _ := browserPost(t, alice, mrURL+"/review", url.Values{"verdict": {"approve"}}); status != 200 {
109 t.Fatalf("review post: %d", status)
110 }
111 show := inst.mrShow(t, aliceKey, "alice/lib", "1")
112 if len(show.Reviews) != 1 || show.Reviews[0].Reviewer != "alice" || show.Reviews[0].Verdict != "approve" {
113 t.Fatalf("review not recorded: %+v", show.Reviews)
114 }
115
116 // Merging is refused while the thread is open, and the page says why.
117 _, body = browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}})
118 if !strings.Contains(body, "unresolved") {
119 t.Fatalf("merge gate not surfaced:\n%s", body)
120 }
121 if st := inst.mrShow(t, aliceKey, "alice/lib", "1").State; st != "open" {
122 t.Fatalf("blocked merge changed state to %s", st)
123 }
124
125 // Resolve the thread, then merge.
126 if status, _ := browserPost(t, alice, mrURL+"/thread",
127 url.Values{"thread": {threadID}, "action": {"resolve"}}); status != 200 {
128 t.Fatalf("resolve post: %d", status)
129 }
130 out, _, _ := inst.ssh(t, aliceKey, "", "mr", "threads", "alice/lib", "1")
131 if !strings.Contains(out, "resolved") {
132 t.Fatalf("thread not resolved:\n%s", out)
133 }
134 if status, _ := browserPost(t, alice, mrURL+"/merge", url.Values{"strategy": {"auto"}}); status != 200 {
135 t.Fatalf("merge post: %d", status)
136 }
137 merged := inst.mrShow(t, aliceKey, "alice/lib", "1")
138 if merged.State != "merged" {
139 t.Fatalf("MR state after web merge: %s", merged.State)
140 }
141 // Who merged it and when, so the page can stop saying alice wants to.
142 if merged.MergedBy != "alice" || merged.MergedAt == "" {
143 t.Fatalf("merge not attributed: %+v", merged)
144 }
145 if merged.Reviews[0].CreatedAt == "" {
146 t.Fatalf("review carries no timestamp: %+v", merged.Reviews[0])
147 }
148 _, body = browserGet(t, alice, mrURL)
149 if strings.Contains(body, "opened by") {
150 t.Fatalf("merged MR still says it was opened:\n%s", body)
151 }
152 if !strings.Contains(body, "merged by <a href=\"/alice\">alice</a>") {
153 t.Fatalf("merged MR does not name the merger:\n%s", body)
154 }
155 mustGit(t, dir, env, "pull", "-q", "origin", "main")
156 if _, err := os.Stat(filepath.Join(dir, "feature.txt")); err != nil {
157 t.Fatal("merged content missing from main")
158 }
159 // A merged MR shows its merged head, and marks a source branch that
160 // no longer exists.
161 mustGit(t, bobDir, bobEnv, "push", "-q", "origin", "--delete", "feature")
162 _, body = browserGet(t, alice, mrURL)
163 if !strings.Contains(body, "merged at") || !strings.Contains(body, "branch deleted") {
164 t.Fatalf("merged MR sidebar after the branch was deleted:\n%s", body)
165 }
166
167 // Readers get no controls, and a forged POST is refused by the command.
168 _, anon := browserGet(t, newBrowser(t), mrURL)
169 if strings.Contains(anon, `value="approve"`) {
170 t.Fatal("anonymous visitor sees review controls")
171 }
172 carol := inst.newKey(t, "carol")
173 inst.admin(t, "admin", "user", "create", "carol", "--key", carol+".pub")
174 if _, errOut, code := inst.ssh(t, carol, "", "repo", "create", "carol/own"); code != 0 {
175 t.Fatalf("carol repo: %s", errOut)
176 }
177 _, denied := browserPost(t, inst.login(t, carol), mrURL+"/close", url.Values{})
178 if !strings.Contains(denied, `class="error"`) || !strings.Contains(denied, "write access") {
179 t.Fatalf("reader was not refused:\n%s", denied)
180 }
181}
182
183// TestMRWebCreate opens a merge request from the browser and checks the
184// form survives a refusal with the draft intact.
185func TestMRWebCreate(t *testing.T) {
186 t.Parallel()
187 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
188 aliceKey := inst.newKey(t, "alice")
189 inst.admin(t, "admin", "user", "create", "alice",
190 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
191 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
192 t.Fatalf("repo create: %s", errOut)
193 }
194 env := inst.gitEnv(aliceKey)
195 work := t.TempDir()
196 mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
197 dir := filepath.Join(work, "w")
198 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
199 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
200 mustGit(t, dir, env, "add", ".")
201 mustGit(t, dir, env, "commit", "-q", "-m", "base")
202 mustGit(t, dir, env, "push", "-q", "origin", "main")
203 mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
204 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
205 mustGit(t, dir, env, "add", ".")
206 mustGit(t, dir, env, "commit", "-q", "-m", "topic work")
207 mustGit(t, dir, env, "push", "-q", "origin", "topic")
208
209 alice := inst.login(t, aliceKey)
210 base := inst.base() + "/alice/lib"
211
212 // The list links to the form, and the form offers the pushed branches.
213 if _, body := browserGet(t, alice, base+"/mrs"); !strings.Contains(body, "/alice/lib/mrs/new") {
214 t.Fatalf("no create link on the list:\n%s", body)
215 }
216 _, form := browserGet(t, alice, base+"/mrs/new")
217 for _, want := range []string{`name="source"`, `value="topic"`, `value="main"`} {
218 if !strings.Contains(form, want) {
219 t.Fatalf("form missing %q:\n%s", want, form)
220 }
221 }
222
223 // A refusal keeps the draft: the branch does not exist.
224 _, retry := browserPost(t, alice, base+"/mrs/new", url.Values{
225 "source": {"nope"}, "target": {"main"}, "title": {"my title"}, "body": {"my body"}})
226 if !strings.Contains(retry, `class="error"`) || !strings.Contains(retry, "my title") ||
227 !strings.Contains(retry, "my body") {
228 t.Fatalf("refusal lost the draft:\n%s", retry)
229 }
230
231 // A real one lands on the merge request it created.
232 status, created := browserPost(t, alice, base+"/mrs/new", url.Values{
233 "source": {"topic"}, "target": {"main"}, "title": {"topic into main"}, "body": {"please review"}})
234 if status != 200 || !strings.Contains(created, "topic into main") {
235 t.Fatalf("create failed: %d\n%s", status, created)
236 }
237 show := inst.mrShow(t, aliceKey, "alice/lib", "1")
238 if show.State != "open" || show.Source != "topic" {
239 t.Fatalf("created MR wrong: %+v", show)
240 }
241}
242
243// TestMRListRows checks that the merge request list shows each row's
244// combined check state and comment count (#230).
245func TestMRListRows(t *testing.T) {
246 t.Parallel()
247 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
248 aliceKey := inst.newKey(t, "alice")
249 inst.admin(t, "admin", "user", "create", "alice",
250 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
251 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
252 t.Fatalf("repo create: %s", errOut)
253 }
254 env := inst.gitEnv(aliceKey)
255 work := t.TempDir()
256 mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
257 dir := filepath.Join(work, "w")
258 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
259 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
260 mustGit(t, dir, env, "add", ".")
261 mustGit(t, dir, env, "commit", "-q", "-m", "base")
262 mustGit(t, dir, env, "push", "-q", "origin", "main")
263 mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
264 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
265 mustGit(t, dir, env, "add", ".")
266 mustGit(t, dir, env, "commit", "-q", "-m", "topic work")
267 mustGit(t, dir, env, "push", "-q", "origin", "topic")
268 sha := strings.TrimSpace(mustGit(t, dir, env, "rev-parse", "topic"))
269
270 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/lib",
271 "--source", "topic", "--target", "main", "--title", "feature"); code != 0 {
272 t.Fatalf("mr create: %s", errOut)
273 }
274 if _, errOut, code := inst.ssh(t, aliceKey, "", "status", "set", "alice/lib", sha,
275 "--context", "ci/test", "--state", "success"); code != 0 {
276 t.Fatalf("status set: %s", errOut)
277 }
278 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "comment", "alice/lib", "1",
279 "--message", "hi"); code != 0 {
280 t.Fatalf("mr comment: %s", errOut)
281 }
282
283 alice := inst.login(t, aliceKey)
284 _, body := browserGet(t, alice, inst.base()+"/alice/lib/mrs")
285 if !strings.Contains(body, `class="chip check-success"`) {
286 t.Errorf("no check chip on the list:\n%s", body)
287 }
288 if !strings.Contains(body, `>1 <span class="vh">comments</span>`) {
289 t.Errorf("no comment count on the list:\n%s", body)
290 }
291}
292
293// TestMRWebDiffThreads opens a review thread on a diff line and replies to
294// it from the browser. The CLI's view of the threads afterwards is what
295// proves the page dispatched mr diff-comment rather than writing its own
296// rows.
297func TestMRWebDiffThreads(t *testing.T) {
298 t.Parallel()
299 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
300 aliceKey := inst.newKey(t, "alice")
301 inst.admin(t, "admin", "user", "create", "alice",
302 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
303
304 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/lib"); code != 0 {
305 t.Fatalf("repo create: %s", errOut)
306 }
307 env := inst.gitEnv(aliceKey)
308 work := t.TempDir()
309 mustGit(t, work, env, "clone", inst.sshURL("alice/lib"), "w")
310 dir := filepath.Join(work, "w")
311 os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {\n}\n"), 0o644)
312 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
313 mustGit(t, dir, env, "add", ".")
314 mustGit(t, dir, env, "commit", "-q", "-m", "base")
315 mustGit(t, dir, env, "push", "-q", "origin", "main")
316 mustGit(t, dir, env, "checkout", "-q", "-b", "feat")
317 os.WriteFile(filepath.Join(dir, "main.go"),
318 []byte("package main\n\nimport \"fmt\"\n\nfunc main() {\n\tfmt.Println(\"hi\")\n}\n"), 0o644)
319 mustGit(t, dir, env, "add", ".")
320 mustGit(t, dir, env, "commit", "-q", "-m", "add greeting")
321 mustGit(t, dir, env, "push", "-q", "origin", "feat")
322 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/lib",
323 "--source", "feat", "--target", "main", "--title", "'greeting'"); code != 0 {
324 t.Fatalf("mr create: %s", errOut)
325 }
326
327 mrURL := inst.base() + "/alice/lib/mrs/1"
328 alice := inst.login(t, aliceKey)
329
330 // The gutter carries the handle, and following it renders the form
331 // anchored to that line. There is no JavaScript, so the anchor has to
332 // survive a round trip in the query.
333 _, body := browserGet(t, alice, mrURL+"?view=diff")
334 if !strings.Contains(body, "cpath=main.go&cline=6&cside=new") {
335 t.Fatalf("no comment handle in the diff gutter:\n%s", body)
336 }
337 _, body = browserGet(t, alice, mrURL+"?view=diff&cpath=main.go&cline=6&cside=new")
338 if !strings.Contains(body, `id="compose"`) || !strings.Contains(body, `name="line" value="6"`) {
339 t.Fatalf("compose form not rendered:\n%s", body)
340 }
341
342 if status, _ := browserPost(t, alice, mrURL+"/diff-comment", url.Values{
343 "path": {"main.go"}, "line": {"6"}, "side": {"new"},
344 "body": {"use log instead of fmt"}}); status != 200 {
345 t.Fatalf("open thread: %d", status)
346 }
347 threads := inst.mrThreads(t, aliceKey, "alice/lib", "1")
348 if len(threads) != 1 || threads[0].Path != "main.go" || threads[0].Line != 6 {
349 t.Fatalf("thread not anchored: %+v", threads)
350 }
351 if len(threads[0].Comments) != 1 || threads[0].Comments[0].Body != "use log instead of fmt" {
352 t.Fatalf("comment body not stored: %+v", threads[0].Comments)
353 }
354
355 // The rendered thread offers reply and resolve to its author.
356 _, body = browserGet(t, alice, mrURL+"?view=diff")
357 if !strings.Contains(body, `name="reply" value="`+strconv.FormatInt(threads[0].ID, 10)+`"`) {
358 t.Fatalf("no reply form on the thread:\n%s", body)
359 }
360 if !strings.Contains(body, `value="resolve"`) {
361 t.Fatalf("no resolve control on the thread:\n%s", body)
362 }
363
364 if status, _ := browserPost(t, alice, mrURL+"/diff-comment", url.Values{
365 "reply": {strconv.FormatInt(threads[0].ID, 10)}, "body": {"agreed, switching"}}); status != 200 {
366 t.Fatalf("reply: %d", status)
367 }
368 threads = inst.mrThreads(t, aliceKey, "alice/lib", "1")
369 if len(threads) != 1 || len(threads[0].Comments) != 2 ||
370 threads[0].Comments[1].Body != "agreed, switching" {
371 t.Fatalf("reply not on the thread: %+v", threads)
372 }
373
374 // An empty body is refused, and says so on the page it returns to.
375 _, body = browserPost(t, alice, mrURL+"/diff-comment", url.Values{
376 "reply": {strconv.FormatInt(threads[0].ID, 10)}, "body": {" "}})
377 if !strings.Contains(body, "empty comment") {
378 t.Fatalf("empty reply not refused:\n%s", body)
379 }
380}
381
382// mrThread is one review thread as mr threads --json reports it.
383type mrThread struct {
384 ID int64 `json:"id"`
385 Path string `json:"path"`
386 Side string `json:"side"`
387 Line int64 `json:"line"`
388 Comments []struct {
389 Author string `json:"author"`
390 Body string `json:"body"`
391 } `json:"comments"`
392}
393
394// mrThreads reads the review threads on a merge request over SSH.
395func (i *instance) mrThreads(t *testing.T, key, repo, n string) []mrThread {
396 t.Helper()
397 out, errOut, code := i.ssh(t, key, "", "mr", "threads", repo, n, "--json")
398 if code != 0 {
399 t.Fatalf("mr threads: %s", errOut)
400 }
401 var env struct {
402 Data []mrThread `json:"data"`
403 }
404 if err := json.Unmarshal([]byte(out), &env); err != nil {
405 t.Fatalf("mr threads json: %v", err)
406 }
407 return env.Data
408}
409
410// TestMRDiffEmptyExplained: a merge request whose head was fast-forwarded
411// into the target outside the request shows why its diff is empty.
412func TestMRDiffEmptyExplained(t *testing.T) {
413 t.Parallel()
414 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
415 key := inst.newKey(t, "alice")
416 inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub", "--email", "alice@example.test", "--verified")
417 if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/app"); code != 0 {
418 t.Fatalf("repo create: %s", errOut)
419 }
420 env := inst.gitEnv(key)
421 work := t.TempDir()
422 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
423 dir := filepath.Join(work, "w")
424 os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644)
425 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
426 mustGit(t, dir, env, "add", ".")
427 mustGit(t, dir, env, "commit", "-q", "-m", "base")
428 mustGit(t, dir, env, "push", "-q", "origin", "main")
429
430 mustGit(t, dir, env, "checkout", "-q", "-b", "feature")
431 os.WriteFile(filepath.Join(dir, "f.txt"), []byte("one\n"), 0o644)
432 mustGit(t, dir, env, "add", ".")
433 mustGit(t, dir, env, "commit", "-q", "-m", "one")
434 mustGit(t, dir, env, "push", "-q", "origin", "feature")
435 if _, errOut, code := inst.ssh(t, key, "", "mr", "create", "alice/app", "--source", "feature", "--target", "main", "--title", "one"); code != 0 {
436 t.Fatal(errOut)
437 }
438 mustGit(t, dir, env, "push", "-q", "origin", "feature:main")
439 _, body := inst.get(t, "/alice/app/mrs/1?view=diff")
440 if !strings.Contains(body, "No changes between the source and target.") ||
441 !strings.Contains(body, "already merged or fast-forwarded into <code>main</code>") {
442 t.Fatalf("empty diff unexplained:\n%s", body)
443 }
444}
445
446// TestMRSupersedes closes one merge request in favour of another from the
447// web form, and checks both pages say so; clearing it over ssh removes
448// both lines again (#223).
449func TestMRSupersedes(t *testing.T) {
450 t.Parallel()
451 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
452 aliceKey := inst.newKey(t, "alice")
453 inst.admin(t, "admin", "user", "create", "alice",
454 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
455 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
456 t.Fatalf("repo create: %s", errOut)
457 }
458 env := inst.gitEnv(aliceKey)
459 work := t.TempDir()
460 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
461 dir := filepath.Join(work, "w")
462 os.WriteFile(filepath.Join(dir, "README"), []byte("base\n"), 0o644)
463 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
464 mustGit(t, dir, env, "add", ".")
465 mustGit(t, dir, env, "commit", "-q", "-m", "base")
466 mustGit(t, dir, env, "push", "-q", "origin", "main")
467
468 for _, branch := range []string{"one", "two"} {
469 mustGit(t, dir, env, "checkout", "-q", "main")
470 mustGit(t, dir, env, "checkout", "-q", "-b", branch)
471 os.WriteFile(filepath.Join(dir, branch+".txt"), []byte(branch+"\n"), 0o644)
472 mustGit(t, dir, env, "add", ".")
473 mustGit(t, dir, env, "commit", "-q", "-m", branch)
474 mustGit(t, dir, env, "push", "-q", "origin", branch)
475 }
476 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
477 "--source", "one", "--target", "main", "--title", "one"); code != 0 {
478 t.Fatalf("mr create one: %s", errOut)
479 }
480 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
481 "--source", "two", "--target", "main", "--title", "two"); code != 0 {
482 t.Fatalf("mr create two: %s", errOut)
483 }
484
485 alice := inst.login(t, aliceKey)
486 if status, body := browserPost(t, alice, inst.base()+"/alice/app/mrs/1/close", url.Values{"by": {"2"}}); status != 200 {
487 t.Fatalf("close post: %d\n%s", status, body)
488 }
489
490 _, body1 := browserGet(t, alice, inst.base()+"/alice/app/mrs/1")
491 if !strings.Contains(body1, `in favour of <a href="/alice/app/mrs/2">!2</a>`) {
492 t.Fatalf("!1 does not say it was superseded:\n%s", body1)
493 }
494 _, body2 := browserGet(t, alice, inst.base()+"/alice/app/mrs/2")
495 if !strings.Contains(body2, `supersedes <a href="/alice/app/mrs/1">!1</a>`) {
496 t.Fatalf("!2 does not say what it supersedes:\n%s", body2)
497 }
498
499 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "edit", "alice/app", "1", "--superseded-by", "none"); code != 0 {
500 t.Fatalf("mr edit --superseded-by none: %s", errOut)
501 }
502 _, body1 = browserGet(t, alice, inst.base()+"/alice/app/mrs/1")
503 if strings.Contains(body1, "in favour of") {
504 t.Fatalf("!1 still says it was superseded after clearing:\n%s", body1)
505 }
506 _, body2 = browserGet(t, alice, inst.base()+"/alice/app/mrs/2")
507 if strings.Contains(body2, "supersedes") {
508 t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2)
509 }
510}
511
512// TestMRWebLabels labels a merge request from the browser and filters the
513// list by it (#231). The CLI is the check that the page dispatched
514// mr label rather than writing its own rows.
515func TestMRWebLabels(t *testing.T) {
516 t.Parallel()
517 inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
518 aliceKey := inst.newKey(t, "alice")
519 bobKey := inst.newKey(t, "bob")
520 inst.admin(t, "admin", "user", "create", "alice",
521 "--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
522 inst.admin(t, "admin", "user", "create", "bob",
523 "--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
524 if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
525 t.Fatalf("repo create: %s", errOut)
526 }
527 env := inst.gitEnv(aliceKey)
528 work := t.TempDir()
529 mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
530 dir := filepath.Join(work, "w")
531 os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
532 mustGit(t, dir, env, "checkout", "-q", "-b", "main")
533 mustGit(t, dir, env, "add", ".")
534 mustGit(t, dir, env, "commit", "-q", "-m", "base")
535 mustGit(t, dir, env, "push", "-q", "origin", "main")
536 mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
537 os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
538 mustGit(t, dir, env, "add", ".")
539 mustGit(t, dir, env, "commit", "-q", "-m", "topic work")
540 mustGit(t, dir, env, "push", "-q", "origin", "topic")
541 if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
542 "--source", "topic", "--target", "main", "--title", "feature"); code != 0 {
543 t.Fatalf("mr create: %s", errOut)
544 }
545
546 alice := inst.login(t, aliceKey)
547 mrURL := inst.base() + "/alice/app/mrs/1"
548
549 // The form is on the page, and applying it lands in the CLI's view.
550 if _, body := browserGet(t, alice, mrURL); !strings.Contains(body, `/mrs/1/label`) {
551 t.Fatalf("MR page has no label form:\n%s", body)
552 }
553 if status, _ := browserPost(t, alice, mrURL+"/label", url.Values{"add": {"bug ui"}}); status != 200 {
554 t.Fatalf("label post: %d", status)
555 }
556 out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json")
557 for _, want := range []string{`"bug"`, `"ui"`} {
558 if !strings.Contains(out, want) {
559 t.Fatalf("label %s did not land:\n%s", want, out)
560 }
561 }
562 _, body := browserGet(t, alice, mrURL)
563 if n := strings.Count(body, `class="chip label"`); n != 2 {
564 t.Fatalf("MR page shows %d label chips, want 2:\n%s", n, body)
565 }
566
567 // Removing works the same way.
568 if status, _ := browserPost(t, alice, mrURL+"/label", url.Values{"remove": {"ui"}}); status != 200 {
569 t.Fatalf("label remove: %d", status)
570 }
571 if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json"); strings.Contains(out, `"ui"`) {
572 t.Fatalf("label not removed:\n%s", out)
573 }
574
575 // The list narrows by label, and says which one it is narrowed by.
576 _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs?label=bug")
577 if !strings.Contains(body, ">feature<") || !strings.Contains(body, `label: <span class="chip label"`) {
578 t.Fatalf("web label filter:\n%s", body)
579 }
580 _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs?label=ui")
581 if strings.Contains(body, ">feature<") {
582 t.Fatalf("removed label still lists the merge request:\n%s", body)
583 }
584
585 // The list's column lists the label with its merge request count and a
586 // link that keeps the state (desktop layout spec).
587 _, body = browserGet(t, alice, inst.base()+"/alice/app/mrs")
588 if !strings.Contains(body, `<nav class="sidecol" aria-label="Filters">`) {
589 t.Fatalf("merge request list lacks the side column:\n%s", body)
590 }
591 if !strings.Contains(body, `href="?label=bug&state=open">bug <i>1</i></a>`) {
592 t.Fatalf("merge request column lacks the bug facet:\n%s", body)
593 }
594
595 // A reader gets the chips and no form.
596 _, body = browserGet(t, inst.login(t, bobKey), mrURL)
597 if !strings.Contains(body, `class="chip label"`) {
598 t.Fatalf("reader sees no labels:\n%s", body)
599 }
600 if strings.Contains(body, `/mrs/1/label`) {
601 t.Fatalf("reader sees the label form:\n%s", body)
602 }
603}