e2e/mrweb_test.go

main
gitbay/e2e/mrweb_test.go history · blame · raw

604 lines · 25555 bytes

10 symbols in this file
  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", "ext/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&amp;cline=6&amp;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	// The field's placeholder is "!N", so the "!" is accepted.
487	if status, body := browserPost(t, alice, inst.base()+"/alice/app/mrs/1/close", url.Values{"by": {"!2"}}); status != 200 {
488		t.Fatalf("close post: %d\n%s", status, body)
489	}
490
491	_, body1 := browserGet(t, alice, inst.base()+"/alice/app/mrs/1")
492	if !strings.Contains(body1, `in favour of <a href="/alice/app/mrs/2">!2</a>`) {
493		t.Fatalf("!1 does not say it was superseded:\n%s", body1)
494	}
495	_, body2 := browserGet(t, alice, inst.base()+"/alice/app/mrs/2")
496	if !strings.Contains(body2, `supersedes <a href="/alice/app/mrs/1">!1</a>`) {
497		t.Fatalf("!2 does not say what it supersedes:\n%s", body2)
498	}
499
500	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "edit", "alice/app", "1", "--superseded-by", "none"); code != 0 {
501		t.Fatalf("mr edit --superseded-by none: %s", errOut)
502	}
503	_, body1 = browserGet(t, alice, inst.base()+"/alice/app/mrs/1")
504	if strings.Contains(body1, "in favour of") {
505		t.Fatalf("!1 still says it was superseded after clearing:\n%s", body1)
506	}
507	_, body2 = browserGet(t, alice, inst.base()+"/alice/app/mrs/2")
508	if strings.Contains(body2, "supersedes") {
509		t.Fatalf("!2 still says it supersedes after clearing:\n%s", body2)
510	}
511}
512
513// TestMRWebLabels labels a merge request from the browser and filters the
514// list by it (#231). The CLI is the check that the page dispatched
515// mr label rather than writing its own rows.
516func TestMRWebLabels(t *testing.T) {
517	t.Parallel()
518	inst := startInstanceWith(t, "[web]\nmode = \"accounts\"\n")
519	aliceKey := inst.newKey(t, "alice")
520	bobKey := inst.newKey(t, "bob")
521	inst.admin(t, "admin", "user", "create", "alice",
522		"--key", aliceKey+".pub", "--email", "alice@example.test", "--verified")
523	inst.admin(t, "admin", "user", "create", "bob",
524		"--key", bobKey+".pub", "--email", "bob@example.test", "--verified")
525	if _, errOut, code := inst.ssh(t, aliceKey, "", "repo", "create", "alice/app"); code != 0 {
526		t.Fatalf("repo create: %s", errOut)
527	}
528	env := inst.gitEnv(aliceKey)
529	work := t.TempDir()
530	mustGit(t, work, env, "clone", inst.sshURL("alice/app"), "w")
531	dir := filepath.Join(work, "w")
532	os.WriteFile(filepath.Join(dir, "a.txt"), []byte("a\n"), 0o644)
533	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
534	mustGit(t, dir, env, "add", ".")
535	mustGit(t, dir, env, "commit", "-q", "-m", "base")
536	mustGit(t, dir, env, "push", "-q", "origin", "main")
537	mustGit(t, dir, env, "checkout", "-q", "-b", "topic")
538	os.WriteFile(filepath.Join(dir, "b.txt"), []byte("b\n"), 0o644)
539	mustGit(t, dir, env, "add", ".")
540	mustGit(t, dir, env, "commit", "-q", "-m", "topic work")
541	mustGit(t, dir, env, "push", "-q", "origin", "topic")
542	if _, errOut, code := inst.ssh(t, aliceKey, "", "mr", "create", "alice/app",
543		"--source", "topic", "--target", "main", "--title", "feature"); code != 0 {
544		t.Fatalf("mr create: %s", errOut)
545	}
546
547	alice := inst.login(t, aliceKey)
548	mrURL := inst.base() + "/alice/app/mrs/1"
549
550	// The form is on the page, and applying it lands in the CLI's view.
551	if _, body := browserGet(t, alice, mrURL); !strings.Contains(body, `/mrs/1/label`) {
552		t.Fatalf("MR page has no label form:\n%s", body)
553	}
554	if status, _ := browserPost(t, alice, mrURL+"/label", url.Values{"add": {"bug ui"}}); status != 200 {
555		t.Fatalf("label post: %d", status)
556	}
557	out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json")
558	for _, want := range []string{`"bug"`, `"ui"`} {
559		if !strings.Contains(out, want) {
560			t.Fatalf("label %s did not land:\n%s", want, out)
561		}
562	}
563	_, body := browserGet(t, alice, mrURL)
564	if n := strings.Count(body, `class="chip label"`); n != 2 {
565		t.Fatalf("MR page shows %d label chips, want 2:\n%s", n, body)
566	}
567
568	// Removing works the same way.
569	if status, _ := browserPost(t, alice, mrURL+"/label", url.Values{"remove": {"ui"}}); status != 200 {
570		t.Fatalf("label remove: %d", status)
571	}
572	if out, _, _ := inst.ssh(t, aliceKey, "", "mr", "show", "alice/app", "1", "--json"); strings.Contains(out, `"ui"`) {
573		t.Fatalf("label not removed:\n%s", out)
574	}
575
576	// The list narrows by label, and says which one it is narrowed by.
577	_, body = browserGet(t, alice, inst.base()+"/alice/app/mrs?label=bug")
578	if !strings.Contains(body, ">feature<") || !strings.Contains(body, `label: <span class="chip label"`) {
579		t.Fatalf("web label filter:\n%s", body)
580	}
581	_, body = browserGet(t, alice, inst.base()+"/alice/app/mrs?label=ui")
582	if strings.Contains(body, ">feature<") {
583		t.Fatalf("removed label still lists the merge request:\n%s", body)
584	}
585
586	// The list's column lists the label with its merge request count and a
587	// link that keeps the state (desktop layout spec).
588	_, body = browserGet(t, alice, inst.base()+"/alice/app/mrs")
589	if !strings.Contains(body, `<nav class="sidecol" aria-label="Filters">`) {
590		t.Fatalf("merge request list lacks the side column:\n%s", body)
591	}
592	if !strings.Contains(body, `href="?label=bug&amp;state=open">bug <i>1</i></a>`) {
593		t.Fatalf("merge request column lacks the bug facet:\n%s", body)
594	}
595
596	// A reader gets the chips and no form.
597	_, body = browserGet(t, inst.login(t, bobKey), mrURL)
598	if !strings.Contains(body, `class="chip label"`) {
599		t.Fatalf("reader sees no labels:\n%s", body)
600	}
601	if strings.Contains(body, `/mrs/1/label`) {
602		t.Fatalf("reader sees the label form:\n%s", body)
603	}
604}