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) | |