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.

Serve repositories from the root of the URL space

ed03b3d1fa16ef6b55e2773aaf10e3be13282c3a · Keenan Nemetz · 3h ago · parent d37d301f

Serve repositories from the root of the URL space

A repository is now /<name> and its Git transport /<name>.git, so the URL a
person opens, the URL a clone uses, and the Go module path are the same name.
The forge's own pages moved under /-/ (admin, accounts, the directory, the
feed, sign-in, assets), a prefix no repository name can start with: names are
[a-z0-9...] and cannot begin with "-". That leaves the router with two disjoint
spaces and removes the /r/ and /git/ prefixes, which existed only to keep
repositories away from the forge's own paths.

Routes: repoRoutes lose /r/; global, auth and admin routes gain /-/; assets
move to /-/static/ because a subtree pattern like /static/ cannot coexist with
one-segment-wildcard repo routes — Go's mux reports them as conflicting
neither-more-specific patterns. The catch-all still renders the styled 404.

Transport: githttp.Route parses /<name>.git/<rest> instead of a /git/ prefix,
and forgepaths.go hands such requests to the proxy before the mux, because a
wildcard fills a whole segment and cannot match "<name>.git". LFS stays on the
mux (<name>.git/info/lfs/...), and the Go module discovery probe moves to the
same pre-router so a package path under a public repository is answered even
when its first segments name a page tab.

Names: repo.ValidateName now refuses the four root paths the forge must keep —
static, robots.txt, sitemap.xml, sitemap-pages.xml. Everything else is an
ordinary repository name, including admin, u, and activity.

Docs and the packaged URLs follow the new layout; robots.txt, the sitemaps and
the instance activity feed point at the new paths. No aliases: the old /r/ and
/git/ URLs are gone.

Verified with ./vole workflow check and ./vole workflow run verify.

server/issues_changes_test.go +147−147

