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