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/vole.git

Write over SSH with a registered key:

git clone ssh://git@rodent.software:2222/vole.git

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

Blame · server/issues_changes_test.go

Line attribution on main · 1568 line(s)

f09ef39e
Keenan Nemetz
15d ago
1 package server_test
2
3 // The browser is a complete surface for the two discussion objects: an issue can
4 // be filed, answered, closed and reopened, and a change can be opened, discussed,
5 // closed and reopened. Each write goes through the same metadata writer as the
6 // CLI, so the branch is the only source of truth.
7
8 import (
9 "io"
10 "net/http"
11 "net/url"
db4081ec
Keenan Nemetz
5d ago
12 "os"
a4e7a99a
Keenan Nemetz
6d ago
13 "path/filepath"
cad93cfe
Keenan Nemetz
10d ago
14 "strconv"
f09ef39e
Keenan Nemetz
15d ago
15 "strings"
16 "testing"
db4081ec
Keenan Nemetz
5d ago
17 "time"
f09ef39e
Keenan Nemetz
15d ago
18
d37d301f
Keenan Nemetz
19h ago
19 "rodent.software/vole/forge/index"
20 "rodent.software/vole/metadata"
f09ef39e
Keenan Nemetz
15d ago
21 )
22
23 func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) {
24 t.Parallel()
25 f := newFixture(t)
26 f.publish(f.repo)
27 sess, cookie := f.browserSession(t, f.ownerID)
28
ed03b3d1
Keenan Nemetz
6h ago
29 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
30 "csrf": {sess.CSRF}, "title": {"Close me from the web"}, "body": {"body\n"},
31 })
32 if resp.StatusCode != http.StatusSeeOther {
33 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
34 }
ed03b3d1
Keenan Nemetz
6h ago
35 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
f09ef39e
Keenan Nemetz
15d ago
36 if id == "" {
37 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
38 }
39
ed03b3d1
Keenan Nemetz
6h ago
40 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
41 "csrf": {sess.CSRF}, "state": {"closed"},
42 })
43 if resp.StatusCode != http.StatusSeeOther {
44 t.Fatalf("close issue: %d\n%s", resp.StatusCode, body)
45 }
ed03b3d1
Keenan Nemetz
6h ago
46 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
f6f4b1db
Keenan Nemetz
15d ago
47 if !strings.Contains(page, "Reopen</button>") || !strings.Contains(page, `class="state closed"`) {
f09ef39e
Keenan Nemetz
15d ago
48 t.Fatalf("closed issue does not offer a reopen:\n%s", page)
49 }
50 // The default list is open issues; the closed one is behind the All filter.
ed03b3d1
Keenan Nemetz
6h ago
51 if _, list := f.browserGet(t, "/"+f.repo+"/issues", cookie); strings.Contains(list, "Close me from the web") {
f09ef39e
Keenan Nemetz
15d ago
52 t.Fatalf("closed issue is still in the open list:\n%s", list)
53 }
ed03b3d1
Keenan Nemetz
6h ago
54 if _, list := f.browserGet(t, "/"+f.repo+"/issues?state=all", cookie); !strings.Contains(list, "Close me from the web") {
f09ef39e
Keenan Nemetz
15d ago
55 t.Fatalf("closed issue is missing from the All list:\n%s", list)
56 }
57
ed03b3d1
Keenan Nemetz
6h ago
58 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
59 "csrf": {sess.CSRF}, "state": {"open"},
60 })
61 if resp.StatusCode != http.StatusSeeOther {
62 t.Fatalf("reopen issue: %d\n%s", resp.StatusCode, body)
63 }
ed03b3d1
Keenan Nemetz
6h ago
64 if _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close</button>") ||
f6f4b1db
Keenan Nemetz
15d ago
65 !strings.Contains(page, `class="danger"`) {
f09ef39e
Keenan Nemetz
15d ago
66 t.Fatalf("reopened issue does not offer a close:\n%s", page)
67 }
68 }
69
a4e7a99a
Keenan Nemetz
6d ago
70 // The browser could file, triage, discuss and close an issue or a change, but it
802e0e52
Keenan Nemetz
5d ago
71 // could not correct what one said. The edit controls are the missing half: the
72 // author and a maintainer may rewrite the title and description in place, and
73 // nobody else may, on either surface. The standalone edit page stays as the
74 // no-JavaScript fallback.
a4e7a99a
Keenan Nemetz
6d ago
75 func TestIssueAndChangeDetailsCanBeEditedInTheBrowser(t *testing.T) {
76 t.Parallel()
77 f := newFixture(t)
78 f.publish(f.repo)
79
80 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
81 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
82 t.Fatalf("main push: %s", errs)
83 }
84 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
85 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
86 t.Fatalf("feature push: %s", errs)
87 }
88
89 sess, cookie := f.browserSession(t, f.ownerID)
90
91 // An issue: the page offers Edit, the form carries what the issue says, and
92 // saving returns to the issue with the new prose.
ed03b3d1
Keenan Nemetz
6h ago
93 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
a4e7a99a
Keenan Nemetz
6d ago
94 "csrf": {sess.CSRF}, "title": {"First draft"}, "body": {"original body\n"},
95 })
96 if resp.StatusCode != http.StatusSeeOther {
97 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
98 }
ed03b3d1
Keenan Nemetz
6h ago
99 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
a4e7a99a
Keenan Nemetz
6d ago
100 if issueID == "" {
101 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
102 }
ed03b3d1
Keenan Nemetz
6h ago
103 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
802e0e52
Keenan Nemetz
5d ago
104 if !strings.Contains(page, "data-title-edit") || !strings.Contains(page, "data-title-edit-form") ||
105 !strings.Contains(page, `data-comment-menu`) || !strings.Contains(page, "Copy text") ||
106 !strings.Contains(page, "data-comment-edit") || !strings.Contains(page, `name="field" value="title"`) ||
107 !strings.Contains(page, `name="field" value="body"`) || !strings.Contains(page, "data-autosize") {
108 t.Fatalf("the issue page offers no in-place title and description controls:\n%s", page)
a4e7a99a
Keenan Nemetz
6d ago
109 }
ed03b3d1
Keenan Nemetz
6h ago
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"`) ||
a4e7a99a
Keenan Nemetz
6d ago
112 !strings.Contains(form, `value="First draft"`) || !strings.Contains(form, "original body") {
113 t.Fatalf("issue edit page = %d:\n%s", resp.StatusCode, form)
114 }
ed03b3d1
Keenan Nemetz
6h ago
115 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
a4e7a99a
Keenan Nemetz
6d ago
116 "csrf": {sess.CSRF}, "title": {"Corrected issue"}, "body": {"corrected issue body\n"},
117 })
118 if resp.StatusCode != http.StatusSeeOther {
119 t.Fatalf("edit issue: %d\n%s", resp.StatusCode, body)
120 }
121 iss, err := index.LoadIssue(mustRepo(t, f), issueID)
122 if err != nil {
123 t.Fatal(err)
124 }
125 if iss.Title != "Corrected issue" || strings.TrimSpace(iss.Description) != "corrected issue body" {
126 t.Fatalf("edited issue = %q / %q", iss.Title, iss.Description)
127 }
ed03b3d1
Keenan Nemetz
6h ago
128 if _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie); !strings.Contains(page, "Corrected issue") ||
a4e7a99a
Keenan Nemetz
6d ago
129 !strings.Contains(page, "corrected issue body") {
130 t.Fatalf("the issue page does not show the edit:\n%s", page)
131 }
132
802e0e52
Keenan Nemetz
5d ago
133 // The title pencil posts only the title. Its response is the heading, not the
134 // description, so a description draft is left alone.
ed03b3d1
Keenan Nemetz
6h ago
135 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
802e0e52
Keenan Nemetz
5d ago
136 "csrf": {sess.CSRF}, "field": {"title"}, "title": {"In-place issue"},
137 })
138 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
139 !strings.Contains(body, `id="issue-head"`) || !strings.Contains(body, "In-place issue") ||
140 strings.Contains(body, `id="issue-description"`) {
141 t.Fatalf("issue inline title edit = %d:\n%s", resp.StatusCode, body)
142 }
143 if iss, err = index.LoadIssue(mustRepo(t, f), issueID); err != nil ||
144 iss.Title != "In-place issue" || strings.TrimSpace(iss.Description) != "corrected issue body" {
145 t.Fatalf("inline issue title edit = %+v, %v", iss, err)
146 }
147
148 // The description menu posts only the description, and comes back as the
149 // description card with the title untouched.
ed03b3d1
Keenan Nemetz
6h ago
150 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
802e0e52
Keenan Nemetz
5d ago
151 "csrf": {sess.CSRF}, "field": {"body"}, "body": {"in-place issue body\n"},
152 })
153 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
154 !strings.Contains(body, `id="issue-description"`) || !strings.Contains(body, "in-place issue body") ||
155 strings.Contains(body, `id="issue-head"`) {
156 t.Fatalf("issue inline description edit = %d:\n%s", resp.StatusCode, body)
157 }
158 if iss, err = index.LoadIssue(mustRepo(t, f), issueID); err != nil ||
159 iss.Title != "In-place issue" || strings.TrimSpace(iss.Description) != "in-place issue body" {
160 t.Fatalf("inline issue description edit = %+v, %v", iss, err)
161 }
162
163 // A refused title keeps the title form open with what was typed; the
164 // description card is not part of the response.
ed03b3d1
Keenan Nemetz
6h ago
165 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/edit", cookie, url.Values{
802e0e52
Keenan Nemetz
5d ago
166 "csrf": {sess.CSRF}, "field": {"title"}, "title": {""},
167 })
168 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "an issue needs a title") ||
169 !strings.Contains(body, `data-title-edit-form`) || !strings.Contains(body, `autofocus`) ||
170 strings.Contains(body, `id="issue-description"`) {
171 t.Fatalf("refused inline issue title edit = %d:\n%s", resp.StatusCode, body)
172 }
173
a4e7a99a
Keenan Nemetz
6d ago
174 // A change: same controls, and the same writer.
ed03b3d1
Keenan Nemetz
6h ago
175 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
a4e7a99a
Keenan Nemetz
6d ago
176 "csrf": {sess.CSRF}, "title": {"First change draft"}, "body": {"original change body\n"},
177 "base": {"main"}, "head": {"feature"},
178 })
179 if resp.StatusCode != http.StatusSeeOther {
180 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
181 }
ed03b3d1
Keenan Nemetz
6h ago
182 changeID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
a4e7a99a
Keenan Nemetz
6d ago
183 if changeID == "" {
184 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
185 }
ed03b3d1
Keenan Nemetz
6h ago
186 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+changeID, cookie)
802e0e52
Keenan Nemetz
5d ago
187 if !strings.Contains(page, "data-title-edit") || !strings.Contains(page, "data-title-edit-form") ||
188 !strings.Contains(page, `data-comment-menu`) || !strings.Contains(page, "Copy text") ||
189 !strings.Contains(page, "data-comment-edit") || !strings.Contains(page, `name="field" value="title"`) ||
190 !strings.Contains(page, `name="field" value="body"`) || !strings.Contains(page, "data-autosize") {
191 t.Fatalf("the change page offers no in-place title and description controls:\n%s", page)
192 }
193 // The Files tab reuses the change head but has no description form there, so
194 // its title pencil must keep the ordinary link to the standalone page.
ed03b3d1
Keenan Nemetz
6h ago
195 if _, files := f.browserGet(t, "/"+f.repo+"/changes/"+changeID+"/files", cookie); !strings.Contains(files, "/changes/"+changeID+"/edit") ||
802e0e52
Keenan Nemetz
5d ago
196 strings.Contains(files, "data-title-edit") {
197 t.Fatalf("the Files tab does not link to the edit page:\n%s", files)
a4e7a99a
Keenan Nemetz
6d ago
198 }
ed03b3d1
Keenan Nemetz
6h ago
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"`) ||
a4e7a99a
Keenan Nemetz
6d ago
201 !strings.Contains(form, `value="First change draft"`) || !strings.Contains(form, "original change body") {
202 t.Fatalf("change edit page = %d:\n%s", resp.StatusCode, form)
203 }
ed03b3d1
Keenan Nemetz
6h ago
204 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
a4e7a99a
Keenan Nemetz
6d ago
205 "csrf": {sess.CSRF}, "title": {"Corrected change"}, "body": {"corrected change body\n"},
206 })
207 if resp.StatusCode != http.StatusSeeOther {
208 t.Fatalf("edit change: %d\n%s", resp.StatusCode, body)
209 }
210 req, err := metadata.GetRequest(mustRepo(t, f).Git(), changeID)
211 if err != nil {
212 t.Fatal(err)
213 }
214 if req.Title != "Corrected change" || strings.TrimSpace(req.Description) != "corrected change body" {
215 t.Fatalf("edited change = %q / %q", req.Title, req.Description)
216 }
ed03b3d1
Keenan Nemetz
6h ago
217 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+changeID, cookie); !strings.Contains(page, "Corrected change") ||
a4e7a99a
Keenan Nemetz
6d ago
218 !strings.Contains(page, "corrected change body") {
219 t.Fatalf("the change page does not show the edit:\n%s", page)
220 }
221
ed03b3d1
Keenan Nemetz
6h ago
222 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
802e0e52
Keenan Nemetz
5d ago
223 "csrf": {sess.CSRF}, "field": {"title"}, "title": {"In-place change"},
224 })
225 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
226 !strings.Contains(body, `id="request-head"`) || !strings.Contains(body, "In-place change") ||
227 strings.Contains(body, `id="request-description"`) || strings.Contains(body, `id="request-alert"`) {
228 t.Fatalf("change inline title edit = %d:\n%s", resp.StatusCode, body)
229 }
230 if req, err = metadata.GetRequest(mustRepo(t, f).Git(), changeID); err != nil ||
231 req.Title != "In-place change" || strings.TrimSpace(req.Description) != "corrected change body" {
232 t.Fatalf("inline change title edit = %+v, %v", req, err)
233 }
ed03b3d1
Keenan Nemetz
6h ago
234 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
802e0e52
Keenan Nemetz
5d ago
235 "csrf": {sess.CSRF}, "field": {"body"}, "body": {"in-place change body\n"},
236 })
237 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
238 !strings.Contains(body, `id="request-description"`) || !strings.Contains(body, "in-place change body") ||
239 strings.Contains(body, `id="request-head"`) {
240 t.Fatalf("change inline description edit = %d:\n%s", resp.StatusCode, body)
241 }
242 if req, err = metadata.GetRequest(mustRepo(t, f).Git(), changeID); err != nil ||
243 req.Title != "In-place change" || strings.TrimSpace(req.Description) != "in-place change body" {
244 t.Fatalf("inline change description edit = %+v, %v", req, err)
245 }
ed03b3d1
Keenan Nemetz
6h ago
246 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+changeID+"/edit", cookie, url.Values{
802e0e52
Keenan Nemetz
5d ago
247 "csrf": {sess.CSRF}, "field": {"title"}, "title": {""},
248 })
249 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
250 !strings.Contains(body, "a change needs a title") || !strings.Contains(body, `data-title-edit-form`) ||
251 !strings.Contains(body, `autofocus`) || strings.Contains(body, `id="request-description"`) {
252 t.Fatalf("refused inline change title edit = %d:\n%s", resp.StatusCode, body)
253 }
254
a4e7a99a
Keenan Nemetz
6d ago
255 // Somebody who is neither the author nor a maintainer may open neither form
256 // and may not post either edit.
257 pub, _ := keypair(t, filepath.Join(f.cfg.DataDir, "id_stranger"))
258 stranger, err := f.srv.Identity.Create("stranger", pub, "test")
259 if err != nil {
260 t.Fatal(err)
261 }
262 strangerSess, strangerCookie := f.browserSession(t, stranger.Name)
263 for _, path := range []string{
ed03b3d1
Keenan Nemetz
6h ago
264 "/" + f.repo + "/issues/" + issueID + "/edit",
265 "/" + f.repo + "/changes/" + changeID + "/edit",
a4e7a99a
Keenan Nemetz
6d ago
266 } {
267 if resp, _ := f.browserGet(t, path, strangerCookie); resp.StatusCode != http.StatusForbidden {
268 t.Fatalf("stranger got %d from %s, want 403", resp.StatusCode, path)
269 }
270 }
271 for _, path := range []string{
ed03b3d1
Keenan Nemetz
6h ago
272 "/" + f.repo + "/issues/" + issueID + "/edit",
273 "/" + f.repo + "/changes/" + changeID + "/edit",
a4e7a99a
Keenan Nemetz
6d ago
274 } {
275 resp, _ := f.browserPost(t, path, strangerCookie, url.Values{
276 "csrf": {strangerSess.CSRF}, "title": {"Not mine"}, "body": {"not mine\n"},
277 })
278 if resp.StatusCode != http.StatusForbidden {
279 t.Fatalf("stranger's edit of %s = %d, want 403", path, resp.StatusCode)
280 }
281 }
282 }
283
f09ef39e
Keenan Nemetz
15d ago
284 func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) {
285 t.Parallel()
286 f := newFixture(t)
287 f.publish(f.repo)
288
289 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
290 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
291 t.Fatalf("main push: %s", errs)
292 }
293 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
294 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
295 t.Fatalf("feature push: %s", errs)
296 }
297
298 sess, cookie := f.browserSession(t, f.ownerID)
ed03b3d1
Keenan Nemetz
6h ago
299 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
300 "csrf": {sess.CSRF}, "title": {"Browser change"}, "body": {"please review\n"},
301 "base": {"main"}, "head": {"feature"},
302 })
303 if resp.StatusCode != http.StatusSeeOther {
304 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
305 }
ed03b3d1
Keenan Nemetz
6h ago
306 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
f09ef39e
Keenan Nemetz
15d ago
307 if id == "" {
308 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
309 }
310
ed03b3d1
Keenan Nemetz
6h ago
311 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
312 "csrf": {sess.CSRF}, "body": {"Looks good from the browser.\n"},
313 })
314 if resp.StatusCode != http.StatusSeeOther {
315 t.Fatalf("comment: %d\n%s", resp.StatusCode, body)
316 }
ed03b3d1
Keenan Nemetz
6h ago
317 _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
f09ef39e
Keenan Nemetz
15d ago
318 if !strings.Contains(page, "Looks good from the browser.") {
319 t.Fatalf("the browser comment is not on the page:\n%s", page)
320 }
321
ed03b3d1
Keenan Nemetz
6h ago
322 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
323 "csrf": {sess.CSRF}, "state": {"closed"},
324 })
325 if resp.StatusCode != http.StatusSeeOther {
326 t.Fatalf("close change: %d\n%s", resp.StatusCode, body)
327 }
ed03b3d1
Keenan Nemetz
6h ago
328 if _, list := f.browserGet(t, "/"+f.repo+"/changes", cookie); strings.Contains(list, "Browser change") {
f09ef39e
Keenan Nemetz
15d ago
329 t.Fatalf("closed change is still in the open list:\n%s", list)
330 }
ed03b3d1
Keenan Nemetz
6h ago
331 if _, list := f.browserGet(t, "/"+f.repo+"/changes?state=all", cookie); !strings.Contains(list, "Browser change") {
f09ef39e
Keenan Nemetz
15d ago
332 t.Fatalf("closed change is missing from the All list:\n%s", list)
333 }
ed03b3d1
Keenan Nemetz
6h ago
334 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie); !strings.Contains(page, "Reopen</button>") {
f09ef39e
Keenan Nemetz
15d ago
335 t.Fatalf("closed change does not offer a reopen:\n%s", page)
336 }
337
ed03b3d1
Keenan Nemetz
6h ago
338 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
339 "csrf": {sess.CSRF}, "state": {"open"},
340 })
341 if resp.StatusCode != http.StatusSeeOther {
342 t.Fatalf("reopen change: %d\n%s", resp.StatusCode, body)
343 }
ed03b3d1
Keenan Nemetz
6h ago
344 if _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie); !strings.Contains(page, "Close</button>") ||
f6f4b1db
Keenan Nemetz
15d ago
345 !strings.Contains(page, `class="danger"`) {
f09ef39e
Keenan Nemetz
15d ago
346 t.Fatalf("reopened change does not offer a close:\n%s", page)
347 }
348 }
349
350 func TestNewIssueAndChangePages(t *testing.T) {
351 t.Parallel()
352 f := newFixture(t)
353 f.publish(f.repo)
354 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
355 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
356 t.Fatalf("main push: %s", errs)
357 }
358 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
359 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
360 t.Fatalf("feature push: %s", errs)
361 }
362 sess, cookie := f.browserSession(t, f.ownerID)
363
ed03b3d1
Keenan Nemetz
6h ago
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") {
f09ef39e
Keenan Nemetz
15d ago
366 t.Fatalf("new issue page = %d:\n%s", resp.StatusCode, body)
367 }
368 // The CSRF token is on the form, not in a popover that has to be opened first.
369 if !strings.Contains(body, sess.CSRF) {
370 t.Fatal("new issue page has no CSRF token")
371 }
372
ed03b3d1
Keenan Nemetz
6h ago
373 resp, body = f.browserGet(t, "/"+f.repo+"/changes/new", cookie)
374 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/`+f.repo+`/changes"`) ||
b06184d1
Keenan Nemetz
11d ago
375 !strings.Contains(body, "feature") || !strings.Contains(body, "Open change") {
f09ef39e
Keenan Nemetz
15d ago
376 t.Fatalf("new change page = %d:\n%s", resp.StatusCode, body)
377 }
378
379 // The list page links to the pages instead of embedding the forms.
ed03b3d1
Keenan Nemetz
6h ago
380 if _, page := f.browserGet(t, "/"+f.repo+"/issues", cookie); !strings.Contains(page, "/issues/new") {
f09ef39e
Keenan Nemetz
15d ago
381 t.Fatal("issues list does not link to the new issue page")
382 }
ed03b3d1
Keenan Nemetz
6h ago
383 if _, page := f.browserGet(t, "/"+f.repo+"/changes", cookie); !strings.Contains(page, "/changes/new") {
f09ef39e
Keenan Nemetz
15d ago
384 t.Fatal("changes list does not link to the new change page")
385 }
386 }
387
388 func TestIssueLabelsCanBeSetAndEditedInTheBrowser(t *testing.T) {
389 t.Parallel()
390 f := newFixture(t)
391 f.publish(f.repo)
392 sess, cookie := f.browserSession(t, f.ownerID)
393
ed03b3d1
Keenan Nemetz
6h ago
394 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
395 "csrf": {sess.CSRF}, "title": {"Labels from the browser"}, "body": {"body\n"},
306f36fc
Keenan Nemetz
12d ago
396 "label": {"bug", "web", "perf", "docs"},
f09ef39e
Keenan Nemetz
15d ago
397 })
398 if resp.StatusCode != http.StatusSeeOther {
399 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
400 }
ed03b3d1
Keenan Nemetz
6h ago
401 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
f09ef39e
Keenan Nemetz
15d ago
402 if id == "" {
403 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
404 }
405 iss, err := index.LoadIssue(mustRepo(t, f), id)
406 if err != nil {
407 t.Fatal(err)
408 }
409 if got := strings.Join(iss.Labels, ","); got != "bug,docs,perf,web" {
410 t.Fatalf("created labels = %q", got)
411 }
412
413 // The detail page seeds the token input with the current labels and offers a
414 // save form. The browser turns that value into chips.
ed03b3d1
Keenan Nemetz
6h ago
415 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
f09ef39e
Keenan Nemetz
15d ago
416 for _, want := range []string{`hx-trigger="token-change"`, `value="bug, docs, perf, web"`, `data-label-input`} {
417 if !strings.Contains(page, want) {
418 t.Fatalf("issue page does not show %q:\n%s", want, page)
419 }
420 }
421 // A new issue page offers the labels already used in the repository.
ed03b3d1
Keenan Nemetz
6h ago
422 if _, page := f.browserGet(t, "/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="bug"`) {
f09ef39e
Keenan Nemetz
15d ago
423 t.Fatalf("new issue page has no existing label picker:\n%s", page)
424 }
425
ed03b3d1
Keenan Nemetz
6h ago
426 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
306f36fc
Keenan Nemetz
12d ago
427 "csrf": {sess.CSRF}, "label": {"bug", "ux"},
f09ef39e
Keenan Nemetz
15d ago
428 })
429 if resp.StatusCode != http.StatusSeeOther {
430 t.Fatalf("edit labels: %d\n%s", resp.StatusCode, body)
431 }
432 iss, err = index.LoadIssue(mustRepo(t, f), id)
433 if err != nil {
434 t.Fatal(err)
435 }
436 if got := strings.Join(iss.Labels, ","); got != "bug,ux" {
437 t.Fatalf("edited labels = %q", got)
438 }
ed03b3d1
Keenan Nemetz
6h ago
439 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
f09ef39e
Keenan Nemetz
15d ago
440 if !strings.Contains(page, `value="bug, ux"`) || strings.Contains(page, `value="bug, docs, perf, web"`) {
441 t.Fatalf("edited labels are not reflected in the picker:\n%s", page)
442 }
443 }
444
445 func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) {
446 t.Parallel()
447 f := newFixture(t)
448 f.publish(f.repo)
449 f.addWriter(t) // wilbur: maintain access, so assignable
450 sess, cookie := f.browserSession(t, f.ownerID)
451
ed03b3d1
Keenan Nemetz
6h ago
452 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
453 "csrf": {sess.CSRF}, "title": {"Assignees from the browser"}, "body": {"body\n"},
306f36fc
Keenan Nemetz
12d ago
454 "assignee": {"wilbur", "keenan"},
f09ef39e
Keenan Nemetz
15d ago
455 })
456 if resp.StatusCode != http.StatusSeeOther {
457 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
458 }
ed03b3d1
Keenan Nemetz
6h ago
459 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
f09ef39e
Keenan Nemetz
15d ago
460 if id == "" {
461 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
462 }
463 iss, err := index.LoadIssue(mustRepo(t, f), id)
464 if err != nil {
465 t.Fatal(err)
466 }
467 if got := strings.Join(iss.Assignees, ","); got != "keenan,wilbur" {
468 t.Fatalf("created assignees = %q", got)
469 }
470
ed03b3d1
Keenan Nemetz
6h ago
471 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
f09ef39e
Keenan Nemetz
15d ago
472 for _, want := range []string{`hx-trigger="token-change"`, `value="keenan, wilbur"`, `data-label-item="assignee"`} {
473 if !strings.Contains(page, want) {
474 t.Fatalf("issue page does not show %q:\n%s", want, page)
475 }
476 }
ed03b3d1
Keenan Nemetz
6h ago
477 if _, page := f.browserGet(t, "/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="wilbur"`) {
f09ef39e
Keenan Nemetz
15d ago
478 t.Fatalf("new issue page does not offer wilbur as an assignee:\n%s", page)
479 }
480
ed03b3d1
Keenan Nemetz
6h ago
481 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
482 "csrf": {sess.CSRF}, "assignee": {"wilbur"},
483 })
484 if resp.StatusCode != http.StatusSeeOther {
485 t.Fatalf("edit assignees: %d\n%s", resp.StatusCode, body)
486 }
487 iss, err = index.LoadIssue(mustRepo(t, f), id)
488 if err != nil {
489 t.Fatal(err)
490 }
491 if got := strings.Join(iss.Assignees, ","); got != "wilbur" {
492 t.Fatalf("edited assignees = %q", got)
493 }
ed03b3d1
Keenan Nemetz
6h ago
494 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+id, cookie)
f09ef39e
Keenan Nemetz
15d ago
495 if !strings.Contains(page, `value="wilbur"`) || strings.Contains(page, `value="keenan, wilbur"`) {
496 t.Fatalf("edited assignees are not reflected in the picker:\n%s", page)
497 }
498
499 // An assignee nobody can resolve is a typo, not a person.
ed03b3d1
Keenan Nemetz
6h ago
500 resp, body = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
501 "csrf": {sess.CSRF}, "title": {"Bad assignee"}, "body": {"body\n"},
502 "assignee": {"nobody"},
503 })
504 if resp.StatusCode != http.StatusBadRequest {
505 t.Fatalf("unknown assignee accepted: %d\n%s", resp.StatusCode, body)
506 }
507 }
508
267feec2
Keenan Nemetz
10d ago
509 func TestChangeLinksIssuesFromTheIssuesSection(t *testing.T) {
cad93cfe
Keenan Nemetz
10d ago
510 t.Parallel()
511 f := newFixture(t)
512 f.publish(f.repo)
513
514 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
515 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
516 t.Fatalf("main push: %s", errs)
517 }
518 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
519 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
520 t.Fatalf("feature push: %s", errs)
521 }
522
523 sess, cookie := f.browserSession(t, f.ownerID)
524 createIssue := func(title string) string {
525 t.Helper()
ed03b3d1
Keenan Nemetz
6h ago
526 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
cad93cfe
Keenan Nemetz
10d ago
527 "csrf": {sess.CSRF}, "title": {title}, "body": {"body\n"},
528 })
529 if resp.StatusCode != http.StatusSeeOther {
530 t.Fatalf("create issue %q: %d\n%s", title, resp.StatusCode, body)
531 }
ed03b3d1
Keenan Nemetz
6h ago
532 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
cad93cfe
Keenan Nemetz
10d ago
533 if id == "" {
534 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
535 }
536 return id
537 }
538 first := createIssue("First linked issue")
539 second := createIssue("Second linked issue")
540
ed03b3d1
Keenan Nemetz
6h ago
541 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
cad93cfe
Keenan Nemetz
10d ago
542 "csrf": {sess.CSRF}, "title": {"Linked issues"}, "body": {"body\n"},
543 "base": {"main"}, "head": {"feature"}, "closes": {first},
544 })
545 if resp.StatusCode != http.StatusSeeOther {
546 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
547 }
ed03b3d1
Keenan Nemetz
6h ago
548 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
cad93cfe
Keenan Nemetz
10d ago
549 if id == "" {
550 t.Fatalf("no change id in %q", resp.Header.Get("Location"))
551 }
552
267feec2
Keenan Nemetz
10d ago
553 // The Issues section uses the overview's issue rows and offers the link
cad93cfe
Keenan Nemetz
10d ago
554 // controls instead of the old one-line "Closes ... when merged".
ed03b3d1
Keenan Nemetz
6h ago
555 _, page := f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
cad93cfe
Keenan Nemetz
10d ago
556 for _, want := range []string{
267feec2
Keenan Nemetz
10d ago
557 `id="request-issues"`, "First linked issue", `value="` + first + `"`,
838f282e
Keenan Nemetz
10d ago
558 `value="remove"`, `request-unlink`, `class="request-issue-search"`,
ed03b3d1
Keenan Nemetz
6h ago
559 `hx-get="/` + f.repo + `/changes/` + id + `/live"`,
707dfd10
Keenan Nemetz
10d ago
560 `hx-trigger="every 20s [document.visibilityState === 'visible']"`, `hx-swap="none"`,
cad93cfe
Keenan Nemetz
10d ago
561 } {
562 if !strings.Contains(page, want) {
563 t.Fatalf("the request page is missing %q:\n%s", want, page)
564 }
565 }
566 if strings.Contains(page, "when merged.") {
567 t.Fatalf("the old closes line is still on the page:\n%s", page)
568 }
707dfd10
Keenan Nemetz
10d ago
569 if strings.Contains(page, "document.activeElement") {
570 t.Fatalf("the poll still guards on focus instead of updating only data regions:\n%s", page)
571 }
cad93cfe
Keenan Nemetz
10d ago
572
838f282e
Keenan Nemetz
10d ago
573 // The search box filters by title, `#number` and ULID prefix. A match is a
574 // button that posts the link, and the Issues section comes back in place.
cad93cfe
Keenan Nemetz
10d ago
575 m := mustRepo(t, f)
576 nums, err := metadata.LoadNumbers(m.Git(), metadata.Ref)
577 if err != nil {
578 t.Fatal(err)
579 }
580 number, ok := nums.Of(second)
581 if !ok {
582 t.Fatalf("the second issue has no number")
583 }
838f282e
Keenan Nemetz
10d ago
584 for _, query := range []string{"Second", "#" + strconv.Itoa(number), second[:8]} {
ed03b3d1
Keenan Nemetz
6h ago
585 resp, results := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q="+url.QueryEscape(query), cookie)
838f282e
Keenan Nemetz
10d ago
586 if resp.StatusCode != http.StatusOK || !strings.Contains(results, "Second linked issue") ||
587 !strings.Contains(results, `value="`+second+`"`) || !strings.Contains(results, `value="add"`) ||
bb541fde
Keenan Nemetz
10d ago
588 !strings.Contains(results, `hx-target="#request-issues"`) ||
589 !strings.Contains(results, `request-issue-popover`) {
838f282e
Keenan Nemetz
10d ago
590 t.Fatalf("search %q is not the expected result (%d):\n%s", query, resp.StatusCode, results)
591 }
592 }
593 // An issue already linked is not offered, so a shared title cannot hide the
594 // rows that are still available.
ed03b3d1
Keenan Nemetz
6h ago
595 resp, results := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q=First", cookie)
838f282e
Keenan Nemetz
10d ago
596 if resp.StatusCode != http.StatusOK || strings.Contains(results, "First linked issue") ||
597 !strings.Contains(results, "No issues match") {
598 t.Fatalf("a linked issue is still in the search results (%d):\n%s", resp.StatusCode, results)
599 }
bb541fde
Keenan Nemetz
10d ago
600 // Clearing the field removes the popover rather than leaving a blank panel.
ed03b3d1
Keenan Nemetz
6h ago
601 resp, results = f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/issues/search?q=", cookie)
bb541fde
Keenan Nemetz
10d ago
602 if resp.StatusCode != http.StatusOK || strings.TrimSpace(results) != "" {
603 t.Fatalf("an empty search left a panel (%d):\n%s", resp.StatusCode, results)
604 }
838f282e
Keenan Nemetz
10d ago
605
606 // Clicking a result posts its id, and the section comes back with both links.
ed03b3d1
Keenan Nemetz
6h ago
607 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
838f282e
Keenan Nemetz
10d ago
608 "csrf": {sess.CSRF}, "action": {"add"}, "issue": {second},
cad93cfe
Keenan Nemetz
10d ago
609 })
267feec2
Keenan Nemetz
10d ago
610 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-issues"`) ||
cad93cfe
Keenan Nemetz
10d ago
611 !strings.Contains(body, "Second linked issue") || !strings.Contains(body, "First linked issue") {
612 t.Fatalf("add issue response is not the expected section (%d):\n%s", resp.StatusCode, body)
613 }
614 if got, err := metadata.GetRequest(m.Git(), id); err != nil || len(got.Closes) != 2 {
615 t.Fatalf("linked issues = %v, %v; want both", got, err)
616 }
617
618 // Removing a link is the same write, and the row disappears.
ed03b3d1
Keenan Nemetz
6h ago
619 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
cad93cfe
Keenan Nemetz
10d ago
620 "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {first},
621 })
267feec2
Keenan Nemetz
10d ago
622 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-issues"`) ||
cad93cfe
Keenan Nemetz
10d ago
623 strings.Contains(body, "First linked issue") || !strings.Contains(body, "Second linked issue") {
624 t.Fatalf("remove issue response is not the expected section (%d):\n%s", resp.StatusCode, body)
625 }
626 got, err := metadata.GetRequest(m.Git(), id)
627 if err != nil || len(got.Closes) != 1 || got.Closes[0] != second {
628 t.Fatalf("linked issues = %v, %v; want only %s", got, err, second)
629 }
630
631 // A duplicate says so beside the control instead of rewriting the list.
ed03b3d1
Keenan Nemetz
6h ago
632 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
cad93cfe
Keenan Nemetz
10d ago
633 "csrf": {sess.CSRF}, "action": {"add"}, "issue": {second},
634 })
635 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "already linked") {
636 t.Fatalf("duplicate link response (%d):\n%s", resp.StatusCode, body)
637 }
638
639 // A closed request keeps its record but offers no edits, and the writer
640 // refuses one posted anyway.
ed03b3d1
Keenan Nemetz
6h ago
641 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/state", cookie, url.Values{
cad93cfe
Keenan Nemetz
10d ago
642 "csrf": {sess.CSRF}, "state": {"closed"},
643 })
644 if resp.StatusCode != http.StatusSeeOther {
645 t.Fatalf("close change: %d\n%s", resp.StatusCode, body)
646 }
ed03b3d1
Keenan Nemetz
6h ago
647 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+id, cookie)
838f282e
Keenan Nemetz
10d ago
648 if !strings.Contains(page, "Second linked issue") || strings.Contains(page, `request-unlink`) ||
649 strings.Contains(page, `class="request-issue-search"`) {
267feec2
Keenan Nemetz
10d ago
650 t.Fatalf("a closed request does not show the right Issues section:\n%s", page)
cad93cfe
Keenan Nemetz
10d ago
651 }
ed03b3d1
Keenan Nemetz
6h ago
652 resp, _ = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/issues", cookie, url.Values{
cad93cfe
Keenan Nemetz
10d ago
653 "csrf": {sess.CSRF}, "action": {"remove"}, "issue": {second},
654 })
655 if resp.StatusCode != http.StatusForbidden {
656 t.Fatalf("a closed request changed its issues: status=%d", resp.StatusCode)
657 }
658 }
659
0789f22e
Keenan Nemetz
9d ago
660 func TestRepositoryCanRequireALinkedIssueForNewChanges(t *testing.T) {
661 t.Parallel()
662 f := newFixture(t)
663 f.publish(f.repo)
664
665 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
666 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
667 t.Fatalf("main push: %s", errs)
668 }
669 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
670 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
671 t.Fatalf("feature push: %s", errs)
672 }
673
674 m := mustRepo(t, f)
675 a, err := m.ACL()
676 if err != nil {
677 t.Fatal(err)
678 }
679 a.RequireLinkedIssue = true
680 if err := m.SetACL(a, "test"); err != nil {
681 t.Fatal(err)
682 }
683
684 sess, cookie := f.browserSession(t, f.ownerID)
ed03b3d1
Keenan Nemetz
6h ago
685 resp, body := f.browserGet(t, "/"+f.repo+"/changes/new", cookie)
0789f22e
Keenan Nemetz
9d ago
686 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "requires a linked issue") ||
687 !strings.Contains(body, "required") {
688 t.Fatalf("new change page does not explain the rule (%d):\n%s", resp.StatusCode, body)
689 }
690
691 form := url.Values{
692 "csrf": {sess.CSRF}, "title": {"Unlinked change"}, "body": {"body\n"},
693 "base": {"main"}, "head": {"feature"},
694 }
ed03b3d1
Keenan Nemetz
6h ago
695 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, form)
0789f22e
Keenan Nemetz
9d ago
696 if resp.StatusCode != http.StatusBadRequest || !strings.Contains(body, "requires at least one linked issue") {
697 t.Fatalf("an unlinked change was accepted (%d):\n%s", resp.StatusCode, body)
698 }
699
ed03b3d1
Keenan Nemetz
6h ago
700 resp, body = f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
0789f22e
Keenan Nemetz
9d ago
701 "csrf": {sess.CSRF}, "title": {"The linked issue"}, "body": {"body\n"},
702 })
703 if resp.StatusCode != http.StatusSeeOther {
704 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
705 }
ed03b3d1
Keenan Nemetz
6h ago
706 issueID := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
0789f22e
Keenan Nemetz
9d ago
707 if issueID == "" {
708 t.Fatalf("no issue id in %q", resp.Header.Get("Location"))
709 }
710
711 form.Set("title", "Linked change")
712 form.Set("closes", issueID)
ed03b3d1
Keenan Nemetz
6h ago
713 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, form)
0789f22e
Keenan Nemetz
9d ago
714 if resp.StatusCode != http.StatusSeeOther {
715 t.Fatalf("a linked change was rejected (%d):\n%s", resp.StatusCode, body)
716 }
ed03b3d1
Keenan Nemetz
6h ago
717 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
0789f22e
Keenan Nemetz
9d ago
718 got, err := metadata.GetRequest(m.Git(), id)
719 if err != nil {
720 t.Fatal(err)
721 }
722 if len(got.Closes) != 1 || got.Closes[0] != issueID {
723 t.Fatalf("linked issues = %v, want %s", got.Closes, issueID)
724 }
725 }
726
f09ef39e
Keenan Nemetz
15d ago
727 func (f *fixture) htmxPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) {
728 t.Helper()
729 req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode()))
730 if err != nil {
731 t.Fatal(err)
732 }
733 req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
734 req.Header.Set("Cookie", cookie)
735 req.Header.Set("HX-Request", "true")
736 resp, err := noRedirectAuth.Do(req)
737 if err != nil {
738 t.Fatal(err)
739 }
740 defer resp.Body.Close()
741 body, _ := io.ReadAll(resp.Body)
742 return resp, string(body)
743 }
744
745 func TestIssueSidebarFragmentsUpdateWithHtmx(t *testing.T) {
746 t.Parallel()
747 f := newFixture(t)
748 f.publish(f.repo)
749 f.addWriter(t)
750 sess, cookie := f.browserSession(t, f.ownerID)
751
ed03b3d1
Keenan Nemetz
6h ago
752 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
753 "csrf": {sess.CSRF}, "title": {"HTMX issue"}, "body": {"body\n"},
754 })
755 if resp.StatusCode != http.StatusSeeOther {
756 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
757 }
ed03b3d1
Keenan Nemetz
6h ago
758 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/issues/")
f09ef39e
Keenan Nemetz
15d ago
759
760 // Labels: the response is the fragment, not a page and not a redirect.
ed03b3d1
Keenan Nemetz
6h ago
761 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{
306f36fc
Keenan Nemetz
12d ago
762 "csrf": {sess.CSRF}, "label": {"bug", "ux"},
f09ef39e
Keenan Nemetz
15d ago
763 })
764 if resp.StatusCode != http.StatusOK {
765 t.Fatalf("htmx labels: %d\n%s", resp.StatusCode, body)
766 }
767 if strings.Contains(body, "<!doctype html>") || !strings.Contains(body, `id="issue-labels"`) ||
768 !strings.Contains(body, `value="bug, ux"`) {
769 t.Fatalf("labels response is not the expected fragment:\n%s", body)
770 }
771
772 // Assignees: the replacement fragment seeds the token input and keeps the
306f36fc
Keenan Nemetz
12d ago
773 // token-change trigger.
ed03b3d1
Keenan Nemetz
6h ago
774 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
775 "csrf": {sess.CSRF}, "assignee": {"wilbur"},
776 })
777 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-assignees"`) ||
778 !strings.Contains(body, `value="wilbur"`) || !strings.Contains(body, `hx-trigger="token-change"`) {
779 t.Fatalf("assignees response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
780 }
781
782 // Comments: the thread fragment includes the new comment and the composer.
ed03b3d1
Keenan Nemetz
6h ago
783 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+id+"/comments", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
784 "csrf": {sess.CSRF}, "body": {"Posted over HTMX.\n"},
785 })
786 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) ||
787 !strings.Contains(body, "Posted over HTMX.") {
788 t.Fatalf("comment response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
789 }
790 }
791
792 func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) {
793 t.Parallel()
794 f := newFixture(t)
795 f.addWriter(t) // wilbur: maintain, and not the request author
796 f.publish(f.repo)
797
798 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
799 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
800 t.Fatalf("main push: %s", errs)
801 }
802 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
803 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
804 t.Fatalf("feature push: %s", errs)
805 }
806
807 ownerSess, ownerCookie := f.browserSession(t, f.ownerID)
ed03b3d1
Keenan Nemetz
6h ago
808 resp, body := f.browserPost(t, "/"+f.repo+"/changes", ownerCookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
809 "csrf": {ownerSess.CSRF}, "title": {"HTMX change"}, "body": {"body\n"},
810 "base": {"main"}, "head": {"feature"},
811 })
812 if resp.StatusCode != http.StatusSeeOther {
813 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
814 }
ed03b3d1
Keenan Nemetz
6h ago
815 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
f09ef39e
Keenan Nemetz
15d ago
816
707dfd10
Keenan Nemetz
10d ago
817 // A maintainer approves; the data-only regions and the action buttons come
818 // back as out-of-band updates, so the Issues section and any open search are
819 // left alone.
f09ef39e
Keenan Nemetz
15d ago
820 writerSess, writerCookie := f.browserSession(t, f.writerID)
e050e14a
Keenan Nemetz
13d ago
821 request, err := metadata.GetRequest(mustRepo(t, f).Git(), id)
822 if err != nil {
823 t.Fatal(err)
824 }
ed03b3d1
Keenan Nemetz
6h ago
825 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/approve", writerCookie, url.Values{
e050e14a
Keenan Nemetz
13d ago
826 "csrf": {writerSess.CSRF}, "base": {request.Base}, "head": {request.Head},
f09ef39e
Keenan Nemetz
15d ago
827 })
707dfd10
Keenan Nemetz
10d ago
828 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `hx-swap-oob="outerHTML"`) ||
829 !strings.Contains(body, `id="request-approvals"`) || !strings.Contains(body, "1 of 1 required approval(s).") ||
830 !strings.Contains(body, `id="request-actions"`) || !strings.Contains(body, `id="request-review-status"`) {
831 t.Fatalf("approval response is not the expected live update (%d):\n%s", resp.StatusCode, body)
832 }
833 for _, unwanted := range []string{`id="request-issues"`, `id="request-target"`, `request-issue-search`} {
834 if strings.Contains(body, unwanted) {
835 t.Fatalf("approval response reset unrelated state %q:\n%s", unwanted, body)
836 }
f09ef39e
Keenan Nemetz
15d ago
837 }
838
707dfd10
Keenan Nemetz
10d ago
839 // The page's poll returns only out-of-band data regions. It has no sidebar
840 // wrapper and no form, so it cannot close a search or replace a picker.
ed03b3d1
Keenan Nemetz
6h ago
841 resp, body = f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/live", writerCookie)
d3dcf54d
Keenan Nemetz
11d ago
842 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") ||
707dfd10
Keenan Nemetz
10d ago
843 strings.Contains(body, "<form") || strings.Contains(body, `id="request-aside"`) ||
844 strings.Contains(body, `id="request-sidebar"`) || strings.Contains(body, `request-issues`) {
845 t.Fatalf("live poll is not the expected data-only fragment (%d):\n%s", resp.StatusCode, body)
846 }
847 for _, want := range []string{
848 `hx-swap-oob="outerHTML"`, `id="request-review-status"`, `id="request-changes"`,
849 `id="request-approvals"`, `id="request-checks"`, `id="request-combined"`,
850 `id="request-merge-note"`, "Merges into main cleanly", "1 of 1 required approval(s).",
851 `class="help"`,
852 } {
853 if !strings.Contains(body, want) {
854 t.Fatalf("live poll is missing %q (%d):\n%s", want, resp.StatusCode, body)
855 }
d3dcf54d
Keenan Nemetz
11d ago
856 }
857
f09ef39e
Keenan Nemetz
15d ago
858 // Posting a comment replaces only the discussion fragment.
ed03b3d1
Keenan Nemetz
6h ago
859 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", ownerCookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
860 "csrf": {ownerSess.CSRF}, "body": {"Review note over HTMX.\n"},
861 })
862 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) ||
863 !strings.Contains(body, "Review note over HTMX.") {
864 t.Fatalf("discussion response is not the expected fragment (%d):\n%s", resp.StatusCode, body)
865 }
866 }
867
868 func (f *fixture) htmxGet(t *testing.T, path, cookie string) (*http.Response, string) {
869 t.Helper()
870 req, err := http.NewRequest("GET", f.http.URL+path, nil)
871 if err != nil {
872 t.Fatal(err)
873 }
874 req.Header.Set("Cookie", cookie)
875 req.Header.Set("HX-Request", "true")
876 resp, err := noRedirectAuth.Do(req)
877 if err != nil {
878 t.Fatal(err)
879 }
880 defer resp.Body.Close()
881 body, _ := io.ReadAll(resp.Body)
882 return resp, string(body)
883 }
884
885 func TestIssueAndChangeListsReturnFragmentsForHtmx(t *testing.T) {
886 t.Parallel()
887 f := newFixture(t)
888 f.publish(f.repo)
889 files := map[string]string{
890 "issues/01M5JR2JK29KKMAA1T9MDNTPD1/issue.md": issueFile("Open thing", "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
891 "issues/01M5JR2JK29KKMAA1T9MDNTPD2/issue.md": issueFile("Closed thing", "keenan", "closed", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
892 }
893 work := f.issuesRepo(t, files)
894 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
895 t.Fatalf("push: %s", errs)
896 }
897
ed03b3d1
Keenan Nemetz
6h ago
898 resp, body := f.htmxGet(t, "/"+f.repo+"/issues?state=closed", "")
f09ef39e
Keenan Nemetz
15d ago
899 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-list"`) ||
900 strings.Contains(body, "<!doctype html>") {
901 t.Fatalf("issues fragment = %d:\n%s", resp.StatusCode, body)
902 }
903 if !strings.Contains(body, "Closed thing") || strings.Contains(body, "Open thing") {
904 t.Fatalf("issues fragment did not apply the state filter:\n%s", body)
905 }
6813339b
Keenan Nemetz
4d ago
906 // The list uses the same status shape as the change list, not the state pill
907 // the page headers still use.
908 if !strings.Contains(body, `class="row-status closed"`) || strings.Contains(body, `class="state closed"`) {
909 t.Fatalf("the closed issue does not use the list status style:\n%s", body)
910 }
f09ef39e
Keenan Nemetz
15d ago
911 if !strings.Contains(body, `hx-target="#issue-list"`) {
912 t.Fatal("issues fragment lost its HTMX target")
913 }
914
ed03b3d1
Keenan Nemetz
6h ago
915 resp, body = f.htmxGet(t, "/"+f.repo+"/changes?state=all", "")
f09ef39e
Keenan Nemetz
15d ago
916 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="change-list"`) ||
917 strings.Contains(body, "<!doctype html>") {
918 t.Fatalf("changes fragment = %d:\n%s", resp.StatusCode, body)
919 }
920 if !strings.Contains(body, `hx-target="#change-list"`) {
921 t.Fatal("changes fragment lost its HTMX target")
922 }
923 }
924
f64c77e4
Keenan Nemetz
9d ago
925 // TestRequestFilesListsEveryChangedFile: the changed-file tree and the count come
926 // from the change's inventory, so a file larger than a page can render does not
927 // hide the files named after it. The patch the tab shows is still one file's.
928 func TestRequestFilesListsEveryChangedFile(t *testing.T) {
929 t.Parallel()
930 f := newFixture(t)
931 f.publish(f.repo)
932 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
933 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
934 t.Fatalf("main push: %s", errs)
935 }
936 // a-big.txt sorts first and is larger than the 512 KiB that used to bound the
937 // patch the tree was parsed from, so the files after it were never listed.
938 files := map[string]string{"a-big.txt": strings.Repeat("x", 600<<10) + "\n"}
939 for _, name := range []string{"b.txt", "docs/c.txt", "z.txt"} {
940 files[name] = name + "\n"
941 }
942 feature, _ := f.cloneWithChange(t, files, "a big change")
bef0c093
Keenan Nemetz
9d ago
943 // A deletion rides along: it has one path and no new one, which is the shape
944 // that used to make the page fail when it was opened.
945 if _, errs, err := runGit(t, feature, "rm", "-q", "readme.md"); err != nil {
946 t.Fatalf("rm: %s", errs)
947 }
948 if _, errs, err := runGit(t, feature, "commit", "-qm", "drop the readme"); err != nil {
949 t.Fatalf("commit: %s", errs)
950 }
f64c77e4
Keenan Nemetz
9d ago
951 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
952 t.Fatalf("feature push: %s", errs)
953 }
954 sess, cookie := f.browserSession(t, f.ownerID)
ed03b3d1
Keenan Nemetz
6h ago
955 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
f64c77e4
Keenan Nemetz
9d ago
956 "csrf": {sess.CSRF}, "title": {"Every file"}, "body": {"body\n"},
957 "base": {"main"}, "head": {"feature"},
958 })
959 if resp.StatusCode != http.StatusSeeOther {
960 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
961 }
ed03b3d1
Keenan Nemetz
6h ago
962 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
f64c77e4
Keenan Nemetz
9d ago
963
ed03b3d1
Keenan Nemetz
6h ago
964 filesURL := "/" + f.repo + "/changes/" + id + "/files"
f64c77e4
Keenan Nemetz
9d ago
965 _, page := f.browserGet(t, filesURL, cookie)
bef0c093
Keenan Nemetz
9d ago
966 if !strings.Contains(page, "5 changed") {
f64c77e4
Keenan Nemetz
9d ago
967 t.Errorf("the tree does not count every file:\n%.2000s", page)
968 }
bef0c093
Keenan Nemetz
9d ago
969 for _, name := range []string{"a-big.txt", "b.txt", "docs/c.txt", "readme.md", "z.txt"} {
f64c77e4
Keenan Nemetz
9d ago
970 if !strings.Contains(page, filesURL+"/"+name) {
971 t.Errorf("the tree is missing %s:\n%.2000s", name, page)
972 }
973 }
974 // The open file is one file's patch, and it is whole.
975 _, page = f.browserGet(t, filesURL+"/z.txt", cookie)
976 if !strings.Contains(page, `<span class="path">z.txt</span>`) ||
977 !strings.Contains(page, `data-highlight-side="new">z.txt<`) {
978 t.Fatalf("the selected file has no patch:\n%.2000s", page)
979 }
bef0c093
Keenan Nemetz
9d ago
980 // The deletion opens too: one path, no new one, and its removed lines.
981 resp, page = f.browserGet(t, filesURL+"/readme.md", cookie)
982 if resp.StatusCode != http.StatusOK {
983 t.Fatalf("deleted file = %d:\n%.2000s", resp.StatusCode, page)
984 }
985 if !strings.Contains(page, `data-highlight-side="old">v1<`) {
986 t.Fatalf("the deleted file has no patch:\n%.2000s", page)
987 }
f64c77e4
Keenan Nemetz
9d ago
988 }
989
f09ef39e
Keenan Nemetz
15d ago
990 func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) {
991 t.Parallel()
992 f := newFixture(t)
993 f.publish(f.repo)
994 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
995 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
996 t.Fatalf("main push: %s", errs)
997 }
998 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
999 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1000 t.Fatalf("feature push: %s", errs)
1001 }
1002 sess, cookie := f.browserSession(t, f.ownerID)
ed03b3d1
Keenan Nemetz
6h ago
1003 resp, body := f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
1004 "csrf": {sess.CSRF}, "title": {"Line comments"}, "body": {"body\n"},
1005 "base": {"main"}, "head": {"feature"},
1006 })
1007 if resp.StatusCode != http.StatusSeeOther {
1008 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1009 }
ed03b3d1
Keenan Nemetz
6h ago
1010 id := strings.TrimPrefix(resp.Header.Get("Location"), "/"+f.repo+"/changes/")
f09ef39e
Keenan Nemetz
15d ago
1011
f6f4b1db
Keenan Nemetz
15d ago
1012 // The Files tab brings the changed file and its per-line comment button.
ed03b3d1
Keenan Nemetz
6h ago
1013 filesURL := "/" + f.repo + "/changes/" + id + "/files"
f6f4b1db
Keenan Nemetz
15d ago
1014 _, page := f.browserGet(t, filesURL, cookie)
1015 if !strings.Contains(page, `id="review-tree"`) || !strings.Contains(page, `data-status="M"`) ||
1016 !strings.Contains(page, filesURL+"/readme.md") {
1017 t.Fatalf("files tab has no changed-file tree:\n%s", page)
1018 }
1019 if !strings.Contains(page, "path=readme.md") || !strings.Contains(page, "line=2") ||
1020 !strings.Contains(page, "/comment-form?path=readme.md") {
1021 t.Fatalf("diff has no line comment control:\n%s", page)
1022 }
1023
1024 // A tree click returns only the review pane.
1025 resp, piece := f.htmxGet(t, filesURL+"/readme.md", cookie)
1026 if resp.StatusCode != http.StatusOK || strings.Contains(piece, "<!doctype html>") ||
1027 !strings.Contains(piece, `id="review-main"`) || !strings.Contains(piece, "readme.md") {
1028 t.Fatalf("review fragment = %d:\n%s", resp.StatusCode, piece)
f09ef39e
Keenan Nemetz
15d ago
1029 }
1030
f6f4b1db
Keenan Nemetz
15d ago
1031 // HTMX inserts the composer under the line, with the anchor already filled in.
ed03b3d1
Keenan Nemetz
6h ago
1032 resp, form := f.htmxGet(t, "/"+f.repo+"/changes/"+id+"/comment-form?path=readme.md&line=2&side=new", cookie)
f6f4b1db
Keenan Nemetz
15d ago
1033 if resp.StatusCode != http.StatusOK || strings.Contains(form, "<!doctype html>") ||
1034 !strings.Contains(form, `class="inline-comment"`) ||
1035 !strings.Contains(form, `name="path" value="readme.md"`) ||
1036 !strings.Contains(form, `name="line" value="2"`) ||
1037 !strings.Contains(form, "data-inline-comment-cancel") {
1038 t.Fatalf("inline composer = %d:\n%s", resp.StatusCode, form)
1039 }
1040
1041 // The old full-page fallback still opens the composer with the anchor filled in.
ed03b3d1
Keenan Nemetz
6h ago
1042 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+id+"?path=readme.md&line=2&side=new", cookie)
f09ef39e
Keenan Nemetz
15d ago
1043 if !strings.Contains(page, "Commenting on") || !strings.Contains(page, `name="path" value="readme.md"`) ||
1044 !strings.Contains(page, `name="line" value="2"`) {
1045 t.Fatalf("anchored composer is not pre-filled:\n%s", page)
1046 }
1047
c717d4eb
Keenan Nemetz
13d ago
1048 // A rejected anchored comment re-renders the discussion with the anchor still
1049 // chosen, so the reader keeps the line they picked instead of being sent back to
1050 // the Files tab to choose it again.
ed03b3d1
Keenan Nemetz
6h ago
1051 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
c717d4eb
Keenan Nemetz
13d ago
1052 "csrf": {sess.CSRF}, "body": {""},
1053 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1054 })
1055 if resp.StatusCode != http.StatusOK || !strings.Contains(body, "Commenting on") ||
1056 !strings.Contains(body, `name="line" value="2"`) ||
1057 !strings.Contains(body, "a comment needs a body") {
1058 t.Fatalf("rejected anchored comment = %d:\n%s", resp.StatusCode, body)
1059 }
1060
f09ef39e
Keenan Nemetz
15d ago
1061 // Posting it through HTMX stores the anchor and returns the discussion.
ed03b3d1
Keenan Nemetz
6h ago
1062 resp, body = f.htmxPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
f09ef39e
Keenan Nemetz
15d ago
1063 "csrf": {sess.CSRF}, "body": {"This line needs a second look."},
1064 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1065 })
1066 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) ||
1067 !strings.Contains(body, "This line needs a second look.") {
1068 t.Fatalf("anchored comment response = %d:\n%s", resp.StatusCode, body)
1069 }
1070 req, err := metadata.GetRequest(mustRepo(t, f).Git(), id)
1071 if err != nil {
1072 t.Fatal(err)
1073 }
1074 if len(req.Comments) != 1 || req.Comments[0].Anchor == nil {
1075 t.Fatalf("anchored comment was not stored: %+v", req.Comments)
1076 }
1077 a := req.Comments[0].Anchor
1078 if a.Path != "readme.md" || a.Line != 2 || a.Side != "new" || a.Blob == "" {
1079 t.Fatalf("anchor = %+v", a)
1080 }
f6f4b1db
Keenan Nemetz
15d ago
1081
1082 // The Files full page puts the comment beneath the line it belongs to.
1083 _, page = f.browserGet(t, filesURL+"/readme.md", cookie)
1084 if !strings.Contains(page, `id="inline-c-`+req.Comments[0].ID+`"`) ||
1085 !strings.Contains(page, "This line needs a second look.") {
1086 t.Fatalf("anchored comment is not rendered inline:\n%s", page)
1087 }
1088
1089 // A normal form post from the Files tab returns to that file's line note.
ed03b3d1
Keenan Nemetz
6h ago
1090 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+id+"/comments", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1091 "csrf": {sess.CSRF}, "body": {"Another line note."},
1092 "path": {"readme.md"}, "line": {"2"}, "side": {"new"},
1093 })
1094 if resp.StatusCode != http.StatusSeeOther {
1095 t.Fatalf("inline comment post = %d:\n%s", resp.StatusCode, body)
1096 }
1097 if loc := resp.Header.Get("Location"); !strings.HasPrefix(loc, filesURL+"/readme.md#inline-c-") {
1098 t.Fatalf("inline comment redirect = %q", loc)
1099 }
1100 }
1101
1102 func TestIssueAndChangeCommentsThreadOneLevel(t *testing.T) {
1103 t.Parallel()
1104 f := newFixture(t)
1105 f.publish(f.repo)
1106 sess, cookie := f.browserSession(t, f.ownerID)
1107 commentID := func(resp *http.Response) string {
1108 loc := resp.Header.Get("Location")
1109 if i := strings.LastIndex(loc, "#c-"); i >= 0 {
1110 return loc[i+3:]
1111 }
1112 return ""
1113 }
1114
1115 // Issue: replies are rendered under their root, even when replying to a reply.
ed03b3d1
Keenan Nemetz
6h ago
1116 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1117 "csrf": {sess.CSRF}, "title": {"Threaded issue"}, "body": {"body\n"},
1118 })
1119 if resp.StatusCode != http.StatusSeeOther {
1120 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
1121 }
ed03b3d1
Keenan Nemetz
6h ago
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{
f6f4b1db
Keenan Nemetz
15d ago
1124 "csrf": {sess.CSRF}, "body": {"Root comment."},
1125 })
1126 rootID := commentID(resp)
1127 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1128 t.Fatalf("issue root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1129 }
ed03b3d1
Keenan Nemetz
6h ago
1130 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1131 "csrf": {sess.CSRF}, "body": {"Issue reply."}, "reply_to": {rootID},
1132 })
1133 replyID := commentID(resp)
1134 if resp.StatusCode != http.StatusSeeOther || replyID == "" {
1135 t.Fatalf("issue reply: %d %q\n%s", resp.StatusCode, replyID, body)
1136 }
ed03b3d1
Keenan Nemetz
6h ago
1137 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
f6f4b1db
Keenan Nemetz
15d ago
1138 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) ||
1139 !strings.Contains(page, "Issue reply.") || !strings.Contains(page, "data-comment-menu") ||
1140 !strings.Contains(page, `data-reply-to="`+replyID+`"`) ||
1141 !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) {
1142 t.Fatalf("issue reply is not nested:\n%s", page)
1143 }
1144 if !strings.Contains(page, `<div id="issue-thread">`) ||
1145 !strings.Contains(page, `class="thread-replies"`) ||
1146 strings.Contains(page, `class="discussion-panel"`) || strings.Contains(page, `class="comment-scroll"`) ||
1147 strings.Count(page, `discussion-composer`) != 1 ||
1148 !strings.Contains(page, "data-reply-input") || !strings.Contains(page, "Cancel reply") {
1149 t.Fatalf("issue discussion is not a full thread with one composer:\n%s", page)
1150 }
ed03b3d1
Keenan Nemetz
6h ago
1151 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1152 "csrf": {sess.CSRF}, "body": {"Reply to a reply."}, "reply_to": {replyID},
1153 })
1154 deeperID := commentID(resp)
1155 if resp.StatusCode != http.StatusSeeOther || deeperID == "" {
1156 t.Fatalf("reply to a reply: %d %q\n%s", resp.StatusCode, deeperID, body)
1157 }
ed03b3d1
Keenan Nemetz
6h ago
1158 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
f6f4b1db
Keenan Nemetz
15d ago
1159 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+deeperID+`"`) ||
1160 !strings.Contains(page, "Reply to a reply.") || !strings.Contains(page, `data-reply-to="`+deeperID+`"`) ||
1161 !strings.Contains(page, `href="#c-`+replyID+`"`) {
1162 t.Fatalf("a reply to a reply was not flattened under its root:\n%s", page)
1163 }
1164
1165 // Change: the same flattening rule applies to the review discussion.
1166 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1167 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1168 t.Fatalf("main push: %s", errs)
1169 }
1170 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
1171 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1172 t.Fatalf("feature push: %s", errs)
1173 }
ed03b3d1
Keenan Nemetz
6h ago
1174 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1175 "csrf": {sess.CSRF}, "title": {"Threaded change"}, "body": {"body\n"},
1176 "base": {"main"}, "head": {"feature"},
1177 })
1178 if resp.StatusCode != http.StatusSeeOther {
1179 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1180 }
ed03b3d1
Keenan Nemetz
6h ago
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{
f6f4b1db
Keenan Nemetz
15d ago
1183 "csrf": {sess.CSRF}, "body": {"Change root."},
1184 })
1185 rootID = commentID(resp)
1186 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1187 t.Fatalf("change root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1188 }
ed03b3d1
Keenan Nemetz
6h ago
1189 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1190 "csrf": {sess.CSRF}, "body": {"Change reply."}, "reply_to": {rootID},
1191 })
1192 replyID = commentID(resp)
1193 if resp.StatusCode != http.StatusSeeOther || replyID == "" {
1194 t.Fatalf("change reply: %d %q\n%s", resp.StatusCode, replyID, body)
1195 }
ed03b3d1
Keenan Nemetz
6h ago
1196 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
f6f4b1db
Keenan Nemetz
15d ago
1197 if !strings.Contains(page, `class="comment-replies"`) || !strings.Contains(page, `id="c-`+replyID+`"`) ||
1198 !strings.Contains(page, "Change reply.") || !strings.Contains(page, `data-reply-to="`+replyID+`"`) ||
1199 !strings.Contains(page, `class="reply-context"`) || !strings.Contains(page, `href="#c-`+rootID+`"`) ||
1200 !strings.Contains(page, `<div id="request-discussion">`) ||
1201 !strings.Contains(page, `class="thread-replies"`) ||
1202 strings.Count(page, `discussion-composer`) != 1 {
1203 t.Fatalf("change reply is not nested:\n%s", page)
1204 }
1205 }
1206
1207 func TestCommentEditAndDeleteFromTheWeb(t *testing.T) {
1208 t.Parallel()
1209 f := newFixture(t)
1210 f.publish(f.repo)
1211 f.addWriter(t)
1212 sess, cookie := f.browserSession(t, f.ownerID)
1213 commentID := func(resp *http.Response) string {
1214 loc := resp.Header.Get("Location")
1215 if i := strings.LastIndex(loc, "#c-"); i >= 0 {
1216 return loc[i+3:]
1217 }
1218 return ""
1219 }
1220
ed03b3d1
Keenan Nemetz
6h ago
1221 resp, body := f.browserPost(t, "/"+f.repo+"/issues", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1222 "csrf": {sess.CSRF}, "title": {"Editable issue"}, "body": {"body\n"},
1223 })
1224 if resp.StatusCode != http.StatusSeeOther {
1225 t.Fatalf("create issue: %d\n%s", resp.StatusCode, body)
1226 }
ed03b3d1
Keenan Nemetz
6h ago
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{
f6f4b1db
Keenan Nemetz
15d ago
1229 "csrf": {sess.CSRF}, "body": {"Original root."},
1230 })
1231 rootID := commentID(resp)
1232 if resp.StatusCode != http.StatusSeeOther || rootID == "" {
1233 t.Fatalf("root comment: %d %q\n%s", resp.StatusCode, rootID, body)
1234 }
ed03b3d1
Keenan Nemetz
6h ago
1235 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1236 "csrf": {sess.CSRF}, "body": {"A surviving reply."}, "reply_to": {rootID},
1237 })
1238 if resp.StatusCode != http.StatusSeeOther {
1239 t.Fatalf("reply: %d\n%s", resp.StatusCode, body)
1240 }
1241
ed03b3d1
Keenan Nemetz
6h ago
1242 _, page := f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
f6f4b1db
Keenan Nemetz
15d ago
1243 for _, want := range []string{"data-comment-menu-toggle", "Copy text", "Copy link", "data-comment-edit", "Delete"} {
1244 if !strings.Contains(page, want) {
1245 t.Fatalf("comment menu is missing %q:\n%s", want, page)
1246 }
1247 }
1248
1249 // A maintainer who is not the author cannot rewrite or remove the comment.
1250 writerSess, writerCookie := f.browserSession(t, f.writerID)
ed03b3d1
Keenan Nemetz
6h ago
1251 resp, _ = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", writerCookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1252 "csrf": {writerSess.CSRF}, "body": {"Not mine."},
1253 })
1254 if resp.StatusCode != http.StatusForbidden {
1255 t.Fatalf("foreign edit = %d, want 403", resp.StatusCode)
1256 }
1257
ed03b3d1
Keenan Nemetz
6h ago
1258 resp, body = f.htmxPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/edit", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1259 "csrf": {sess.CSRF}, "body": {"Edited root."},
1260 })
1261 if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) ||
1262 !strings.Contains(body, "Edited root.") || strings.Contains(body, "Original root.") {
1263 t.Fatalf("edit response = %d:\n%s", resp.StatusCode, body)
1264 }
1265
ed03b3d1
Keenan Nemetz
6h ago
1266 resp, body = f.browserPost(t, "/"+f.repo+"/issues/"+issueID+"/comments/"+rootID+"/delete", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1267 "csrf": {sess.CSRF},
1268 })
1269 if resp.StatusCode != http.StatusSeeOther {
1270 t.Fatalf("delete = %d:\n%s", resp.StatusCode, body)
1271 }
ed03b3d1
Keenan Nemetz
6h ago
1272 _, page = f.browserGet(t, "/"+f.repo+"/issues/"+issueID, cookie)
f6f4b1db
Keenan Nemetz
15d ago
1273 if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "A surviving reply.") {
1274 t.Fatalf("deleted root did not become a tombstone:\n%s", page)
1275 }
1276
1277 // The same routes work for a change discussion.
1278 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1279 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1280 t.Fatalf("main push: %s", errs)
1281 }
1282 feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature")
1283 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1284 t.Fatalf("feature push: %s", errs)
1285 }
ed03b3d1
Keenan Nemetz
6h ago
1286 resp, body = f.browserPost(t, "/"+f.repo+"/changes", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1287 "csrf": {sess.CSRF}, "title": {"Editable change"}, "body": {"body\n"},
1288 "base": {"main"}, "head": {"feature"},
1289 })
1290 if resp.StatusCode != http.StatusSeeOther {
1291 t.Fatalf("create change: %d\n%s", resp.StatusCode, body)
1292 }
ed03b3d1
Keenan Nemetz
6h ago
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{
f6f4b1db
Keenan Nemetz
15d ago
1295 "csrf": {sess.CSRF}, "body": {"Original change root."},
1296 })
1297 rootID = commentID(resp)
ed03b3d1
Keenan Nemetz
6h ago
1298 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1299 "csrf": {sess.CSRF}, "body": {"Change reply survives."}, "reply_to": {rootID},
1300 })
1301 if resp.StatusCode != http.StatusSeeOther {
1302 t.Fatalf("change reply: %d\n%s", resp.StatusCode, body)
1303 }
ed03b3d1
Keenan Nemetz
6h ago
1304 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments/"+rootID+"/edit", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1305 "csrf": {sess.CSRF}, "body": {"Edited change root."},
1306 })
1307 if resp.StatusCode != http.StatusSeeOther {
1308 t.Fatalf("change edit: %d\n%s", resp.StatusCode, body)
1309 }
ed03b3d1
Keenan Nemetz
6h ago
1310 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
f6f4b1db
Keenan Nemetz
15d ago
1311 if !strings.Contains(page, "Edited change root.") || strings.Contains(page, "Original change root.") {
1312 t.Fatalf("change edit did not replace the comment:\n%s", page)
1313 }
ed03b3d1
Keenan Nemetz
6h ago
1314 resp, body = f.browserPost(t, "/"+f.repo+"/changes/"+requestID+"/comments/"+rootID+"/delete", cookie, url.Values{
f6f4b1db
Keenan Nemetz
15d ago
1315 "csrf": {sess.CSRF},
1316 })
1317 if resp.StatusCode != http.StatusSeeOther {
1318 t.Fatalf("change delete: %d\n%s", resp.StatusCode, body)
1319 }
ed03b3d1
Keenan Nemetz
6h ago
1320 _, page = f.browserGet(t, "/"+f.repo+"/changes/"+requestID, cookie)
f6f4b1db
Keenan Nemetz
15d ago
1321 if !strings.Contains(page, "Comment deleted") || !strings.Contains(page, "Change reply survives.") {
1322 t.Fatalf("deleted change root did not become a tombstone:\n%s", page)
1323 }
f09ef39e
Keenan Nemetz
15d ago
1324 }
c8fa5759
Keenan Nemetz
13d ago
1325
8032d15a
Keenan Nemetz
6d ago
1326 // Landing redirects to the request page, which now reads as merged. The redirect
1327 // carries no flag: the page shows the merged state from the request itself.
1328 func TestLandingRedirectsToTheRequestPage(t *testing.T) {
c8fa5759
Keenan Nemetz
13d ago
1329 t.Parallel()
1330 f := newFixture(t)
1331 f.publish(f.repo)
1332
1333 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1334 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1335 t.Fatalf("initial main: %s", errs)
1336 }
1337 feature, head := f.cloneWithChange(t, map[string]string{"readme.md": "v2\n"}, "feature")
1338 if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil {
1339 t.Fatalf("feature push: %s", errs)
1340 }
1341 meta := f.newMetaRepo(t)
1342 if err := meta.apply(map[string]string{
b492b557
Keenan Nemetz
3d ago
1343 "changes/" + reqID + "/change.md": requestMeta("main", head, "wilbur"),
c8fa5759
Keenan Nemetz
13d ago
1344 }, "request"); err != nil {
1345 t.Fatalf("request: %v", err)
1346 }
1347
1348 sess, cookie := f.browserSession(t, f.ownerID)
ed03b3d1
Keenan Nemetz
6h ago
1349 resp, body := f.browserPost(t, "/"+f.repo+"/changes/"+reqID+"/merge", cookie,
c8fa5759
Keenan Nemetz
13d ago
1350 url.Values{"csrf": {sess.CSRF}})
1351 if resp.StatusCode != http.StatusSeeOther {
1352 t.Fatalf("merge = %d, want 303:\n%s", resp.StatusCode, body)
1353 }
ed03b3d1
Keenan Nemetz
6h ago
1354 want := "/" + f.repo + "/changes/" + reqID
c8fa5759
Keenan Nemetz
13d ago
1355 if got := resp.Header.Get("Location"); got != want {
1356 t.Fatalf("merge redirect = %q, want %q", got, want)
1357 }
1358
8032d15a
Keenan Nemetz
6d ago
1359 // The page it points at is the ordinary merged view, not a special case.
c8fa5759
Keenan Nemetz
13d ago
1360 resp, page := f.browserGet(t, want, cookie)
1361 if resp.StatusCode != http.StatusOK {
1362 t.Fatalf("landing page = %d", resp.StatusCode)
1363 }
1364 if !strings.Contains(page, "merged into") {
1365 t.Fatalf("landing page does not show the merged state:\n%s", page)
1366 }
1367 }
a2ec9561
Keenan Nemetz
9d ago
1368
1369 // The header polls the two tab counts so a reader watching a repository sees new
1370 // issues and changes arrive without a reload. The poll must answer with the two
1371 // badges alone: an out-of-band update leaves the page, its current tab and
1372 // anything the reader typed where they were.
1373 func TestRepoTabCountsPollOutOfBand(t *testing.T) {
1374 t.Parallel()
1375 f := newFixture(t)
1376 f.publish(f.repo)
1377 files := map[string]string{
1378 "issues/01M5JR2JK29KKMAA1T9MDNTPD1/issue.md": issueFile("Open thing", "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
1379 "issues/01M5JR2JK29KKMAA1T9MDNTPD2/issue.md": issueFile("Closed thing", "keenan", "closed", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"),
1380 }
1381 work := f.issuesRepo(t, files)
1382 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1383 t.Fatalf("push: %s", errs)
1384 }
1385
1386 // The page is what the poll has to keep in step: the badges it names, and the
1387 // request that refreshes them while the page is visible.
ed03b3d1
Keenan Nemetz
6h ago
1388 code, page := f.get("/" + f.repo + "/issues")
a2ec9561
Keenan Nemetz
9d ago
1389 if code != http.StatusOK {
1390 t.Fatalf("issue page: %d", code)
1391 }
1392 for _, want := range []string{
1393 `id="repo-issue-count"`,
1394 `id="repo-request-count"`,
ed03b3d1
Keenan Nemetz
6h ago
1395 `hx-get="/` + f.repo + `/counts"`,
a2ec9561
Keenan Nemetz
9d ago
1396 "every 30s [document.visibilityState === 'visible']",
1397 } {
1398 if !strings.Contains(page, want) {
1399 t.Fatalf("the repository header does not show %q:\n%s", want, page)
1400 }
1401 }
1402
1403 // The answer is only the badges, each replacing itself out of band. Both are
1404 // present on every poll, so a count that did not move is not left stale.
ed03b3d1
Keenan Nemetz
6h ago
1405 resp, body := f.htmxGet(t, "/"+f.repo+"/counts", "")
a2ec9561
Keenan Nemetz
9d ago
1406 if resp.StatusCode != http.StatusOK || strings.Contains(body, "<!doctype html>") {
1407 t.Fatalf("counts poll = %d:\n%s", resp.StatusCode, body)
1408 }
1409 for _, want := range []string{
1410 `id="repo-issue-count" hx-swap-oob="outerHTML">1<`,
1411 `id="repo-request-count" hx-swap-oob="outerHTML">0<`,
1412 } {
1413 if !strings.Contains(body, want) {
1414 t.Fatalf("counts poll does not replace %q:\n%s", want, body)
1415 }
1416 }
1417 if got := strings.Count(body, "hx-swap-oob"); got != 2 {
1418 t.Fatalf("counts poll replaced %d elements, want 2:\n%s", got, body)
1419 }
1420
1421 // A repository the reader cannot see answers like a missing one, so the poll
1422 // cannot be used to discover private repositories.
ed03b3d1
Keenan Nemetz
6h ago
1423 if resp, _ := f.htmxGet(t, "/no-such-repo/counts", ""); resp.StatusCode != http.StatusNotFound {
a2ec9561
Keenan Nemetz
9d ago
1424 t.Fatalf("counts for an unknown repository = %d, want 404", resp.StatusCode)
1425 }
1426 }
db4081ec
Keenan Nemetz
5d ago
1427
1428 // TestIssueListShowsActivityColumnsAndSortsByUpdated: the list is read for what
1429 // moved, so the row carries the opening under the title, labels in their own
1430 // column, people as people, and the time of the last edit or comment.
1431 func TestIssueListShowsActivityColumnsAndSortsByUpdated(t *testing.T) {
1432 t.Parallel()
1433 f := newFixture(t)
1434 f.publish(f.repo)
1435 f.addWriter(t) // wilbur, so an assignee is a real profile link
1436 const olderID = "01M5JR2JK29KKMAA1T9MDNTPC1"
1437 const newerID = "01M5JR2JK29KKMAA1T9MDNTPC2"
1438 files := map[string]string{
c67173ca
Keenan Nemetz
5d ago
1439 "issues/" + olderID + "/issue.md": issueFile("Older issue", "keenan", "open", "2026-10-20T10:00:00Z", []string{"bug"}, "body\n", "wilbur"),
1440 "issues/" + olderID + "/comments/01M5JR2JK29KKMAA1T9MDNTPK1.md": commentFile("keenan", "2026-10-21T10:00:00Z", "a note\n"),
1441 "issues/" + newerID + "/issue.md": issueFile("Newer issue", "keenan", "open", "2026-10-22T10:00:00Z", nil, "body\n"),
db4081ec
Keenan Nemetz
5d ago
1442 }
1443 work := f.issuesRepo(t, files)
1444 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1445 t.Fatalf("push issues: %v\n%s", err, errs)
1446 }
1447
1448 // Edit the older issue in a commit of its own, dated after the first push.
1449 // Creation order and the ULID both put the newer issue first, so only sorting
1450 // by activity can move the edited one to the top.
1451 edited := issueFile("Older issue, edited", "keenan", "open", "2026-10-20T10:00:00Z", []string{"bug"}, "body\n", "wilbur")
1452 if err := os.WriteFile(filepath.Join(work, "issues", olderID, "issue.md"), []byte(edited), 0o644); err != nil {
1453 t.Fatal(err)
1454 }
1455 if _, errs, err := runGit(t, work, "add", "-A"); err != nil {
1456 t.Fatalf("add the edit: %v: %s", err, errs)
1457 }
1458 later := time.Now().Add(48 * time.Hour).UTC().Format(time.RFC3339)
1459 if _, errs, err := f.git(work, "commit", "-qm", "edit the older issue", "--date", later); err != nil {
1460 t.Fatalf("commit the edit: %v\n%s", err, errs)
1461 }
1462 if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil {
1463 t.Fatalf("push the edit: %v\n%s", err, errs)
1464 }
1465
ed03b3d1
Keenan Nemetz
6h ago
1466 code, body := f.get("/" + f.repo + "/issues")
db4081ec
Keenan Nemetz
5d ago
1467 if code != http.StatusOK {
1468 t.Fatalf("issue list: %d\n%s", code, body)
1469 }
1470 for _, want := range []string{
c67173ca
Keenan Nemetz
5d ago
1471 "<th>Status</th><th>Issue</th><th>Labels</th><th>Assignees</th><th>Updated</th>",
6813339b
Keenan Nemetz
4d ago
1472 `<span class="row-status open">`,
1473 `>Open</span>`,
c67173ca
Keenan Nemetz
5d ago
1474 `<span>1 comment</span>`,
db4081ec
Keenan Nemetz
5d ago
1475 `<span class="label">bug</span>`,
ed03b3d1
Keenan Nemetz
6h ago
1476 `opened by <a class="user" href="/-/u/keenan">keenan</a>`,
1477 `<a class="user" href="/-/u/wilbur">wilbur</a>`,
db4081ec
Keenan Nemetz
5d ago
1478 } {
1479 if !strings.Contains(body, want) {
1480 t.Fatalf("issue list is missing %q:\n%s", want, body)
1481 }
1482 }
c67173ca
Keenan Nemetz
5d ago
1483 if strings.Contains(body, "<th>Age</th>") || strings.Contains(body, "<th>Author</th>") ||
1484 strings.Contains(body, "<th>Replies</th>") {
db4081ec
Keenan Nemetz
5d ago
1485 t.Fatalf("issue list still has the replaced columns:\n%s", body)
1486 }
1487 editedAt, otherAt := strings.Index(body, "Older issue, edited"), strings.Index(body, "Newer issue")
1488 if editedAt < 0 || otherAt < 0 || editedAt > otherAt {
1489 t.Fatalf("issue list is not ordered by updated: edited at %d, other at %d\n%s", editedAt, otherAt, body)
1490 }
1491 }
7cc36ba0
Keenan Nemetz
5d ago
1492
1493 // TestChangeListShowsOneStatus: the list answers "where is this change?" in one
1494 // column, from the approvals and checks the repository already has, and carries
1495 // the author and opening under the title like the issue list does.
1496 func TestChangeListShowsOneStatus(t *testing.T) {
1497 t.Parallel()
1498 f := newFixture(t)
1499 f.addWriter(t) // wilbur, so the approved change is not approved by its author
1500 f.publish(f.repo)
1501 base := workRepo(t, map[string]string{"readme.md": "v1\n"})
1502 if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil {
1503 t.Fatalf("main push: %v\n%s", err, errs)
1504 }
1505 m := mustRepo(t, f)
1506 a, _ := m.ACL()
1507 a.Protected = []string{"refs/heads/main"}
1508 if err := m.SetACL(a, "test"); err != nil {
1509 t.Fatal(err)
1510 }
1511
1512 first, firstHead := f.cloneWithChange(t, map[string]string{"a.txt": "1\n"}, "change one")
1513 if _, errs, err := f.git(first, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature-one"); err != nil {
1514 t.Fatalf("feature one push: %v\n%s", err, errs)
1515 }
1516 second, secondHead := f.cloneWithChange(t, map[string]string{"b.txt": "1\n"}, "change two")
1517 if _, errs, err := f.git(second, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature-two"); err != nil {
1518 t.Fatalf("feature two push: %v\n%s", err, errs)
1519 }
1520
1521 const reviewID = "01M5JR2JK29KKMAA1T9MDNTR02"
1522 const approvedID = "01M5JR2JK29KKMAA1T9MDNTR03"
1523 const mergedID = "01M5JR2JK29KKMAA1T9MDNTR04"
1524 const closedID = "01M5JR2JK29KKMAA1T9MDNTR05"
b6af0843
Keenan Nemetz
4d ago
1525 const followingID = "01M5JR2JK29KKMAA1T9MDNTR06"
7cc36ba0
Keenan Nemetz
5d ago
1526 meta := f.newMetaRepo(t)
1527 if err := meta.apply(map[string]string{
b492b557
Keenan Nemetz
3d ago
1528 "changes/" + reviewID + "/change.md": requestFileTitled("Needs a review", "main", firstHead, "keenan", "body\n"),
1529 "changes/" + approvedID + "/change.md": requestFileTitled("Approved change", "main", secondHead, "wilbur", "body\n"),
1530 "changes/" + approvedID + "/approvals/" + f.accountName("keenan") + ".md": approvalFile("main", secondHead),
1531 "changes/" + mergedID + "/change.md": requestFileMerged("Landed change", "main", secondHead, "wilbur", "keenan", nil),
1532 "changes/" + closedID + "/change.md": requestFileState("Dropped change", "main", firstHead, "wilbur", "closed", "body\n"),
1533 "changes/" + followingID + "/change.md": requestFileFollowing("Follows a branch", "main", firstHead, "feature-one", "keenan", "body\n"),
7cc36ba0
Keenan Nemetz
5d ago
1534 }, "changes"); err != nil {
1535 t.Fatalf("changes push: %v", err)
1536 }
1537
ed03b3d1
Keenan Nemetz
6h ago
1538 code, body := f.get("/" + f.repo + "/changes?state=all")
7cc36ba0
Keenan Nemetz
5d ago
1539 if code != http.StatusOK {
1540 t.Fatalf("change list: %d\n%s", code, body)
1541 }
1542 for _, want := range []string{
5899ae25
Keenan Nemetz
4d ago
1543 "<th>Status</th><th>Change</th><th>Base</th><th>Head</th><th>Updated</th>",
b7afc6a9
Keenan Nemetz
4d ago
1544 `<code>main</code>`,
b6af0843
Keenan Nemetz
4d ago
1545 `pinned to</span> <span class="sha"`,
1546 `title="the branch this request follows">feature-one</code>`,
6813339b
Keenan Nemetz
4d ago
1547 `class="row-status review"`,
7cc36ba0
Keenan Nemetz
5d ago
1548 `title="0 of 1 required approvals."`,
1549 "Waiting for review",
6813339b
Keenan Nemetz
4d ago
1550 `class="row-status ready"`,
7cc36ba0
Keenan Nemetz
5d ago
1551 "Ready to merge",
6813339b
Keenan Nemetz
4d ago
1552 `class="row-status merged"`,
7cc36ba0
Keenan Nemetz
5d ago
1553 "Merged",
6813339b
Keenan Nemetz
4d ago
1554 `class="row-status closed"`,
7cc36ba0
Keenan Nemetz
5d ago
1555 "Closed",
ed03b3d1
Keenan Nemetz
6h ago
1556 `opened by <a class="user" href="/-/u/keenan">keenan</a>`,
1557 `opened by <a class="user" href="/-/u/wilbur">wilbur</a>`,
7cc36ba0
Keenan Nemetz
5d ago
1558 } {
1559 if !strings.Contains(body, want) {
1560 t.Fatalf("change list is missing %q:\n%s", want, body)
1561 }
1562 }
1563 for _, gone := range []string{"<th>Author</th>", "<th>Approvals</th>", "<th>Age</th>", "<th>Replies</th>"} {
1564 if strings.Contains(body, gone) {
1565 t.Fatalf("change list still has %s:\n%s", gone, body)
1566 }
1567 }
1568 }