@@ -26,42 +26,42 @@ func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) {
26 26 f.publish(f.repo)
27 27 sess, cookie := f.browserSession(t, f.ownerID)
28 28
29 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
29 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
30 30 "csrf": {sess.CSRF}, "title": {"Close me from the web"}, "body": {"body\n"},
31 31 })
32 32 if resp.StatusCode != http.StatusSeeOther {
33 33 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
34 34 }
35 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
35 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
36 36 if id == "" {
37 37 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
38 38 }
39 39
40 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
40 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
41 41 "csrf": {sess.CSRF}, "state": {"closed"},
42 42 })
43 43 if resp.StatusCode != http.StatusSeeOther {
44 44 t.Fatalf("close issue: %d\n%s", resp.StatusCode, body)
45 45 }
46 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
46 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
47 47 if !strings.Contains(page, "Reopen</button>") || !strings.Contains(page, `class="state closed"`) {
48 48 t.Fatalf("closed issue does not offer a reopen:\n%s", page)
49 49 }
50 50 // The default list is open issues; the closed one is behind the All filter.
51 if _, list := f.browserGet(t, "/r/"+f.repo+"/issues", cookie); strings.Contains(list, "Close me from the web") {
51 if _, list := f.browserGet(t, "/"+f.repo+"/issues", cookie); strings.Contains(list, "Close me from the web") {
52 52 t.Fatalf("closed issue is still in the open list:\n%s", list)
53 53 }
54 if _, list := f.browserGet(t, "/r/"+f.repo+"/issues?state=all", cookie); !strings.Contains(list, "Close me from the web") {
54 if _, list := f.browserGet(t, "/"+f.repo+"/issues?state=all", cookie); !strings.Contains(list, "Close me from the web") {
55 55 t.Fatalf("closed issue is missing from the All list:\n%s", list)
56 56 }
57 57
58 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
58 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
59 59 "csrf": {sess.CSRF}, "state": {"open"},
60 60 })
61 61 if resp.StatusCode != http.StatusSeeOther {
62 62 t.Fatalf("reopen issue: %d\n%s", resp.StatusCode, body)
63 63 }
64 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close</button>") ||
64 if _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close</button>") ||
65 65 !strings.Contains(page, `class="danger"`) {
66 66 t.Fatalf("reopened issue does not offer a close:\n%s", page)
67 67 }
@@ -90,29 +90,29 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
90 90
91 91 // An issue: the page offers Edit, the form carries what the issue says, and
92 92 // saving returns to the issue with the new prose.
93 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
93 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
94 94 "csrf": {sess.CSRF}, "title": {"First draft"}, "body": {"original body\n"},
95 95 })
96 96 if resp.StatusCode != http.StatusSeeOther {
97 97 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
98 98 }
99 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
99 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
100 100 if issueID == "" {
101 101 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
102 102 }
103 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
103 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
104 104 if !strings.Contains(page, "data-title-edit") || !strings.Contains(page, "data-title-edit-form") ||
105 105 !strings.Contains(page, `data-comment-menu`) || !strings.Contains(page, "Copy text") ||
106 106 !strings.Contains(page, "data-comment-edit") || !strings.Contains(page, `name="field" value="title"`) ||
107 107 !strings.Contains(page, `name="field" value="body"`) || !strings.Contains(page, "data-autosize") {
108 108 t.Fatalf("the issue page offers no in-place title and description controls:\n%s", page)
109 109 }
110 resp, form := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie)
111 if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/r/`+f.repo+`/issues/`+issueID+`/edit"`) ||
110 resp, form := f.browserGet(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie)
111 if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/`+f.repo+`/issues/`+issueID+`/edit"`) ||
112 112 !strings.Contains(form, `value="First draft"`) || !strings.Contains(form, "original body") {
113 113 t.Fatalf("issue edit page = %d:\n%s", resp.StatusCode, form)
114 114 }
115 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
115 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
116 116 "csrf": {sess.CSRF}, "title": {"Corrected issue"}, "body": {"corrected issue body\n"},
117 117 })
118 118 if resp.StatusCode != http.StatusSeeOther {
@@ -125,14 +125,14 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
125 125 if iss.Title != "Corrected issue" || strings.TrimSpace(iss.Description) != "corrected issue body" {
126 126 t.Fatalf("edited issue = %q / %q", iss.Title, iss.Description)
127 127 }
128 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie); !strings.Contains(page, "Corrected issue") ||
128 if _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie); !strings.Contains(page, "Corrected issue") ||
129 129 !strings.Contains(page, "corrected issue body") {
130 130 t.Fatalf("the issue page does not show the edit:\n%s", page)
131 131 }
132 132
133 133 // The title pencil posts only the title. Its response is the heading, not the
134 134 // description, so a description draft is left alone.
135 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
135 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
136 136 "csrf": {sess.CSRF}, "field": {"title"}, "title": {"In-place issue"},
137 137 })
138 138 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
@@ -147,7 +147,7 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
147 147
148 148 // The description menu posts only the description, and comes back as the
149 149 // description card with the title untouched.
150 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
150 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
151 151 "csrf": {sess.CSRF}, "field": {"body"}, "body": {"in-place issue body\n"},
152 152 })
153 153 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
@@ -162,7 +162,7 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
162 162
163 163 // A refused title keeps the title form open with what was typed; the
164 164 // description card is not part of the response.
165 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
165 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
166 166 "csrf": {sess.CSRF}, "field": {"title"}, "title": {""},
167 167 })
168 168 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "an issue needs a title") ||
@@ -172,18 +172,18 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
172 172 }
173 173
174 174 // A change: same controls, and the same writer.
175 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{
175 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
176 176 "csrf": {sess.CSRF}, "title": {"First change draft"}, "body": {"original change body\n"},
177 177 "base": {"main"}, "head": {"feature"},
178 178 })
179 179 if resp.StatusCode != http.StatusSeeOther {
180 180 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
181 181 }
182 changeID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
182 changeID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
183 183 if changeID == "" {
184 184 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
185 185 }
186 _, page = f.browserGet(t, "/r/"+f.repo+"/changes/"+changeID, cookie)
186 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+changeID, cookie)
187 187 if !strings.Contains(page, "data-title-edit") || !strings.Contains(page, "data-title-edit-form") ||
188 188 !strings.Contains(page, `data-comment-menu`) || !strings.Contains(page, "Copy text") ||
189 189 !strings.Contains(page, "data-comment-edit") || !strings.Contains(page, `name="field" value="title"`) ||
@@ -192,16 +192,16 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
192 192 }
193 193 // The Files tab reuses the change head but has no description form there, so
194 194 // its title pencil must keep the ordinary link to the standalone page.
195 if _, files := f.browserGet(t, "/r/"+f.repo+"/changes/"+changeID+"/files", cookie); !strings.Contains(files, "/changes/"+changeID+"/edit") ||
195 if _, files := f.browserGet(t, "/"+f.repo+"/changes/"+changeID+"/files", cookie); !strings.Contains(files, "/changes/"+changeID+"/edit") ||
196 196 strings.Contains(files, "data-title-edit") {
197 197 t.Fatalf("the Files tab does not link to the edit page:\n%s", files)
198 198 }
199 resp, form = f.browserGet(t, "/r/"+f.repo+"/changes/"+changeID+"/edit", cookie)
200 if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/r/`+f.repo+`/changes/`+changeID+`/edit"`) ||
199 resp, form = f.browserGet(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie)
200 if resp.StatusCode != http.StatusOK || !strings.Contains(form, `action="/`+f.repo+`/changes/`+changeID+`/edit"`) ||
201 201 !strings.Contains(form, `value="First change draft"`) || !strings.Contains(form, "original change body") {
202 202 t.Fatalf("change edit page = %d:\n%s", resp.StatusCode, form)
203 203 }
204 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
204 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
205 205 "csrf": {sess.CSRF}, "title": {"Corrected change"}, "body": {"corrected change body\n"},
206 206 })
207 207 if resp.StatusCode != http.StatusSeeOther {
@@ -214,12 +214,12 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
214 214 if req.Title != "Corrected change" || strings.TrimSpace(req.Description) != "corrected change body" {
215 215 t.Fatalf("edited change = %q / %q", req.Title, req.Description)
216 216 }
217 if _, page := f.browserGet(t, "/r/"+f.repo+"/changes/"+changeID, cookie); !strings.Contains(page, "Corrected change") ||
217 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+changeID, cookie); !strings.Contains(page, "Corrected change") ||
218 218 !strings.Contains(page, "corrected change body") {
219 219 t.Fatalf("the change page does not show the edit:\n%s", page)
220 220 }
221 221
222 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
222 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
223 223 "csrf": {sess.CSRF}, "field": {"title"}, "title": {"In-place change"},
224 224 })
225 225 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
@@ -231,7 +231,7 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
231 231 req.Title != "In-place change" || strings.TrimSpace(req.Description) != "corrected change body" {
232 232 t.Fatalf("inline change title edit = %+v, %v", req, err)
233 233 }
234 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
234 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
235 235 "csrf": {sess.CSRF}, "field": {"body"}, "body": {"in-place change body\n"},
236 236 })
237 237 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
@@ -243,7 +243,7 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
243 243 req.Title != "In-place change" || strings.TrimSpace(req.Description) != "in-place change body" {
244 244 t.Fatalf("inline change description edit = %+v, %v", req, err)
245 245 }
246 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
246 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
247 247 "csrf": {sess.CSRF}, "field": {"title"}, "title": {""},
248 248 })
249 249 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
@@ -261,16 +261,16 @@ func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
261 261 }
262 262 strangerSess, strangerCookie := f.browserSession(t, stranger.Name)
263 263 for _, path := range []string{
264 "/r/" + f.repo + "/issues/" + issueID + "/edit",
265 "/r/" + f.repo + "/changes/" + changeID + "/edit",
264 "/" + f.repo + "/issues/" + issueID + "/edit",
265 "/" + f.repo + "/changes/" + changeID + "/edit",
266 266 } {
267 267 if resp, _ := f.browserGet(t, path, strangerCookie); resp.StatusCode != http.StatusForbidden {
268 268 t.Fatalf("stranger got %d from %s, want 403", resp.StatusCode, path)
269 269 }
270 270 }
271 271 for _, path := range []string{
272 "/r/" + f.repo + "/issues/" + issueID + "/edit",
273 "/r/" + f.repo + "/changes/" + changeID + "/edit",
272 "/" + f.repo + "/issues/" + issueID + "/edit",
273 "/" + f.repo + "/changes/" + changeID + "/edit",
274 274 } {
275 275 resp, _ := f.browserPost(t, path, strangerCookie, url.Values{
276 276 "csrf": {strangerSess.CSRF}, "title": {"Not mine"}, "body": {"not mine\n"},
@@ -296,52 +296,52 @@ func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) {
296 296 }
297 297
298 298 sess, cookie := f.browserSession(t, f.ownerID)
299 resp, body := f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{
299 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
300 300 "csrf": {sess.CSRF}, "title": {"Browser change"}, "body": {"please review\n"},
301 301 "base": {"main"}, "head": {"feature"},
302 302 })
303 303 if resp.StatusCode != http.StatusSeeOther {
304 304 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
305 305 }
306 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
306 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
307 307 if id == "" {
308 308 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
309 309 }
310 310
311 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
311 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
312 312 "csrf": {sess.CSRF}, "body": {"Looks good from the browser.\n"},
313 313 })
314 314 if resp.StatusCode != http.StatusSeeOther {
315 315 t.Fatalf("comment: %d\n%s", resp.StatusCode, body)
316 316 }
317 _, page := f.browserGet(t, "/r/"+f.repo+"/changes/"+id, cookie)
317 _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
318 318 if !strings.Contains(page, "Looks good from the browser.") {
319 319 t.Fatalf("the browser comment is not on the page:\n%s", page)
320 320 }
321 321
322 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
322 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
323 323 "csrf": {sess.CSRF}, "state": {"closed"},
324 324 })
325 325 if resp.StatusCode != http.StatusSeeOther {
326 326 t.Fatalf("close change: %d\n%s", resp.StatusCode, body)
327 327 }
328 if _, list := f.browserGet(t, "/r/"+f.repo+"/changes", cookie); strings.Contains(list, "Browser change") {
328 if _, list := f.browserGet(t, "/"+f.repo+"/changes", cookie); strings.Contains(list, "Browser change") {
329 329 t.Fatalf("closed change is still in the open list:\n%s", list)
330 330 }
331 if _, list := f.browserGet(t, "/r/"+f.repo+"/changes?state=all", cookie); !strings.Contains(list, "Browser change") {
331 if _, list := f.browserGet(t, "/"+f.repo+"/changes?state=all", cookie); !strings.Contains(list, "Browser change") {
332 332 t.Fatalf("closed change is missing from the All list:\n%s", list)
333 333 }
334 if _, page := f.browserGet(t, "/r/"+f.repo+"/changes/"+id, cookie); !strings.Contains(page, "Reopen</button>") {
334 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie); !strings.Contains(page, "Reopen</button>") {
335 335 t.Fatalf("closed change does not offer a reopen:\n%s", page)
336 336 }
337 337
338 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
338 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
339 339 "csrf": {sess.CSRF}, "state": {"open"},
340 340 })
341 341 if resp.StatusCode != http.StatusSeeOther {
342 342 t.Fatalf("reopen change: %d\n%s", resp.StatusCode, body)
343 343 }
344 if _, page := f.browserGet(t, "/r/"+f.repo+"/changes/"+id, cookie); !strings.Contains(page, "Close</button>") ||
344 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie); !strings.Contains(page, "Close</button>") ||
345 345 !strings.Contains(page, `class="danger"`) {
346 346 t.Fatalf("reopened change does not offer a close:\n%s", page)
347 347 }
@@ -361,8 +361,8 @@ func TestNewIssueAndChangePages(t *testing.T) {
361 361 }
362 362 sess, cookie := f.browserSession(t, f.ownerID)
363 363
364 resp, body := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie)
365 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/r/`+f.repo+`/issues"`) || !strings.Contains(body, "Open issue") {
364 resp, body := f.browserGet(t, "/"+f.repo+"/issues/new", cookie)
365 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/`+f.repo+`/issues"`) || !strings.Contains(body, "Open issue") {
366 366 t.Fatalf("new issue page = %d:\n%s", resp.StatusCode, body)
367 367 }
368 368 // The CSRF token is on the form, not in a popover that has to be opened first.
@@ -370,17 +370,17 @@ func TestNewIssueAndChangePages(t *testing.T) {
370 370 t.Fatal("new issue page has no CSRF token")
371 371 }
372 372
373 resp, body = f.browserGet(t, "/r/"+f.repo+"/changes/new", cookie)
374 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/r/`+f.repo+`/changes"`) ||
373 resp, body = f.browserGet(t, "/"+f.repo+"/changes/new", cookie)
374 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/`+f.repo+`/changes"`) ||
375 375 !strings.Contains(body, "feature") || !strings.Contains(body, "Open change") {
376 376 t.Fatalf("new change page = %d:\n%s", resp.StatusCode, body)
377 377 }
378 378
379 379 // The list page links to the pages instead of embedding the forms.
380 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues", cookie); !strings.Contains(page, "/issues/new") {
380 if _, page := f.browserGet(t, "/"+f.repo+"/issues", cookie); !strings.Contains(page, "/issues/new") {
381 381 t.Fatal("issues list does not link to the new issue page")
382 382 }
383 if _, page := f.browserGet(t, "/r/"+f.repo+"/changes", cookie); !strings.Contains(page, "/changes/new") {
383 if _, page := f.browserGet(t, "/"+f.repo+"/changes", cookie); !strings.Contains(page, "/changes/new") {
384 384 t.Fatal("changes list does not link to the new change page")
385 385 }
386 386 }
@@ -391,14 +391,14 @@ func TestIssueLabelsCanBeSetAndEditedInTheBrowser(t *testing.T) {
391 391 f.publish(f.repo)
392 392 sess, cookie := f.browserSession(t, f.ownerID)
393 393
394 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
394 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
395 395 "csrf": {sess.CSRF}, "title": {"Labels from the browser"}, "body": {"body\n"},
396 396 "label": {"bug", "web", "perf", "docs"},
397 397 })
398 398 if resp.StatusCode != http.StatusSeeOther {
399 399 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
400 400 }
401 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
401 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
402 402 if id == "" {
403 403 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
404 404 }
@@ -412,18 +412,18 @@ func TestIssueLabelsCanBeSetAndEditedInTheBrowser(t *testing.T) {
412 412
413 413 // The detail page seeds the token input with the current labels and offers a
414 414 // save form. The browser turns that value into chips.
415 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
415 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
416 416 for _, want := range []string{`hx-trigger="token-change"`, `value="bug, docs, perf, web"`, `data-label-input`} {
417 417 if !strings.Contains(page, want) {
418 418 t.Fatalf("issue page does not show %q:\n%s", want, page)
419 419 }
420 420 }
421 421 // A new issue page offers the labels already used in the repository.
422 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="bug"`) {
422 if _, page := f.browserGet(t, "/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="bug"`) {
423 423 t.Fatalf("new issue page has no existing label picker:\n%s", page)
424 424 }
425 425
426 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
426 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
427 427 "csrf": {sess.CSRF}, "label": {"bug", "ux"},
428 428 })
429 429 if resp.StatusCode != http.StatusSeeOther {
@@ -436,7 +436,7 @@ func TestIssueLabelsCanBeSetAndEditedInTheBrowser(t *testing.T) {
436 436 if got := strings.Join(iss.Labels, ","); got != "bug,ux" {
437 437 t.Fatalf("edited labels = %q", got)
438 438 }
439 _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
439 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
440 440 if !strings.Contains(page, `value="bug, ux"`) || strings.Contains(page, `value="bug, docs, perf, web"`) {
441 441 t.Fatalf("edited labels are not reflected in the picker:\n%s", page)
442 442 }
@@ -449,14 +449,14 @@ func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) {
449 449 f.addWriter(t) // wilbur: maintain access, so assignable
450 450 sess, cookie := f.browserSession(t, f.ownerID)
451 451
452 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
452 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
453 453 "csrf": {sess.CSRF}, "title": {"Assignees from the browser"}, "body": {"body\n"},
454 454 "assignee": {"wilbur", "keenan"},
455 455 })
456 456 if resp.StatusCode != http.StatusSeeOther {
457 457 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
458 458 }
459 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
459 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
460 460 if id == "" {
461 461 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
462 462 }
@@ -468,17 +468,17 @@ func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) {
468 468 t.Fatalf("created assignees = %q", got)
469 469 }
470 470
471 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
471 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
472 472 for _, want := range []string{`hx-trigger="token-change"`, `value="keenan, wilbur"`, `data-label-item="assignee"`} {
473 473 if !strings.Contains(page, want) {
474 474 t.Fatalf("issue page does not show %q:\n%s", want, page)
475 475 }
476 476 }
477 if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="wilbur"`) {
477 if _, page := f.browserGet(t, "/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="wilbur"`) {
478 478 t.Fatalf("new issue page does not offer wilbur as an assignee:\n%s", page)
479 479 }
480 480
481 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
481 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
482 482 "csrf": {sess.CSRF}, "assignee": {"wilbur"},
483 483 })
484 484 if resp.StatusCode != http.StatusSeeOther {
@@ -491,13 +491,13 @@ func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) {
491 491 if got := strings.Join(iss.Assignees, ","); got != "wilbur" {
492 492 t.Fatalf("edited assignees = %q", got)
493 493 }
494 _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie)
494 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
495 495 if !strings.Contains(page, `value="wilbur"`) || strings.Contains(page, `value="keenan, wilbur"`) {
496 496 t.Fatalf("edited assignees are not reflected in the picker:\n%s", page)
497 497 }
498 498
499 499 // An assignee nobody can resolve is a typo, not a person.
500 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
500 resp, body = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
501 501 "csrf": {sess.CSRF}, "title": {"Bad assignee"}, "body": {"body\n"},
502 502 "assignee": {"nobody"},
503 503 })
@@ -523,13 +523,13 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
523 523 sess, cookie := f.browserSession(t, f.ownerID)
524 524 createIssue := func(title string) string {
525 525 t.Helper()
526 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
526 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
527 527 "csrf": {sess.CSRF}, "title": {title}, "body": {"body\n"},
528 528 })
529 529 if resp.StatusCode != http.StatusSeeOther {
530 530 t.Fatalf("create issue %q: %d\n%s", title, resp.StatusCode, body)
531 531 }
532 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
532 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
533 533 if id == "" {
534 534 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
535 535 }
@@ -538,25 +538,25 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
538 538 first := createIssue("First linked issue")
539 539 second := createIssue("Second linked issue")
540 540
541 resp, body := f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{
541 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
542 542 "csrf": {sess.CSRF}, "title": {"Linked issues"}, "body": {"body\n"},
543 543 "base": {"main"}, "head": {"feature"}, "closes": {first},
544 544 })
545 545 if resp.StatusCode != http.StatusSeeOther {
546 546 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
547 547 }
548 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
548 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
549 549 if id == "" {
550 550 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
551 551 }
552 552
553 553 // The Issues section uses the overview's issue rows and offers the link
554 554 // controls instead of the old one-line "Closes ... when merged".
555 _, page := f.browserGet(t, "/r/"+f.repo+"/changes/"+id, cookie)
555 _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
556 556 for _, want := range []string{
557 557 `id="request-issues"`, "First linked issue", `value="` + first + `"`,
558 558 `value="remove"`, `request-unlink`, `class="request-issue-search"`,
559 `hx-get="/r/` + f.repo + `/changes/` + id + `/live"`,
559 `hx-get="/` + f.repo + `/changes/` + id + `/live"`,
560 560 `hx-trigger="every 20s [document.visibilityState === 'visible']"`, `hx-swap="none"`,
561 561 } {
562 562 if !strings.Contains(page, want) {
@@ -582,7 +582,7 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
582 582 t.Fatalf("the second issue has no number")
583 583 }
584 584 for _, query := range []string{"Second", "#" + strconv.Itoa(number), second[:8]} {
585 resp, results := f.htmxGet(t, "/r/"+f.repo+"/changes/"+id+"/issues/search?q="+url.QueryEscape(query), cookie)
585 resp, results := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q="+url.QueryEscape(query), cookie)
586 586 if resp.StatusCode != http.StatusOK || !strings.Contains(results, "Second linked issue") ||
587 587 !strings.Contains(results, `value="`+second+`"`) || !strings.Contains(results, `value="add"`) ||
588 588 !strings.Contains(results, `hx-target="#request-issues"`) ||
@@ -592,19 +592,19 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
592 592 }
593 593 // An issue already linked is not offered, so a shared title cannot hide the
594 594 // rows that are still available.
595 resp, results := f.htmxGet(t, "/r/"+f.repo+"/changes/"+id+"/issues/search?q=First", cookie)
595 resp, results := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q=First", cookie)
596 596 if resp.StatusCode != http.StatusOK || strings.Contains(results, "First linked issue") ||
597 597 !strings.Contains(results, "No issues match") {
598 598 t.Fatalf("a linked issue is still in the search results (%d):\n%s", resp.StatusCode, results)
599 599 }
600 600 // Clearing the field removes the popover rather than leaving a blank panel.
601 resp, results = f.htmxGet(t, "/r/"+f.repo+"/changes/"+id+"/issues/search?q=", cookie)
601 resp, results = f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q=", cookie)
602 602 if resp.StatusCode != http.StatusOK || strings.TrimSpace(results) != "" {
603 603 t.Fatalf("an empty search left a panel (%d):\n%s", resp.StatusCode, results)
604 604 }
605 605
606 606 // Clicking a result posts its id, and the section comes back with both links.
607 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
607 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
608 608 "csrf": {sess.CSRF}, "action": {"add"}, "issue": {second},
609 609 })
610 610 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-issues"`) ||
@@ -616,7 +616,7 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
616 616 }
617 617
618 618 // Removing a link is the same write, and the row disappears.
619 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
619 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
620 620 "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {first},
621 621 })
622 622 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-issues"`) ||
@@ -629,7 +629,7 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
629 629 }
630 630
631 631 // A duplicate says so beside the control instead of rewriting the list.
632 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
632 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
633 633 "csrf": {sess.CSRF}, "action": {"add"}, "issue": {second},
634 634 })
635 635 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "already linked") {
@@ -638,18 +638,18 @@ func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
638 638
639 639 // A closed request keeps its record but offers no edits, and the writer
640 640 // refuses one posted anyway.
641 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
641 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
642 642 "csrf": {sess.CSRF}, "state": {"closed"},
643 643 })
644 644 if resp.StatusCode != http.StatusSeeOther {
645 645 t.Fatalf("close change: %d\n%s", resp.StatusCode, body)
646 646 }
647 _, page = f.browserGet(t, "/r/"+f.repo+"/changes/"+id, cookie)
647 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
648 648 if !strings.Contains(page, "Second linked issue") || strings.Contains(page, `request-unlink`) ||
649 649 strings.Contains(page, `class="request-issue-search"`) {
650 650 t.Fatalf("a closed request does not show the right Issues section:\n%s", page)
651 651 }
652 resp, _ = f.htmxPost(t, "/r/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
652 resp, _ = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
653 653 "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {second},
654 654 })
655 655 if resp.StatusCode != http.StatusForbidden {
@@ -682,7 +682,7 @@ func TestRepositoryCanRequireALinkedIssueForNewChanges(t *testing.T) {
682 682 }
683 683
684 684 sess, cookie := f.browserSession(t, f.ownerID)
685 resp, body := f.browserGet(t, "/r/"+f.repo+"/changes/new", cookie)
685 resp, body := f.browserGet(t, "/"+f.repo+"/changes/new", cookie)
686 686 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "requires a linked issue") ||
687 687 !strings.Contains(body, "required") {
688 688 t.Fatalf("new change page does not explain the rule (%d):\n%s", resp.StatusCode, body)
@@ -692,29 +692,29 @@ func TestRepositoryCanRequireALinkedIssueForNewChanges(t *testing.T) {
692 692 "csrf": {sess.CSRF}, "title": {"Unlinked change"}, "body": {"body\n"},
693 693 "base": {"main"}, "head": {"feature"},
694 694 }
695 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes", cookie, form)
695 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, form)
696 696 if resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "requires at least one linked issue") {
697 697 t.Fatalf("an unlinked change was accepted (%d):\n%s", resp.StatusCode, body)
698 698 }
699 699
700 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
700 resp, body = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
701 701 "csrf": {sess.CSRF}, "title": {"The linked issue"}, "body": {"body\n"},
702 702 })
703 703 if resp.StatusCode != http.StatusSeeOther {
704 704 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
705 705 }
706 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
706 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
707 707 if issueID == "" {
708 708 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
709 709 }
710 710
711 711 form.Set("title", "Linked change")
712 712 form.Set("closes", issueID)
713 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes", cookie, form)
713 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, form)
714 714 if resp.StatusCode != http.StatusSeeOther {
715 715 t.Fatalf("a linked change was rejected (%d):\n%s", resp.StatusCode, body)
716 716 }
717 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
717 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
718 718 got, err := metadata.GetRequest(m.Git(), id)
719 719 if err != nil {
720 720 t.Fatal(err)
@@ -749,16 +749,16 @@ func TestIssueSidebarFragmentsUpdateWithHtmx(t *testing.T) {
749 749 f.addWriter(t)
750 750 sess, cookie := f.browserSession(t, f.ownerID)
751 751
752 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
752 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
753 753 "csrf": {sess.CSRF}, "title": {"HTMX issue"}, "body": {"body\n"},
754 754 })
755 755 if resp.StatusCode != http.StatusSeeOther {
756 756 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
757 757 }
758 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
758 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
759 759
760 760 // Labels: the response is the fragment, not a page and not a redirect.
761 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
761 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
762 762 "csrf": {sess.CSRF}, "label": {"bug", "ux"},
763 763 })
764 764 if resp.StatusCode != http.StatusOK {
@@ -771,7 +771,7 @@ func TestIssueSidebarFragmentsUpdateWithHtmx(t *testing.T) {
771 771
772 772 // Assignees: the replacement fragment seeds the token input and keeps the
773 773 // token-change trigger.
774 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
774 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
775 775 "csrf": {sess.CSRF}, "assignee": {"wilbur"},
776 776 })
777 777 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-assignees"`) ||
@@ -780,7 +780,7 @@ func TestIssueSidebarFragmentsUpdateWithHtmx(t *testing.T) {
780 780 }
781 781
782 782 // Comments: the thread fragment includes the new comment and the composer.
783 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/comments", cookie, url.Values{
783 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/comments", cookie, url.Values{
784 784 "csrf": {sess.CSRF}, "body": {"Posted over HTMX.\n"},
785 785 })
786 786 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) ||
@@ -805,14 +805,14 @@ func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) {
805 805 }
806 806
807 807 ownerSess, ownerCookie := f.browserSession(t, f.ownerID)
808 resp, body := f.browserPost(t, "/r/"+f.repo+"/changes", ownerCookie, url.Values{
808 resp, body := f.browserPost(t, "/"+f.repo+"/changes", ownerCookie, url.Values{
809 809 "csrf": {ownerSess.CSRF}, "title": {"HTMX change"}, "body": {"body\n"},
810 810 "base": {"main"}, "head": {"feature"},
811 811 })
812 812 if resp.StatusCode != http.StatusSeeOther {
813 813 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
814 814 }
815 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
815 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
816 816
817 817 // A maintainer approves; the data-only regions and the action buttons come
818 818 // back as out-of-band updates, so the Issues section and any open search are
@@ -822,7 +822,7 @@ func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) {
822 822 if err != nil {
823 823 t.Fatal(err)
824 824 }
825 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+id+"/approve", writerCookie, url.Values{
825 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/approve", writerCookie, url.Values{
826 826 "csrf": {writerSess.CSRF}, "base": {request.Base}, "head": {request.Head},
827 827 })
828 828 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `hx-swap-oob="outerHTML"`) ||
@@ -838,7 +838,7 @@ func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) {
838 838
839 839 // The page's poll returns only out-of-band data regions. It has no sidebar
840 840 // wrapper and no form, so it cannot close a search or replace a picker.
841 resp, body = f.htmxGet(t, "/r/"+f.repo+"/changes/"+id+"/live", writerCookie)
841 resp, body = f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/live", writerCookie)
842 842 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
843 843 strings.Contains(body, "<form") || strings.Contains(body, `id="request-aside"`) ||
844 844 strings.Contains(body, `id="request-sidebar"`) || strings.Contains(body, `request-issues`) {
@@ -856,7 +856,7 @@ func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) {
856 856 }
857 857
858 858 // Posting a comment replaces only the discussion fragment.
859 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+id+"/comments", ownerCookie, url.Values{
859 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", ownerCookie, url.Values{
860 860 "csrf": {ownerSess.CSRF}, "body": {"Review note over HTMX.\n"},
861 861 })
862 862 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) ||
@@ -895,7 +895,7 @@ func TestIssueAndChangeListsReturnFragmentsForHtmx(t *testing.T) {
895 895 t.Fatalf("push: %s", errs)
896 896 }
897 897
898 resp, body := f.htmxGet(t, "/r/"+f.repo+"/issues?state=closed", "")
898 resp, body := f.htmxGet(t, "/"+f.repo+"/issues?state=closed", "")
899 899 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-list"`) ||
900 900 strings.Contains(body, "<!doctype html>") {
901 901 t.Fatalf("issues fragment = %d:\n%s", resp.StatusCode, body)
@@ -912,7 +912,7 @@ func TestIssueAndChangeListsReturnFragmentsForHtmx(t *testing.T) {
912 912 t.Fatal("issues fragment lost its HTMX target")
913 913 }
914 914
915 resp, body = f.htmxGet(t, "/r/"+f.repo+"/changes?state=all", "")
915 resp, body = f.htmxGet(t, "/"+f.repo+"/changes?state=all", "")
916 916 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="change-list"`) ||
917 917 strings.Contains(body, "<!doctype html>") {
918 918 t.Fatalf("changes fragment = %d:\n%s", resp.StatusCode, body)
@@ -952,16 +952,16 @@ func TestRequestFilesListsEveryChangedFile(t *testing.T) {
952 952 t.Fatalf("feature push: %s", errs)
953 953 }
954 954 sess, cookie := f.browserSession(t, f.ownerID)
955 resp, body := f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{
955 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
956 956 "csrf": {sess.CSRF}, "title": {"Every file"}, "body": {"body\n"},
957 957 "base": {"main"}, "head": {"feature"},
958 958 })
959 959 if resp.StatusCode != http.StatusSeeOther {
960 960 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
961 961 }
962 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
962 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
963 963
964 filesURL := "/r/" + f.repo + "/changes/" + id + "/files"
964 filesURL := "/" + f.repo + "/changes/" + id + "/files"
965 965 _, page := f.browserGet(t, filesURL, cookie)
966 966 if !strings.Contains(page, "5 changed") {
967 967 t.Errorf("the tree does not count every file:\n%.2000s", page)
@@ -1000,17 +1000,17 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
1000 1000 t.Fatalf("feature push: %s", errs)
1001 1001 }
1002 1002 sess, cookie := f.browserSession(t, f.ownerID)
1003 resp, body := f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{
1003 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
1004 1004 "csrf": {sess.CSRF}, "title": {"Line comments"}, "body": {"body\n"},
1005 1005 "base": {"main"}, "head": {"feature"},
1006 1006 })
1007 1007 if resp.StatusCode != http.StatusSeeOther {
1008 1008 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1009 1009 }
1010 id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
1010 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
1011 1011
1012 1012 // The Files tab brings the changed file and its per-line comment button.
1013 filesURL := "/r/" + f.repo + "/changes/" + id + "/files"
1013 filesURL := "/" + f.repo + "/changes/" + id + "/files"
1014 1014 _, page := f.browserGet(t, filesURL, cookie)
1015 1015 if !strings.Contains(page, `id="review-tree"`) || !strings.Contains(page, `data-status="M"`) ||
1016 1016 !strings.Contains(page, filesURL+"/readme.md") {
@@ -1029,7 +1029,7 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
1029 1029 }
1030 1030
1031 1031 // HTMX inserts the composer under the line, with the anchor already filled in.
1032 resp, form := f.htmxGet(t, "/r/"+f.repo+"/changes/"+id+"/comment-form?path=readme.md&line=2&side=new", cookie)
1032 resp, form := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/comment-form?path=readme.md&line=2&side=new", cookie)
1033 1033 if resp.StatusCode != http.StatusOK || strings.Contains(form, "<!doctype html>") ||
1034 1034 !strings.Contains(form, `class="inline-comment"`) ||
1035 1035 !strings.Contains(form, `name="path" value="readme.md"`) ||
@@ -1039,7 +1039,7 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
1039 1039 }
1040 1040
1041 1041 // The old full-page fallback still opens the composer with the anchor filled in.
1042 _, page = f.browserGet(t, "/r/"+f.repo+"/changes/"+id+"?path=readme.md&line=2&side=new", cookie)
1042 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+id+"?path=readme.md&line=2&side=new", cookie)
1043 1043 if !strings.Contains(page, "Commenting on") || !strings.Contains(page, `name="path" value="readme.md"`) ||
1044 1044 !strings.Contains(page, `name="line" value="2"`) {
1045 1045 t.Fatalf("anchored composer is not pre-filled:\n%s", page)
@@ -1048,7 +1048,7 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
1048 1048 // A rejected anchored comment re-renders the discussion with the anchor still
1049 1049 // chosen, so the reader keeps the line they picked instead of being sent back to
1050 1050 // the Files tab to choose it again.
1051 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1051 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1052 1052 "csrf": {sess.CSRF}, "body": {""},
1053 1053 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1054 1054 })
@@ -1059,7 +1059,7 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
1059 1059 }
1060 1060
1061 1061 // Posting it through HTMX stores the anchor and returns the discussion.
1062 resp, body = f.htmxPost(t, "/r/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1062 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1063 1063 "csrf": {sess.CSRF}, "body": {"This line needs a second look."},
1064 1064 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1065 1065 })
@@ -1087,7 +1087,7 @@ func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
1087 1087 }
1088 1088
1089 1089 // A normal form post from the Files tab returns to that file's line note.
1090 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1090 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
1091 1091 "csrf": {sess.CSRF}, "body": {"Another line note."},
1092 1092 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1093 1093 })
@@ -1113,28 +1113,28 @@ func TestIssueAndChangeCommentsThreadOneLevel(t *testing.T) {
1113 1113 }
1114 1114
1115 1115 // Issue: replies are rendered under their root, even when replying to a reply.
1116 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
1116 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
1117 1117 "csrf": {sess.CSRF}, "title": {"Threaded issue"}, "body": {"body\n"},
1118 1118 })
1119 1119 if resp.StatusCode != http.StatusSeeOther {
1120 1120 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
1121 1121 }
1122 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
1123 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1122 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
1123 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1124 1124 "csrf": {sess.CSRF}, "body": {"Root comment."},
1125 1125 })
1126 1126 rootID := commentID(resp)
1127 1127 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1128 1128 t.Fatalf("issue root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1129 1129 }
1130 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1130 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1131 1131 "csrf": {sess.CSRF}, "body": {"Issue reply."}, "reply_to": {rootID},
1132 1132 })
1133 1133 replyID := commentID(resp)
1134 1134 if resp.StatusCode != http.StatusSeeOther || replyID == "" {
1135 1135 t.Fatalf("issue reply: %d %q\n%s", resp.StatusCode, replyID, body)
1136 1136 }
1137 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
1137 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
1138 1138 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) ||
1139 1139 !strings.Contains(page, "Issue reply.") || !strings.Contains(page, "data-comment-menu") ||
1140 1140 !strings.Contains(page, `data-reply-to="`+replyID+`"`) ||
@@ -1148,14 +1148,14 @@ func TestIssueAndChangeCommentsThreadOneLevel(t *testing.T) {
1148 1148 !strings.Contains(page, "data-reply-input") || !strings.Contains(page, "Cancel reply") {
1149 1149 t.Fatalf("issue discussion is not a full thread with one composer:\n%s", page)
1150 1150 }
1151 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1151 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1152 1152 "csrf": {sess.CSRF}, "body": {"Reply to a reply."}, "reply_to": {replyID},
1153 1153 })
1154 1154 deeperID := commentID(resp)
1155 1155 if resp.StatusCode != http.StatusSeeOther || deeperID == "" {
1156 1156 t.Fatalf("reply to a reply: %d %q\n%s", resp.StatusCode, deeperID, body)
1157 1157 }
1158 _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
1158 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
1159 1159 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+deeperID+`"`) ||
1160 1160 !strings.Contains(page, "Reply to a reply.") || !strings.Contains(page, `data-reply-to="`+deeperID+`"`) ||
1161 1161 !strings.Contains(page, `href="#c-`+replyID+`"`) {
@@ -1171,29 +1171,29 @@ func TestIssueAndChangeCommentsThreadOneLevel(t *testing.T) {
1171 1171 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1172 1172 t.Fatalf("feature push: %s", errs)
1173 1173 }
1174 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{
1174 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
1175 1175 "csrf": {sess.CSRF}, "title": {"Threaded change"}, "body": {"body\n"},
1176 1176 "base": {"main"}, "head": {"feature"},
1177 1177 })
1178 1178 if resp.StatusCode != http.StatusSeeOther {
1179 1179 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1180 1180 }
1181 requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
1182 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1181 requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
1182 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1183 1183 "csrf": {sess.CSRF}, "body": {"Change root."},
1184 1184 })
1185 1185 rootID = commentID(resp)
1186 1186 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1187 1187 t.Fatalf("change root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1188 1188 }
1189 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1189 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1190 1190 "csrf": {sess.CSRF}, "body": {"Change reply."}, "reply_to": {rootID},
1191 1191 })
1192 1192 replyID = commentID(resp)
1193 1193 if resp.StatusCode != http.StatusSeeOther || replyID == "" {
1194 1194 t.Fatalf("change reply: %d %q\n%s", resp.StatusCode, replyID, body)
1195 1195 }
1196 _, page = f.browserGet(t, "/r/"+f.repo+"/changes/"+requestID, cookie)
1196 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
1197 1197 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) ||
1198 1198 !strings.Contains(page, "Change reply.") || !strings.Contains(page, `data-reply-to="`+replyID+`"`) ||
1199 1199 !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) ||
@@ -1218,28 +1218,28 @@ func TestCommentEditAndDeleteFromTheWeb(t *testing.T) {
1218 1218 return ""
1219 1219 }
1220 1220
1221 resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{
1221 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
1222 1222 "csrf": {sess.CSRF}, "title": {"Editable issue"}, "body": {"body\n"},
1223 1223 })
1224 1224 if resp.StatusCode != http.StatusSeeOther {
1225 1225 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
1226 1226 }
1227 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/")
1228 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1227 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
1228 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1229 1229 "csrf": {sess.CSRF}, "body": {"Original root."},
1230 1230 })
1231 1231 rootID := commentID(resp)
1232 1232 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1233 1233 t.Fatalf("root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1234 1234 }
1235 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1235 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
1236 1236 "csrf": {sess.CSRF}, "body": {"A surviving reply."}, "reply_to": {rootID},
1237 1237 })
1238 1238 if resp.StatusCode != http.StatusSeeOther {
1239 1239 t.Fatalf("reply: %d\n%s", resp.StatusCode, body)
1240 1240 }
1241 1241
1242 _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
1242 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
1243 1243 for _, want := range []string{"data-comment-menu-toggle", "Copy text", "Copy link", "data-comment-edit", "Delete"} {
1244 1244 if !strings.Contains(page, want) {
1245 1245 t.Fatalf("comment menu is missing %q:\n%s", want, page)
@@ -1248,14 +1248,14 @@ func TestCommentEditAndDeleteFromTheWeb(t *testing.T) {
1248 1248
1249 1249 // A maintainer who is not the author cannot rewrite or remove the comment.
1250 1250 writerSess, writerCookie := f.browserSession(t, f.writerID)
1251 resp, _ = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", writerCookie, url.Values{
1251 resp, _ = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", writerCookie, url.Values{
1252 1252 "csrf": {writerSess.CSRF}, "body": {"Not mine."},
1253 1253 })
1254 1254 if resp.StatusCode != http.StatusForbidden {
1255 1255 t.Fatalf("foreign edit = %d, want 403", resp.StatusCode)
1256 1256 }
1257 1257
1258 resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", cookie, url.Values{
1258 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", cookie, url.Values{
1259 1259 "csrf": {sess.CSRF}, "body": {"Edited root."},
1260 1260 })
1261 1261 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) ||
@@ -1263,13 +1263,13 @@ func TestCommentEditAndDeleteFromTheWeb(t *testing.T) {
1263 1263 t.Fatalf("edit response = %d:\n%s", resp.StatusCode, body)
1264 1264 }
1265 1265
1266 resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/delete", cookie, url.Values{
1266 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/delete", cookie, url.Values{
1267 1267 "csrf": {sess.CSRF},
1268 1268 })
1269 1269 if resp.StatusCode != http.StatusSeeOther {
1270 1270 t.Fatalf("delete = %d:\n%s", resp.StatusCode, body)
1271 1271 }
1272 _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+issueID, cookie)
1272 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
1273 1273 if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "A surviving reply.") {
1274 1274 t.Fatalf("deleted root did not become a tombstone:\n%s", page)
1275 1275 }
@@ -1283,41 +1283,41 @@ func TestCommentEditAndDeleteFromTheWeb(t *testing.T) {
1283 1283 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1284 1284 t.Fatalf("feature push: %s", errs)
1285 1285 }
1286 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes", cookie, url.Values{
1286 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
1287 1287 "csrf": {sess.CSRF}, "title": {"Editable change"}, "body": {"body\n"},
1288 1288 "base": {"main"}, "head": {"feature"},
1289 1289 })
1290 1290 if resp.StatusCode != http.StatusSeeOther {
1291 1291 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1292 1292 }
1293 requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/changes/")
1294 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1293 requestID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
1294 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1295 1295 "csrf": {sess.CSRF}, "body": {"Original change root."},
1296 1296 })
1297 1297 rootID = commentID(resp)
1298 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1298 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
1299 1299 "csrf": {sess.CSRF}, "body": {"Change reply survives."}, "reply_to": {rootID},
1300 1300 })
1301 1301 if resp.StatusCode != http.StatusSeeOther {
1302 1302 t.Fatalf("change reply: %d\n%s", resp.StatusCode, body)
1303 1303 }
1304 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+requestID+"/comments/"+rootID+"/edit", cookie, url.Values{
1304 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments/"+rootID+"/edit", cookie, url.Values{
1305 1305 "csrf": {sess.CSRF}, "body": {"Edited change root."},
1306 1306 })
1307 1307 if resp.StatusCode != http.StatusSeeOther {
1308 1308 t.Fatalf("change edit: %d\n%s", resp.StatusCode, body)
1309 1309 }
1310 _, page = f.browserGet(t, "/r/"+f.repo+"/changes/"+requestID, cookie)
1310 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
1311 1311 if !strings.Contains(page, "Edited change root.") || strings.Contains(page, "Original change root.") {
1312 1312 t.Fatalf("change edit did not replace the comment:\n%s", page)
1313 1313 }
1314 resp, body = f.browserPost(t, "/r/"+f.repo+"/changes/"+requestID+"/comments/"+rootID+"/delete", cookie, url.Values{
1314 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments/"+rootID+"/delete", cookie, url.Values{
1315 1315 "csrf": {sess.CSRF},
1316 1316 })
1317 1317 if resp.StatusCode != http.StatusSeeOther {
1318 1318 t.Fatalf("change delete: %d\n%s", resp.StatusCode, body)
1319 1319 }
1320 _, page = f.browserGet(t, "/r/"+f.repo+"/changes/"+requestID, cookie)
1320 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
1321 1321 if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "Change reply survives.") {
1322 1322 t.Fatalf("deleted change root did not become a tombstone:\n%s", page)
1323 1323 }
@@ -1346,12 +1346,12 @@ func TestLandingRedirectsToTheRequestPage(t *testing.T) {
1346 1346 }
1347 1347
1348 1348 sess, cookie := f.browserSession(t, f.ownerID)
1349 resp, body := f.browserPost(t, "/r/"+f.repo+"/changes/"+reqID+"/merge", cookie,
1349 resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+reqID+"/merge", cookie,
1350 1350 url.Values{"csrf": {sess.CSRF}})
1351 1351 if resp.StatusCode != http.StatusSeeOther {
1352 1352 t.Fatalf("merge = %d, want 303:\n%s", resp.StatusCode, body)
1353 1353 }
1354 want := "/r/" + f.repo + "/changes/" + reqID
1354 want := "/" + f.repo + "/changes/" + reqID
1355 1355 if got := resp.Header.Get("Location"); got != want {
1356 1356 t.Fatalf("merge redirect = %q, want %q", got, want)
1357 1357 }
@@ -1385,14 +1385,14 @@ func TestRepoTabCountsPollOutOfBand(t *testing.T) {
1385 1385
1386 1386 // The page is what the poll has to keep in step: the badges it names, and the
1387 1387 // request that refreshes them while the page is visible.
1388 code, page := f.get("/r/" + f.repo + "/issues")
1388 code, page := f.get("/" + f.repo + "/issues")
1389 1389 if code != http.StatusOK {
1390 1390 t.Fatalf("issue page: %d", code)
1391 1391 }
1392 1392 for _, want := range []string{
1393 1393 `id="repo-issue-count"`,
1394 1394 `id="repo-request-count"`,
1395 `hx-get="/r/` + f.repo + `/counts"`,
1395 `hx-get="/` + f.repo + `/counts"`,
1396 1396 "every 30s [document.visibilityState === 'visible']",
1397 1397 } {
1398 1398 if !strings.Contains(page, want) {
@@ -1402,7 +1402,7 @@ func TestRepoTabCountsPollOutOfBand(t *testing.T) {
1402 1402
1403 1403 // The answer is only the badges, each replacing itself out of band. Both are
1404 1404 // present on every poll, so a count that did not move is not left stale.
1405 resp, body := f.htmxGet(t, "/r/"+f.repo+"/counts", "")
1405 resp, body := f.htmxGet(t, "/"+f.repo+"/counts", "")
1406 1406 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") {
1407 1407 t.Fatalf("counts poll = %d:\n%s", resp.StatusCode, body)
1408 1408 }
@@ -1420,7 +1420,7 @@ func TestRepoTabCountsPollOutOfBand(t *testing.T) {
1420 1420
1421 1421 // A repository the reader cannot see answers like a missing one, so the poll
1422 1422 // cannot be used to discover private repositories.
1423 if resp, _ := f.htmxGet(t, "/r/no-such-repo/counts", ""); resp.StatusCode != http.StatusNotFound {
1423 if resp, _ := f.htmxGet(t, "/no-such-repo/counts", ""); resp.StatusCode != http.StatusNotFound {
1424 1424 t.Fatalf("counts for an unknown repository = %d, want 404", resp.StatusCode)
1425 1425 }
1426 1426 }
@@ -1463,7 +1463,7 @@ func TestIssueListShowsActivityColumnsAndSortsByUpdated(t *testing.T) {
1463 1463 t.Fatalf("push the edit: %v\n%s", err, errs)
1464 1464 }
1465 1465
1466 code, body := f.get("/r/" + f.repo + "/issues")
1466 code, body := f.get("/" + f.repo + "/issues")
1467 1467 if code != http.StatusOK {
1468 1468 t.Fatalf("issue list: %d\n%s", code, body)
1469 1469 }
@@ -1473,8 +1473,8 @@ func TestIssueListShowsActivityColumnsAndSortsByUpdated(t *testing.T) {
1473 1473 `>Open</span>`,
1474 1474 `<span>1 comment</span>`,
1475 1475 `<span class="label">bug</span>`,
1476 `opened by <a class="user" href="/u/keenan">keenan</a>`,
1477 `<a class="user" href="/u/wilbur">wilbur</a>`,
1476 `opened by <a class="user" href="/-/u/keenan">keenan</a>`,
1477 `<a class="user" href="/-/u/wilbur">wilbur</a>`,
1478 1478 } {
1479 1479 if !strings.Contains(body, want) {
1480 1480 t.Fatalf("issue list is missing %q:\n%s", want, body)
@@ -1535,7 +1535,7 @@ func TestChangeListShowsOneStatus(t *testing.T) {
1535 1535 t.Fatalf("changes push: %v", err)
1536 1536 }
1537 1537
1538 code, body := f.get("/r/" + f.repo + "/changes?state=all")
1538 code, body := f.get("/" + f.repo + "/changes?state=all")
1539 1539 if code != http.StatusOK {
1540 1540 t.Fatalf("change list: %d\n%s", code, body)
1541 1541 }
@@ -1553,8 +1553,8 @@ func TestChangeListShowsOneStatus(t *testing.T) {
1553 1553 "Merged",
1554 1554 `class="row-status closed"`,
1555 1555 "Closed",
1556 `opened by <a class="user" href="/u/keenan">keenan</a>`,
1557 `opened by <a class="user" href="/u/wilbur">wilbur</a>`,
1556 `opened by <a class="user" href="/-/u/keenan">keenan</a>`,
1557 `opened by <a class="user" href="/-/u/wilbur">wilbur</a>`,
1558 1558 } {
1559 1559 if !strings.Contains(body, want) {
1560 1560 t.Fatalf("change list is missing %q:\n%s", want, body)