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.
Build out the forge web UI
f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264
Build out the forge web UI Turn the web surface into a compact, server-rendered forge with HTMX as the progressive dynamic layer. - Split templates and assets into embedded files; add a tokenised dark theme, Lucide icons, and vendored HTMX. - Add the repository Overview page and a separate chronological Activity feed with type filters and HTMX fragment updates. - Finish the issue and change workflows: create pages, comments, close/reopen, labels and assignees, approval, discussion, line-anchored review comments, and HTMX list filters. - Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware code browsing, and a repository Runs list. - Add a loopback-only development login for local write testing, with explicit configuration and listener checks. - Extend tests for the new pages, fragments, labels, assignees, anchors, and development login.
server/issues_changes_test.go +524
| @@ -0,0 +1,524 @@ | |||
| 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" | ||
| 12 | "strings" | ||
| 13 | "testing" | ||
| 14 | |||
| 15 | "github.com/rodent-software/vole/forge/index" | ||
| 16 | "github.com/rodent-software/vole/metadata" | ||
| 17 | ) | ||
| 18 | |||
| 19 | func TestIssueCanBeClosedAndReopenedInTheBrowser(t *testing.T) { | ||
| 20 | t.Parallel() | ||
| 21 | f := newFixture(t) | ||
| 22 | f.publish(f.repo) | ||
| 23 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 24 | |||
| 25 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 26 | "csrf": {sess.CSRF}, "title": {"Close me from the web"}, "body": {"body\n"}, | ||
| 27 | }) | ||
| 28 | if resp.StatusCode != http.StatusSeeOther { | ||
| 29 | t.Fatalf("create issue: %d\n%s", resp.StatusCode, body) | ||
| 30 | } | ||
| 31 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/") | ||
| 32 | if id == "" { | ||
| 33 | t.Fatalf("no issue id in %q", resp.Header.Get("Location")) | ||
| 34 | } | ||
| 35 | |||
| 36 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{ | ||
| 37 | "csrf": {sess.CSRF}, "state": {"closed"}, | ||
| 38 | }) | ||
| 39 | if resp.StatusCode != http.StatusSeeOther { | ||
| 40 | t.Fatalf("close issue: %d\n%s", resp.StatusCode, body) | ||
| 41 | } | ||
| 42 | _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie) | ||
| 43 | if !strings.Contains(page, "Reopen issue") || !strings.Contains(page, `class="state closed"`) { | ||
| 44 | t.Fatalf("closed issue does not offer a reopen:\n%s", page) | ||
| 45 | } | ||
| 46 | // The default list is open issues; the closed one is behind the All filter. | ||
| 47 | if _, list := f.browserGet(t, "/r/"+f.repo+"/issues", cookie); strings.Contains(list, "Close me from the web") { | ||
| 48 | t.Fatalf("closed issue is still in the open list:\n%s", list) | ||
| 49 | } | ||
| 50 | if _, list := f.browserGet(t, "/r/"+f.repo+"/issues?state=all", cookie); !strings.Contains(list, "Close me from the web") { | ||
| 51 | t.Fatalf("closed issue is missing from the All list:\n%s", list) | ||
| 52 | } | ||
| 53 | |||
| 54 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{ | ||
| 55 | "csrf": {sess.CSRF}, "state": {"open"}, | ||
| 56 | }) | ||
| 57 | if resp.StatusCode != http.StatusSeeOther { | ||
| 58 | t.Fatalf("reopen issue: %d\n%s", resp.StatusCode, body) | ||
| 59 | } | ||
| 60 | if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie); !strings.Contains(page, "Close issue") { | ||
| 61 | t.Fatalf("reopened issue does not offer a close:\n%s", page) | ||
| 62 | } | ||
| 63 | } | ||
| 64 | |||
| 65 | func TestChangeCanBeDiscussedAndClosedInTheBrowser(t *testing.T) { | ||
| 66 | t.Parallel() | ||
| 67 | f := newFixture(t) | ||
| 68 | f.publish(f.repo) | ||
| 69 | |||
| 70 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 71 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 72 | t.Fatalf("main push: %s", errs) | ||
| 73 | } | ||
| 74 | feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature") | ||
| 75 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 76 | t.Fatalf("feature push: %s", errs) | ||
| 77 | } | ||
| 78 | |||
| 79 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 80 | resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{ | ||
| 81 | "csrf": {sess.CSRF}, "title": {"Browser change"}, "body": {"please review\n"}, | ||
| 82 | "base": {"main"}, "head": {"feature"}, | ||
| 83 | }) | ||
| 84 | if resp.StatusCode != http.StatusSeeOther { | ||
| 85 | t.Fatalf("create change: %d\n%s", resp.StatusCode, body) | ||
| 86 | } | ||
| 87 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/") | ||
| 88 | if id == "" { | ||
| 89 | t.Fatalf("no change id in %q", resp.Header.Get("Location")) | ||
| 90 | } | ||
| 91 | |||
| 92 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/comments", cookie, url.Values{ | ||
| 93 | "csrf": {sess.CSRF}, "body": {"Looks good from the browser.\n"}, | ||
| 94 | }) | ||
| 95 | if resp.StatusCode != http.StatusSeeOther { | ||
| 96 | t.Fatalf("comment: %d\n%s", resp.StatusCode, body) | ||
| 97 | } | ||
| 98 | _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie) | ||
| 99 | if !strings.Contains(page, "Looks good from the browser.") { | ||
| 100 | t.Fatalf("the browser comment is not on the page:\n%s", page) | ||
| 101 | } | ||
| 102 | |||
| 103 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/state", cookie, url.Values{ | ||
| 104 | "csrf": {sess.CSRF}, "state": {"closed"}, | ||
| 105 | }) | ||
| 106 | if resp.StatusCode != http.StatusSeeOther { | ||
| 107 | t.Fatalf("close change: %d\n%s", resp.StatusCode, body) | ||
| 108 | } | ||
| 109 | if _, list := f.browserGet(t, "/r/"+f.repo+"/requests", cookie); strings.Contains(list, "Browser change") { | ||
| 110 | t.Fatalf("closed change is still in the open list:\n%s", list) | ||
| 111 | } | ||
| 112 | if _, list := f.browserGet(t, "/r/"+f.repo+"/requests?state=all", cookie); !strings.Contains(list, "Browser change") { | ||
| 113 | t.Fatalf("closed change is missing from the All list:\n%s", list) | ||
| 114 | } | ||
| 115 | if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Reopen change") { | ||
| 116 | t.Fatalf("closed change does not offer a reopen:\n%s", page) | ||
| 117 | } | ||
| 118 | |||
| 119 | resp, body = f.browserPost(t, "/r/"+f.repo+"/requests/"+id+"/state", cookie, url.Values{ | ||
| 120 | "csrf": {sess.CSRF}, "state": {"open"}, | ||
| 121 | }) | ||
| 122 | if resp.StatusCode != http.StatusSeeOther { | ||
| 123 | t.Fatalf("reopen change: %d\n%s", resp.StatusCode, body) | ||
| 124 | } | ||
| 125 | if _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie); !strings.Contains(page, "Close change") { | ||
| 126 | t.Fatalf("reopened change does not offer a close:\n%s", page) | ||
| 127 | } | ||
| 128 | } | ||
| 129 | |||
| 130 | func TestNewIssueAndChangePages(t *testing.T) { | ||
| 131 | t.Parallel() | ||
| 132 | f := newFixture(t) | ||
| 133 | f.publish(f.repo) | ||
| 134 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 135 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 136 | t.Fatalf("main push: %s", errs) | ||
| 137 | } | ||
| 138 | feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature") | ||
| 139 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 140 | t.Fatalf("feature push: %s", errs) | ||
| 141 | } | ||
| 142 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 143 | |||
| 144 | resp, body := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie) | ||
| 145 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/r/`+f.repo+`/issues"`) || !strings.Contains(body, "Open issue") { | ||
| 146 | t.Fatalf("new issue page = %d:\n%s", resp.StatusCode, body) | ||
| 147 | } | ||
| 148 | // The CSRF token is on the form, not in a popover that has to be opened first. | ||
| 149 | if !strings.Contains(body, sess.CSRF) { | ||
| 150 | t.Fatal("new issue page has no CSRF token") | ||
| 151 | } | ||
| 152 | |||
| 153 | resp, body = f.browserGet(t, "/r/"+f.repo+"/requests/new", cookie) | ||
| 154 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `action="/r/`+f.repo+`/requests"`) || | ||
| 155 | !strings.Contains(body, "feature") || !strings.Contains(body, "Open change request") { | ||
| 156 | t.Fatalf("new change page = %d:\n%s", resp.StatusCode, body) | ||
| 157 | } | ||
| 158 | |||
| 159 | // The list page links to the pages instead of embedding the forms. | ||
| 160 | if _, page := f.browserGet(t, "/r/"+f.repo+"/issues", cookie); !strings.Contains(page, "/issues/new") { | ||
| 161 | t.Fatal("issues list does not link to the new issue page") | ||
| 162 | } | ||
| 163 | if _, page := f.browserGet(t, "/r/"+f.repo+"/requests", cookie); !strings.Contains(page, "/requests/new") { | ||
| 164 | t.Fatal("changes list does not link to the new change page") | ||
| 165 | } | ||
| 166 | } | ||
| 167 | |||
| 168 | func TestIssueLabelsCanBeSetAndEditedInTheBrowser(t *testing.T) { | ||
| 169 | t.Parallel() | ||
| 170 | f := newFixture(t) | ||
| 171 | f.publish(f.repo) | ||
| 172 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 173 | |||
| 174 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 175 | "csrf": {sess.CSRF}, "title": {"Labels from the browser"}, "body": {"body\n"}, | ||
| 176 | "label": {"bug", "web"}, "labels": {"perf, docs"}, | ||
| 177 | }) | ||
| 178 | if resp.StatusCode != http.StatusSeeOther { | ||
| 179 | t.Fatalf("create issue: %d\n%s", resp.StatusCode, body) | ||
| 180 | } | ||
| 181 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/") | ||
| 182 | if id == "" { | ||
| 183 | t.Fatalf("no issue id in %q", resp.Header.Get("Location")) | ||
| 184 | } | ||
| 185 | iss, err := index.LoadIssue(mustRepo(t, f), id) | ||
| 186 | if err != nil { | ||
| 187 | t.Fatal(err) | ||
| 188 | } | ||
| 189 | if got := strings.Join(iss.Labels, ","); got != "bug,docs,perf,web" { | ||
| 190 | t.Fatalf("created labels = %q", got) | ||
| 191 | } | ||
| 192 | |||
| 193 | // The detail page seeds the token input with the current labels and offers a | ||
| 194 | // save form. The browser turns that value into chips. | ||
| 195 | _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie) | ||
| 196 | for _, want := range []string{`hx-trigger="token-change"`, `value="bug, docs, perf, web"`, `data-label-input`} { | ||
| 197 | if !strings.Contains(page, want) { | ||
| 198 | t.Fatalf("issue page does not show %q:\n%s", want, page) | ||
| 199 | } | ||
| 200 | } | ||
| 201 | // A new issue page offers the labels already used in the repository. | ||
| 202 | if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="bug"`) { | ||
| 203 | t.Fatalf("new issue page has no existing label picker:\n%s", page) | ||
| 204 | } | ||
| 205 | |||
| 206 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{ | ||
| 207 | "csrf": {sess.CSRF}, "label": {"bug"}, "labels": {"ux"}, | ||
| 208 | }) | ||
| 209 | if resp.StatusCode != http.StatusSeeOther { | ||
| 210 | t.Fatalf("edit labels: %d\n%s", resp.StatusCode, body) | ||
| 211 | } | ||
| 212 | iss, err = index.LoadIssue(mustRepo(t, f), id) | ||
| 213 | if err != nil { | ||
| 214 | t.Fatal(err) | ||
| 215 | } | ||
| 216 | if got := strings.Join(iss.Labels, ","); got != "bug,ux" { | ||
| 217 | t.Fatalf("edited labels = %q", got) | ||
| 218 | } | ||
| 219 | _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie) | ||
| 220 | if !strings.Contains(page, `value="bug, ux"`) || strings.Contains(page, `value="bug, docs, perf, web"`) { | ||
| 221 | t.Fatalf("edited labels are not reflected in the picker:\n%s", page) | ||
| 222 | } | ||
| 223 | } | ||
| 224 | |||
| 225 | func TestIssueAssigneesCanBeSetAndEditedInTheBrowser(t *testing.T) { | ||
| 226 | t.Parallel() | ||
| 227 | f := newFixture(t) | ||
| 228 | f.publish(f.repo) | ||
| 229 | f.addWriter(t) // wilbur: maintain access, so assignable | ||
| 230 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 231 | |||
| 232 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 233 | "csrf": {sess.CSRF}, "title": {"Assignees from the browser"}, "body": {"body\n"}, | ||
| 234 | "assignee": {"wilbur"}, "assignees": {"keenan"}, | ||
| 235 | }) | ||
| 236 | if resp.StatusCode != http.StatusSeeOther { | ||
| 237 | t.Fatalf("create issue: %d\n%s", resp.StatusCode, body) | ||
| 238 | } | ||
| 239 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/") | ||
| 240 | if id == "" { | ||
| 241 | t.Fatalf("no issue id in %q", resp.Header.Get("Location")) | ||
| 242 | } | ||
| 243 | iss, err := index.LoadIssue(mustRepo(t, f), id) | ||
| 244 | if err != nil { | ||
| 245 | t.Fatal(err) | ||
| 246 | } | ||
| 247 | if got := strings.Join(iss.Assignees, ","); got != "keenan,wilbur" { | ||
| 248 | t.Fatalf("created assignees = %q", got) | ||
| 249 | } | ||
| 250 | |||
| 251 | _, page := f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie) | ||
| 252 | for _, want := range []string{`hx-trigger="token-change"`, `value="keenan, wilbur"`, `data-label-item="assignee"`} { | ||
| 253 | if !strings.Contains(page, want) { | ||
| 254 | t.Fatalf("issue page does not show %q:\n%s", want, page) | ||
| 255 | } | ||
| 256 | } | ||
| 257 | if _, page := f.browserGet(t, "/r/"+f.repo+"/issues/new", cookie); !strings.Contains(page, `data-label-option="wilbur"`) { | ||
| 258 | t.Fatalf("new issue page does not offer wilbur as an assignee:\n%s", page) | ||
| 259 | } | ||
| 260 | |||
| 261 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{ | ||
| 262 | "csrf": {sess.CSRF}, "assignee": {"wilbur"}, | ||
| 263 | }) | ||
| 264 | if resp.StatusCode != http.StatusSeeOther { | ||
| 265 | t.Fatalf("edit assignees: %d\n%s", resp.StatusCode, body) | ||
| 266 | } | ||
| 267 | iss, err = index.LoadIssue(mustRepo(t, f), id) | ||
| 268 | if err != nil { | ||
| 269 | t.Fatal(err) | ||
| 270 | } | ||
| 271 | if got := strings.Join(iss.Assignees, ","); got != "wilbur" { | ||
| 272 | t.Fatalf("edited assignees = %q", got) | ||
| 273 | } | ||
| 274 | _, page = f.browserGet(t, "/r/"+f.repo+"/issues/"+id, cookie) | ||
| 275 | if !strings.Contains(page, `value="wilbur"`) || strings.Contains(page, `value="keenan, wilbur"`) { | ||
| 276 | t.Fatalf("edited assignees are not reflected in the picker:\n%s", page) | ||
| 277 | } | ||
| 278 | |||
| 279 | // An assignee nobody can resolve is a typo, not a person. | ||
| 280 | resp, body = f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 281 | "csrf": {sess.CSRF}, "title": {"Bad assignee"}, "body": {"body\n"}, | ||
| 282 | "assignee": {"nobody"}, | ||
| 283 | }) | ||
| 284 | if resp.StatusCode != http.StatusBadRequest { | ||
| 285 | t.Fatalf("unknown assignee accepted: %d\n%s", resp.StatusCode, body) | ||
| 286 | } | ||
| 287 | } | ||
| 288 | |||
| 289 | func (f *fixture) htmxPost(t *testing.T, path, cookie string, form url.Values) (*http.Response, string) { | ||
| 290 | t.Helper() | ||
| 291 | req, err := http.NewRequest("POST", f.http.URL+path, strings.NewReader(form.Encode())) | ||
| 292 | if err != nil { | ||
| 293 | t.Fatal(err) | ||
| 294 | } | ||
| 295 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded") | ||
| 296 | req.Header.Set("Cookie", cookie) | ||
| 297 | req.Header.Set("HX-Request", "true") | ||
| 298 | resp, err := noRedirectAuth.Do(req) | ||
| 299 | if err != nil { | ||
| 300 | t.Fatal(err) | ||
| 301 | } | ||
| 302 | defer resp.Body.Close() | ||
| 303 | body, _ := io.ReadAll(resp.Body) | ||
| 304 | return resp, string(body) | ||
| 305 | } | ||
| 306 | |||
| 307 | func TestIssueSidebarFragmentsUpdateWithHtmx(t *testing.T) { | ||
| 308 | t.Parallel() | ||
| 309 | f := newFixture(t) | ||
| 310 | f.publish(f.repo) | ||
| 311 | f.addWriter(t) | ||
| 312 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 313 | |||
| 314 | resp, body := f.browserPost(t, "/r/"+f.repo+"/issues", cookie, url.Values{ | ||
| 315 | "csrf": {sess.CSRF}, "title": {"HTMX issue"}, "body": {"body\n"}, | ||
| 316 | }) | ||
| 317 | if resp.StatusCode != http.StatusSeeOther { | ||
| 318 | t.Fatalf("create issue: %d\n%s", resp.StatusCode, body) | ||
| 319 | } | ||
| 320 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/issues/") | ||
| 321 | |||
| 322 | // Labels: the response is the fragment, not a page and not a redirect. | ||
| 323 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/labels", cookie, url.Values{ | ||
| 324 | "csrf": {sess.CSRF}, "label": {"bug"}, "labels": {"ux"}, | ||
| 325 | }) | ||
| 326 | if resp.StatusCode != http.StatusOK { | ||
| 327 | t.Fatalf("htmx labels: %d\n%s", resp.StatusCode, body) | ||
| 328 | } | ||
| 329 | if strings.Contains(body, "<!doctype html>") || !strings.Contains(body, `id="issue-labels"`) || | ||
| 330 | !strings.Contains(body, `value="bug, ux"`) { | ||
| 331 | t.Fatalf("labels response is not the expected fragment:\n%s", body) | ||
| 332 | } | ||
| 333 | |||
| 334 | // State: closing returns the state fragment with the reopen action. | ||
| 335 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/state", cookie, url.Values{ | ||
| 336 | "csrf": {sess.CSRF}, "state": {"closed"}, | ||
| 337 | }) | ||
| 338 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-state"`) || | ||
| 339 | !strings.Contains(body, "Reopen issue") { | ||
| 340 | t.Fatalf("state response is not the expected fragment (%d):\n%s", resp.StatusCode, body) | ||
| 341 | } | ||
| 342 | |||
| 343 | // Assignees: the replacement fragment seeds the token input and keeps the | ||
| 344 | // save control. | ||
| 345 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/assignees", cookie, url.Values{ | ||
| 346 | "csrf": {sess.CSRF}, "assignee": {"wilbur"}, | ||
| 347 | }) | ||
| 348 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-assignees"`) || | ||
| 349 | !strings.Contains(body, `value="wilbur"`) || !strings.Contains(body, `hx-trigger="token-change"`) { | ||
| 350 | t.Fatalf("assignees response is not the expected fragment (%d):\n%s", resp.StatusCode, body) | ||
| 351 | } | ||
| 352 | |||
| 353 | // Comments: the thread fragment includes the new comment and the composer. | ||
| 354 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/issues/"+id+"/comments", cookie, url.Values{ | ||
| 355 | "csrf": {sess.CSRF}, "body": {"Posted over HTMX.\n"}, | ||
| 356 | }) | ||
| 357 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-thread"`) || | ||
| 358 | !strings.Contains(body, "Posted over HTMX.") { | ||
| 359 | t.Fatalf("comment response is not the expected fragment (%d):\n%s", resp.StatusCode, body) | ||
| 360 | } | ||
| 361 | } | ||
| 362 | |||
| 363 | func TestRequestSidebarAndDiscussionUpdateWithHtmx(t *testing.T) { | ||
| 364 | t.Parallel() | ||
| 365 | f := newFixture(t) | ||
| 366 | f.addWriter(t) // wilbur: maintain, and not the request author | ||
| 367 | f.publish(f.repo) | ||
| 368 | |||
| 369 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 370 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 371 | t.Fatalf("main push: %s", errs) | ||
| 372 | } | ||
| 373 | feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature") | ||
| 374 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 375 | t.Fatalf("feature push: %s", errs) | ||
| 376 | } | ||
| 377 | |||
| 378 | ownerSess, ownerCookie := f.browserSession(t, f.ownerID) | ||
| 379 | resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", ownerCookie, url.Values{ | ||
| 380 | "csrf": {ownerSess.CSRF}, "title": {"HTMX change"}, "body": {"body\n"}, | ||
| 381 | "base": {"main"}, "head": {"feature"}, | ||
| 382 | }) | ||
| 383 | if resp.StatusCode != http.StatusSeeOther { | ||
| 384 | t.Fatalf("create change: %d\n%s", resp.StatusCode, body) | ||
| 385 | } | ||
| 386 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/") | ||
| 387 | |||
| 388 | // A maintainer approves; the sidebar comes back with the approval and progress. | ||
| 389 | writerSess, writerCookie := f.browserSession(t, f.writerID) | ||
| 390 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/approve", writerCookie, url.Values{ | ||
| 391 | "csrf": {writerSess.CSRF}, | ||
| 392 | }) | ||
| 393 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-sidebar"`) || | ||
| 394 | !strings.Contains(body, "1 of 1 required approval(s).") { | ||
| 395 | t.Fatalf("approval response is not the expected sidebar (%d):\n%s", resp.StatusCode, body) | ||
| 396 | } | ||
| 397 | |||
| 398 | // Posting a comment replaces only the discussion fragment. | ||
| 399 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/comments", ownerCookie, url.Values{ | ||
| 400 | "csrf": {ownerSess.CSRF}, "body": {"Review note over HTMX.\n"}, | ||
| 401 | }) | ||
| 402 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) || | ||
| 403 | !strings.Contains(body, "Review note over HTMX.") { | ||
| 404 | t.Fatalf("discussion response is not the expected fragment (%d):\n%s", resp.StatusCode, body) | ||
| 405 | } | ||
| 406 | |||
| 407 | // Closing swaps the sidebar and changes the action to reopen. | ||
| 408 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/state", ownerCookie, url.Values{ | ||
| 409 | "csrf": {ownerSess.CSRF}, "state": {"closed"}, | ||
| 410 | }) | ||
| 411 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-sidebar"`) || | ||
| 412 | !strings.Contains(body, "Reopen change") { | ||
| 413 | t.Fatalf("state response is not the expected sidebar (%d):\n%s", resp.StatusCode, body) | ||
| 414 | } | ||
| 415 | } | ||
| 416 | |||
| 417 | func (f *fixture) htmxGet(t *testing.T, path, cookie string) (*http.Response, string) { | ||
| 418 | t.Helper() | ||
| 419 | req, err := http.NewRequest("GET", f.http.URL+path, nil) | ||
| 420 | if err != nil { | ||
| 421 | t.Fatal(err) | ||
| 422 | } | ||
| 423 | req.Header.Set("Cookie", cookie) | ||
| 424 | req.Header.Set("HX-Request", "true") | ||
| 425 | resp, err := noRedirectAuth.Do(req) | ||
| 426 | if err != nil { | ||
| 427 | t.Fatal(err) | ||
| 428 | } | ||
| 429 | defer resp.Body.Close() | ||
| 430 | body, _ := io.ReadAll(resp.Body) | ||
| 431 | return resp, string(body) | ||
| 432 | } | ||
| 433 | |||
| 434 | func TestIssueAndChangeListsReturnFragmentsForHtmx(t *testing.T) { | ||
| 435 | t.Parallel() | ||
| 436 | f := newFixture(t) | ||
| 437 | f.publish(f.repo) | ||
| 438 | files := map[string]string{ | ||
| 439 | "issues/01M5JR2JK29KKMAA1T9MDNTPD1/issue.md": issueFile("Open thing", "keenan", "open", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"), | ||
| 440 | "issues/01M5JR2JK29KKMAA1T9MDNTPD2/issue.md": issueFile("Closed thing", "keenan", "closed", "2026-10-22T21:00:00Z", []string{"bug"}, "body\n"), | ||
| 441 | } | ||
| 442 | work := f.issuesRepo(t, files) | ||
| 443 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 444 | t.Fatalf("push: %s", errs) | ||
| 445 | } | ||
| 446 | |||
| 447 | resp, body := f.htmxGet(t, "/r/"+f.repo+"/issues?state=closed", "") | ||
| 448 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="issue-list"`) || | ||
| 449 | strings.Contains(body, "<!doctype html>") { | ||
| 450 | t.Fatalf("issues fragment = %d:\n%s", resp.StatusCode, body) | ||
| 451 | } | ||
| 452 | if !strings.Contains(body, "Closed thing") || strings.Contains(body, "Open thing") { | ||
| 453 | t.Fatalf("issues fragment did not apply the state filter:\n%s", body) | ||
| 454 | } | ||
| 455 | if !strings.Contains(body, `hx-target="#issue-list"`) { | ||
| 456 | t.Fatal("issues fragment lost its HTMX target") | ||
| 457 | } | ||
| 458 | |||
| 459 | resp, body = f.htmxGet(t, "/r/"+f.repo+"/requests?state=all", "") | ||
| 460 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="change-list"`) || | ||
| 461 | strings.Contains(body, "<!doctype html>") { | ||
| 462 | t.Fatalf("changes fragment = %d:\n%s", resp.StatusCode, body) | ||
| 463 | } | ||
| 464 | if !strings.Contains(body, `hx-target="#change-list"`) { | ||
| 465 | t.Fatal("changes fragment lost its HTMX target") | ||
| 466 | } | ||
| 467 | } | ||
| 468 | |||
| 469 | func TestLineAnchoredReviewCommentFromTheWeb(t *testing.T) { | ||
| 470 | t.Parallel() | ||
| 471 | f := newFixture(t) | ||
| 472 | f.publish(f.repo) | ||
| 473 | base := workRepo(t, map[string]string{"readme.md": "v1\n"}) | ||
| 474 | if _, errs, err := f.git(base, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 475 | t.Fatalf("main push: %s", errs) | ||
| 476 | } | ||
| 477 | feature, _ := f.cloneWithChange(t, map[string]string{"readme.md": "v1\nfeature\n"}, "feature") | ||
| 478 | if _, errs, err := f.git(feature, "push", f.sshURL(f.repo), "HEAD:refs/heads/feature"); err != nil { | ||
| 479 | t.Fatalf("feature push: %s", errs) | ||
| 480 | } | ||
| 481 | sess, cookie := f.browserSession(t, f.ownerID) | ||
| 482 | resp, body := f.browserPost(t, "/r/"+f.repo+"/requests", cookie, url.Values{ | ||
| 483 | "csrf": {sess.CSRF}, "title": {"Line comments"}, "body": {"body\n"}, | ||
| 484 | "base": {"main"}, "head": {"feature"}, | ||
| 485 | }) | ||
| 486 | if resp.StatusCode != http.StatusSeeOther { | ||
| 487 | t.Fatalf("create change: %d\n%s", resp.StatusCode, body) | ||
| 488 | } | ||
| 489 | id := strings.TrimPrefix(resp.Header.Get("Location"), "/r/"+f.repo+"/requests/") | ||
| 490 | |||
| 491 | // The diff offers a per-line comment link on the added line. | ||
| 492 | _, page := f.browserGet(t, "/r/"+f.repo+"/requests/"+id, cookie) | ||
| 493 | if !strings.Contains(page, "path=readme.md") || !strings.Contains(page, "line=2") { | ||
| 494 | t.Fatalf("diff has no line comment link:\n%s", page) | ||
| 495 | } | ||
| 496 | |||
| 497 | // Following it opens the composer with the anchor pre-filled. | ||
| 498 | _, page = f.browserGet(t, "/r/"+f.repo+"/requests/"+id+"?path=readme.md&line=2&side=new", cookie) | ||
| 499 | if !strings.Contains(page, "Commenting on") || !strings.Contains(page, `name="path" value="readme.md"`) || | ||
| 500 | !strings.Contains(page, `name="line" value="2"`) { | ||
| 501 | t.Fatalf("anchored composer is not pre-filled:\n%s", page) | ||
| 502 | } | ||
| 503 | |||
| 504 | // Posting it through HTMX stores the anchor and returns the discussion. | ||
| 505 | resp, body = f.htmxPost(t, "/r/"+f.repo+"/requests/"+id+"/comments", cookie, url.Values{ | ||
| 506 | "csrf": {sess.CSRF}, "body": {"This line needs a second look."}, | ||
| 507 | "path": {"readme.md"}, "line": {"2"}, "side": {"new"}, | ||
| 508 | }) | ||
| 509 | if resp.StatusCode != http.StatusOK || !strings.Contains(body, `id="request-discussion"`) || | ||
| 510 | !strings.Contains(body, "This line needs a second look.") { | ||
| 511 | t.Fatalf("anchored comment response = %d:\n%s", resp.StatusCode, body) | ||
| 512 | } | ||
| 513 | req, err := metadata.GetRequest(mustRepo(t, f).Git(), id) | ||
| 514 | if err != nil { | ||
| 515 | t.Fatal(err) | ||
| 516 | } | ||
| 517 | if len(req.Comments) != 1 || req.Comments[0].Anchor == nil { | ||
| 518 | t.Fatalf("anchored comment was not stored: %+v", req.Comments) | ||
| 519 | } | ||
| 520 | a := req.Comments[0].Anchor | ||
| 521 | if a.Path != "readme.md" || a.Line != 2 || a.Side != "new" || a.Blob == "" { | ||
| 522 | t.Fatalf("anchor = %+v", a) | ||
| 523 | } | ||
| 524 | } | ||