Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

A free and open-source forge that lives in your Git repo.

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 19m ago 4b8c9e1d
history blame raw 21.1 KiB
1package server_test
2
3// The web has to be enough to review a change: a commit page with the
4// patch it introduced, and a change request that shows what would land.
5
6import (
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11)
12
13func TestCommitPageShowsThePatch(t *testing.T) {
14 t.Parallel()
15 f := newFixture(t)
16 f.publish(f.repo)
17
18 work := workRepo(t, map[string]string{"readme.md": "v1 line\nkeep me\n"})
19 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
20 t.Fatalf("initial push: %s", errs)
21 }
22 if err := os.WriteFile(filepath.Join(work, "readme.md"), []byte("v2 line\nkeep me\nbrand new\n"), 0o644); err != nil {
23 t.Fatal(err)
24 }
25 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
26 t.Fatalf("add: %s", errs)
27 }
28 if _, errs, err := runGit(t, work, "commit", "-qm", "change the readme"); err != nil {
29 t.Fatalf("commit: %s", errs)
30 }
31 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
32 t.Fatalf("second push: %s", errs)
33 }
34 sha := mustSHA(t, work)
35
36 code, body := f.get("/" + f.repo + "/commit/" + sha)
37 if code != 200 {
38 t.Fatalf("commit page = %d", code)
39 }
40 for _, want := range []string{"change the readme", "readme.md", "v2 line", "v1 line", "brand new"} {
41 if !strings.Contains(body, want) {
42 t.Fatalf("commit page does not show %q:\n%s", want, body)
43 }
44 }
45 // Added and removed lines are marked as such, not just printed: the colour
46 // and the numbering both hang off these classes. Each diff also names its
47 // language and the side each line belongs to, so the worker can highlight the
48 // old and new streams without letting one colour the other.
49 for _, want := range []string{
50 `class="add"`, `class="del"`,
51 `data-highlight="markdown"`, `data-highlight-side="old"`, `data-highlight-side="new"`,
52 } {
53 if !strings.Contains(body, want) {
54 t.Fatalf("commit page has no %s marker:\n%s", want, body)
55 }
56 }
57 // The subject links back from the commit list, so the page is reachable by
58 // clicking rather than by knowing a sha.
59 if code, body := f.get("/" + f.repo + "/commits/main"); code != 200 || !strings.Contains(body, "/commit/"+sha) {
60 t.Fatalf("commits page does not link to the commit (%d)", code)
61 }
62 // A sha that does not exist is a 404, not a 500.
63 if code, _ := f.get("/" + f.repo + "/commit/deadbeefdeadbeef"); code != 404 {
64 t.Fatalf("unknown commit = %d, want 404", code)
65 }
66}
67
68func TestRequestPageShowsWhatWouldLand(t *testing.T) {
69 t.Parallel()
70 f := newFixture(t)
71 f.publish(f.repo)
72
73 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
74 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
75 t.Fatalf("main push: %s", errs)
76 }
77
78 feature, head := f.cloneWithChange(t, map[string]string{
79 "readme.md": "v1\nfeature line\n",
80 "docs/nested.md": "nested in a folder\n",
81 }, "feature")
82 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
83 t.Fatalf("feature push: %s", errs)
84 }
85
86 meta := f.newMetaRepo(t)
87 if err := meta.apply(map[string]string{
88 "changes/" + reqID + "/change.md": requestFile("main", head, "keenan", "why this change\n"),
89 }, "request"); err != nil {
90 t.Fatalf("request push: %v", err)
91 }
92
93 code, body := f.get("/" + f.repo + "/changes/" + reqID)
94 if code != 200 {
95 t.Fatalf("request page = %d", code)
96 }
97 for _, want := range []string{"Land the feature", "why this change", "Changes", "View changes"} {
98 if !strings.Contains(body, want) {
99 t.Fatalf("request page does not show %q:\n%s", want, body)
100 }
101 }
102
103 code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files")
104 if code != 200 {
105 t.Fatalf("request files page = %d", code)
106 }
107 for _, want := range []string{"readme.md", "docs/nested.md", `id="review-tree"`} {
108 if !strings.Contains(body, want) {
109 t.Fatalf("request files page does not show %q:\n%s", want, body)
110 }
111 }
112 // The tree opens every folder: a changed file nested in one is part of the
113 // review index, so it should not need unfolding to be found. Clicking still
114 // collapses the folder in the browser.
115 if !strings.Contains(body, `data-path="docs" open`) || !strings.Contains(body, "nested in a folder") {
116 t.Fatalf("the review tree hid a file in a collapsed folder:\n%s", body)
117 }
118 // The Files tab is the approval surface, so it must not need a clone.
119 if strings.Contains(body, "No changes, or the head is not reachable") {
120 t.Fatalf("request files page found no diff for a branch with a change:\n%s", body)
121 }
122 // Each file is a review of its own: the top of the list is selected by default,
123 // and any other row opens the same surface.
124 code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files/readme.md")
125 if code != 200 {
126 t.Fatalf("request file page = %d", code)
127 }
128 for _, want := range []string{"feature line", `class="add"`} {
129 if !strings.Contains(body, want) {
130 t.Fatalf("the readme review does not show %q:\n%s", want, body)
131 }
132 }
133}
134
135// TestMergedRequestStillShowsWhatLanded: landing moves the base to include the head,
136// so the three-dot review of an open request becomes empty — the merge base of the
137// base and the head is the head itself. The record of the base tip is what keeps the
138// change readable afterwards, on both the request page and the Files tab.
139func TestMergedRequestStillShowsWhatLanded(t *testing.T) {
140 t.Parallel()
141 f := newFixture(t)
142 f.publish(f.repo)
143
144 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
145 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
146 t.Fatalf("main push: %s", errs)
147 }
148 baseTip, _, err := runGit(t, base, "rev-parse", "refs/heads/main")
149 if err != nil {
150 t.Fatal(err)
151 }
152 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature line\n"}, "feature")
153 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
154 t.Fatalf("feature push: %s", errs)
155 }
156
157 meta := f.newMetaRepo(t)
158 if err := meta.apply(map[string]string{
159 "changes/" + reqID + "/change.md": requestFile("main", head, "keenan", "why this change\n"),
160 }, "request"); err != nil {
161 t.Fatalf("request push: %v", err)
162 }
163 if _, errs, err := f.cli(feature, "change", "merge", reqID); err != nil {
164 t.Fatalf("merge: %v\n%s", err, errs)
165 }
166
167 // The landing names where the branch started from, in the same commit that says
168 // it landed.
169 record, _, err := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
170 "show", "refs/heads/vole:changes/"+reqID+"/change.md")
171 if err != nil {
172 t.Fatalf("read the record: %v", err)
173 }
174 for _, want := range []string{"state: merged", "merged_base: " + strings.TrimSpace(baseTip)} {
175 if !strings.Contains(record, want) {
176 t.Fatalf("the merged record is missing %q:\n%s", want, record)
177 }
178 }
179
180 code, body := f.get("/" + f.repo + "/changes/" + reqID)
181 if code != 200 {
182 t.Fatalf("merged request page = %d", code)
183 }
184 for _, want := range []string{"1 file(s) changed", "View changes", "merged into <code>main</code> at"} {
185 if !strings.Contains(body, want) {
186 t.Fatalf("merged request page does not show %q:\n%s", want, body)
187 }
188 }
189 if strings.Contains(body, "1 file(s) changed · merged into") {
190 t.Fatalf("the changes panel repeats the merged target the header already gives:\n%s", body)
191 }
192 code, body = f.get("/" + f.repo + "/changes/" + reqID + "/files")
193 if code != 200 {
194 t.Fatalf("merged request files page = %d", code)
195 }
196 for _, want := range []string{"readme.md", "feature line", `class="add"`, `id="review-tree"`} {
197 if !strings.Contains(body, want) {
198 t.Fatalf("merged request files page does not show %q:\n%s", want, body)
199 }
200 }
201 if strings.Contains(body, "No changes, or the head is not reachable") ||
202 strings.Contains(body, "Already merged into main.") {
203 t.Fatalf("a merged request with a recorded base hid its change:\n%s", body)
204 }
205}
206
207// TestRequestRecordsTheBranchItCameFrom: a request names the exact commit under
208// review, and the page shows the commit. The branch it was opened from is worth
209// showing too — it is how people find and fetch the change — as long as the page
210// says so when the branch has moved past that commit. A request opened from a bare
211// commit records no branch, and one written before the field existed still loads.
212func TestRequestRecordsTheBranchItCameFrom(t *testing.T) {
213 t.Parallel()
214 f := newFixture(t)
215 f.publish(f.repo)
216
217 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
218 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
219 t.Fatalf("main push: %s", errs)
220 }
221 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature line\n"}, "feature")
222 // The clone works on the default branch; name the pushed branch locally too, so
223 // --head can name a branch the way a contributor would.
224 if _, errs, err := runGit(t, feature, "branch", "feature"); err != nil {
225 t.Fatalf("branch: %s", errs)
226 }
227 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
228 t.Fatalf("feature push: %s", errs)
229 }
230
231 out, errs, err := f.cli(feature, "change", "new", "--title", "Land the feature",
232 "--base", "main", "--head", "feature", "-m", "why this change")
233 if err != nil {
234 t.Fatalf("change new: %v\n%s", err, errs)
235 }
236 id := strings.Fields(out)[0]
237 recordPath := "refs/heads/vole:changes/" + id + "/change.md"
238 record, _, err := runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"), "show", recordPath)
239 if err != nil {
240 t.Fatalf("read the request: %v", err)
241 }
242 for _, want := range []string{"head: " + head, "head_ref: feature"} {
243 if !strings.Contains(record, want) {
244 t.Fatalf("the request is missing %q:\n%s", want, record)
245 }
246 }
247
248 code, body := f.get("/" + f.repo + "/changes/" + id)
249 if code != 200 {
250 t.Fatalf("request page = %d", code)
251 }
252 if !strings.Contains(body, ">feature</code>") {
253 t.Fatalf("the request page does not name the branch it came from:\n%s", body)
254 }
255 _, body = f.get("/" + f.repo + "/changes")
256 if !strings.Contains(body, ">feature</code>") {
257 t.Fatalf("the request list does not name the branch it came from:\n%s", body)
258 }
259 out, _, err = f.cli(feature, "change", "show", id)
260 if err != nil {
261 t.Fatalf("change show: %v", err)
262 }
263 if !strings.Contains(out, "branch: feature") {
264 t.Fatalf("change show does not name the branch:\n%s", out)
265 }
266
267 // A push to the followed branch moves the request with it: the page shows the
268 // new tip as what would land, not the old one with a "branch moved" note.
269 if err := os.WriteFile(filepath.Join(feature, "readme.md"), []byte("v1\nfeature line\nand more\n"), 0o644); err != nil {
270 t.Fatal(err)
271 }
272 if _, errs, err := runGit(t, feature, "commit", "-qam", "fixup"); err != nil {
273 t.Fatalf("commit: %s", errs)
274 }
275 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
276 t.Fatalf("fixup push: %s", errs)
277 }
278 second, _, err := runGit(t, feature, "rev-parse", "HEAD")
279 if err != nil {
280 t.Fatal(err)
281 }
282 second = strings.TrimSpace(second)
283 if second == head {
284 t.Fatalf("the fixup did not create a new tip")
285 }
286 record, _, err = runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
287 "show", "refs/heads/vole:changes/"+id+"/change.md")
288 if err != nil {
289 t.Fatalf("read the followed request: %v", err)
290 }
291 if !strings.Contains(record, "head: "+second) {
292 t.Fatalf("the request did not follow the branch to %s:\n%s", second, record)
293 }
294 _, body = f.get("/" + f.repo + "/changes/" + id)
295 if !strings.Contains(body, "/commit/"+second) {
296 t.Fatalf("the page does not show the followed tip %s:\n%s", second, body)
297 }
298
299 // A branch that is gone is still the branch the request follows, and the page
300 // says so instead of dropping the name; the last tip it saw stays the head.
301 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), ":refs/heads/feature"); err != nil {
302 t.Fatalf("delete branch: %s", errs)
303 }
304 _, body = f.get("/" + f.repo + "/changes/" + id)
305 if !strings.Contains(body, "branch deleted") || !strings.Contains(body, ">feature</code>") ||
306 !strings.Contains(body, "/commit/"+second) {
307 t.Fatalf("a deleted branch is not labelled, or its last head is gone:\n%s", body)
308 }
309
310 // A raw commit is a head with no branch behind it.
311 out, errs, err = f.cli(feature, "change", "new", "--title", "From a commit",
312 "--base", "main", "--head", head, "-m", "why this change")
313 if err != nil {
314 t.Fatalf("change new from a commit: %v\n%s", err, errs)
315 }
316 commitID := strings.Fields(out)[0]
317 record, _, err = runGit(t, filepath.Join(f.cfg.ReposDir(), f.repo+".git"),
318 "show", "refs/heads/vole:changes/"+commitID+"/change.md")
319 if err != nil {
320 t.Fatalf("read the commit request: %v", err)
321 }
322 if strings.Contains(record, "head_ref:") {
323 t.Fatalf("a request opened from a bare commit recorded a branch:\n%s", record)
324 }
325}
326
327// TestCommitPageEscapesFileContent matters because the web renders other people's
328// commits: a diff that executes a tag is an XSS, not a syntax error.
329func TestCommitPageEscapesFileContent(t *testing.T) {
330 t.Parallel()
331 f := newFixture(t)
332 f.publish(f.repo)
333 work := workRepo(t, map[string]string{"evil.html": "safe\n"})
334 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
335 t.Fatalf("first push: %s", errs)
336 }
337 if err := os.WriteFile(filepath.Join(work, "evil.html"), []byte("<script>alert(1)</script>\n"), 0o644); err != nil {
338 t.Fatal(err)
339 }
340 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
341 t.Fatal(errs)
342 }
343 if _, errs, err := runGit(t, work, "commit", "-qm", "add a tag"); err != nil {
344 t.Fatal(errs)
345 }
346 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
347 t.Fatalf("push: %s", errs)
348 }
349 _, body := f.get("/" + f.repo + "/commit/" + mustSHA(t, work))
350 if strings.Contains(body, "<script>alert(1)</script>") {
351 t.Fatalf("the diff put live markup into the page:\n%s", body)
352 }
353 if !strings.Contains(body, "&lt;script&gt;alert(1)&lt;/script&gt;") {
354 t.Fatalf("the diff does not show the line escaped:\n%s", body)
355 }
356}
357
358// TestReadmeIsTheProjectOverview: the project page has the README, while the
359// chronological feed has its own tab.
360func TestReadmeIsTheProjectOverview(t *testing.T) {
361 t.Parallel()
362 f := newFixture(t)
363 f.publish(f.repo)
364 work := workRepo(t, map[string]string{
365 "README.md": "# Demo project\n\nHow to run it.\n",
366 "main.go": "package main\n",
367 })
368 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
369 t.Fatalf("push: %s", errs)
370 }
371 code, body := f.get("/" + f.repo)
372 if code != 200 {
373 t.Fatalf("overview page = %d", code)
374 }
375 if !strings.Contains(body, "<h1>Demo project</h1>") || !strings.Contains(body, "How to run it.") {
376 t.Fatalf("README was not rendered on the overview page:\n%s", body)
377 }
378 // The feed has its own tab, and the code tab still renders the README too.
379 if code, feed := f.get("/" + f.repo + "/activity"); code != 200 || !strings.Contains(feed, "Activity") {
380 t.Fatalf("repo activity page = %d", code)
381 }
382 if code, codePage := f.get("/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "Demo project") {
383 t.Fatalf("code page = %d", code)
384 }
385 // Markdown opens as source like any other file; rendering is an explicit
386 // preview choice, not the default.
387 if code, body := f.get("/" + f.repo + "/blob/main/README.md"); code != 200 ||
388 !strings.Contains(body, `data-highlight="markdown"`) || strings.Contains(body, "<h1>Demo project</h1>") {
389 t.Fatalf("markdown source blob = %d:\n%s", code, body)
390 }
391 if code, body := f.get("/" + f.repo + "/blob/main/README.md?preview=1"); code != 200 ||
392 !strings.Contains(body, "<h1>Demo project</h1>") {
393 t.Fatalf("markdown preview blob = %d:\n%s", code, body)
394 }
395 // The blob names its language and the browser colours it, so the server sends
396 // no token spans; what matters is that it is code, not rendered markdown.
397 if code, body := f.get("/" + f.repo + "/blob/main/main.go"); code != 200 ||
398 !strings.Contains(body, `data-highlight="go"`) || !strings.Contains(body, "package main") ||
399 strings.Contains(body, `class="token`) {
400 t.Fatalf("source blob = %d", code)
401 }
402}
403
404// TestReadmeIsSanitised: a README is pushed content, so it crosses the same
405// boundary as an issue body.
406func TestReadmeIsSanitised(t *testing.T) {
407 t.Parallel()
408 f := newFixture(t)
409 f.publish(f.repo)
410 work := workRepo(t, map[string]string{
411 "README.md": "# hi\n\n<script>alert(1)</script>\n",
412 })
413 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
414 t.Fatalf("push: %s", errs)
415 }
416 _, body := f.get("/" + f.repo)
417 if strings.Contains(body, "<script>alert(1)</script>") {
418 t.Fatalf("rendered README executed a tag:\n%s", body)
419 }
420}
421
422// TestReadmeRelativeLinksOpenTheFileTheyName is the bug this fixes: a relative
423// link in a README resolves against the page it is rendered on, so `/demo` sent
424// `docs/guide.md` to `/demo/docs/guide.md`. It has to resolve against the
425// document instead, on both surfaces that render the README, and a document in a
426// subdirectory has to resolve its own links the same way.
427func TestReadmeRelativeLinksOpenTheFileTheyName(t *testing.T) {
428 t.Parallel()
429 f := newFixture(t)
430 f.publish(f.repo)
431 work := workRepo(t, map[string]string{
432 "README.md": "# Demo\n\n" +
433 "[guide](docs/guide.md) [terms](LICENSE) [workflow](.vole/workflows/verify.yaml)\n\n" +
434 "[site](https://example.com/x) [top](#demo) [dir](docs/)\n\n" +
435 "![shot](img/shot.png)\n",
436 "docs/guide.md": "# Guide\n\n[back](../README.md) ![local](shot.png)\n",
437 "LICENSE": "terms\n",
438 "img/shot.png": "not really a png\n",
439 })
440 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
441 t.Fatalf("push: %s", errs)
442 }
443
444 want := []string{
445 `href="/demo/blob/main/docs/guide.md?preview=1"`,
446 `href="/demo/blob/main/LICENSE"`,
447 `href="/demo/blob/main/.vole/workflows/verify.yaml"`,
448 `href="/demo/tree/main/docs"`,
449 `href="https://example.com/x"`,
450 `href="#demo"`,
451 `src="/demo/raw/main/img/shot.png"`,
452 }
453 for _, page := range []string{"/" + f.repo, "/" + f.repo + "/code"} {
454 code, body := f.get(page)
455 if code != 200 {
456 t.Fatalf("%s = %d", page, code)
457 }
458 for _, w := range want {
459 if !strings.Contains(body, w) {
460 t.Fatalf("%s does not contain %s:\n%s", page, w, body)
461 }
462 }
463 }
464
465 // A document below the root resolves against its own directory.
466 code, body := f.get("/" + f.repo + "/blob/main/docs/guide.md?preview=1")
467 if code != 200 {
468 t.Fatalf("guide preview = %d", code)
469 }
470 for _, w := range []string{
471 `href="/demo/blob/main/README.md?preview=1"`,
472 `src="/demo/raw/main/docs/shot.png"`,
473 } {
474 if !strings.Contains(body, w) {
475 t.Fatalf("guide preview does not contain %s:\n%s", w, body)
476 }
477 }
478
479 // The source view shows the file's bytes, so the link stays as it was written
480 // there rather than becoming the URL the preview would use.
481 if _, body := f.get("/" + f.repo + "/blob/main/README.md"); !strings.Contains(body, "docs/guide.md") ||
482 strings.Contains(body, `href="/demo/blob/main/docs/guide.md?preview=1"`) {
483 t.Fatalf("the source view is not the file as written:\n%s", body)
484 }
485}
486
487// TestChangeListFiltersMergedRequests: merged is its own state, not a synonym for
488// closed. The page offers a filter and a count for it, and the Closed filter does
489// not quietly include landings.
490func TestChangeListFiltersMergedRequests(t *testing.T) {
491 t.Parallel()
492 f := newFixture(t)
493 f.publish(f.repo)
494
495 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
496 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
497 t.Fatalf("main push: %s", errs)
498 }
499 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature line\n"}, "feature")
500 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
501 t.Fatalf("feature push: %s", errs)
502 }
503
504 const (
505 openID = "01M5JR2JK29KKMAA1T9MDNTR02"
506 mergedID = "01M5JR2JK29KKMAA1T9MDNTR03"
507 )
508 meta := f.newMetaRepo(t)
509 if err := meta.apply(map[string]string{
510 "changes/" + openID + "/change.md": requestFileTitled("Still open", "main", head, "keenan", "open body\n"),
511 "changes/" + mergedID + "/change.md": requestFileTitled("Already landed", "main", head, "keenan", "merged body\n"),
512 }, "requests"); err != nil {
513 t.Fatalf("requests push: %v", err)
514 }
515 if _, errs, err := f.cli(feature, "change", "merge", mergedID); err != nil {
516 t.Fatalf("merge: %v\n%s", err, errs)
517 }
518
519 code, body := f.get("/" + f.repo + "/changes?state=merged")
520 if code != 200 {
521 t.Fatalf("merged list = %d", code)
522 }
523 if !strings.Contains(body, "Already landed") || strings.Contains(body, "Still open") {
524 t.Fatalf("the Merged filter did not narrow to merged requests:\n%s", body)
525 }
526 if !strings.Contains(body, `class="row-status merged"`) {
527 t.Fatalf("the merged row has no merged status mark:\n%s", body)
528 }
529 for _, want := range []string{
530 `>Merged <span class="chip-count">1</span>`,
531 `>Open <span class="chip-count">1</span>`,
532 `>Closed <span class="chip-count">0</span>`,
533 } {
534 if !strings.Contains(body, want) {
535 t.Fatalf("the change list does not show %q:\n%s", want, body)
536 }
537 }
538
539 // Closed means closed, not "no longer open": a landing is reachable through
540 // its own filter and is not counted twice.
541 code, body = f.get("/" + f.repo + "/changes?state=closed")
542 if code != 200 {
543 t.Fatalf("closed list = %d", code)
544 }
545 if strings.Contains(body, "Already landed") {
546 t.Fatalf("the Closed filter included a merged request:\n%s", body)
547 }
548}
549