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.
Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys
keenan · 21m ago
4b8c9e1d
| 1 | package server_test |
| 2 | |
| 3 | // The shell is shared by every page: a missing path, a private repository, and a |
| 4 | // normal page must all come back as Vole HTML, not as a bare Go error string. |
| 5 | |
| 6 | import ( |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "strings" |
| 10 | "testing" |
| 11 | "time" |
| 12 | |
| 13 | "rodent.software/vole/forge/config" |
| 14 | ) |
| 15 | |
| 16 | func TestUnknownPathIsAVolePage(t *testing.T) { |
| 17 | t.Parallel() |
| 18 | f := newFixture(t) |
| 19 | code, body := f.get("/no/such/page") |
| 20 | if code != 404 { |
| 21 | t.Fatalf("unknown path = %d, want 404", code) |
| 22 | } |
| 23 | if !strings.Contains(body, "Back to repositories") { |
| 24 | t.Fatalf("unknown path is not the styled not-found page:\n%s", body) |
| 25 | } |
| 26 | } |
| 27 | |
| 28 | // The instance name is the product an organisation runs: it belongs in the one |
| 29 | // place a reader always looks, the tab, and in the brand link's accessible name. |
| 30 | func TestInstanceNameReachesThePageChrome(t *testing.T) { |
| 31 | t.Parallel() |
| 32 | f := newFixtureWith(t, func(c *config.Config) { c.InstanceName = "Acme Forge" }) |
| 33 | f.publish(f.repo) |
| 34 | for _, path := range []string{"/", "/" + f.repo, "/" + f.repo + "/issues", "/no/such/page"} { |
| 35 | code, body := f.get(path) |
| 36 | if code != 200 && code != 404 { |
| 37 | t.Fatalf("%s = %d", path, code) |
| 38 | } |
| 39 | if !strings.Contains(body, " · Acme Forge</title>") { |
| 40 | t.Fatalf("%s does not name the instance in its tab title:\n%s", path, body) |
| 41 | } |
| 42 | if !strings.Contains(body, `aria-label="Acme Forge home"`) { |
| 43 | t.Fatalf("%s does not name the instance in the brand link:\n%s", path, body) |
| 44 | } |
| 45 | if strings.Contains(body, "· Vole</title>") || strings.Contains(body, `aria-label="Vole home"`) { |
| 46 | t.Fatalf("%s still calls the instance Vole:\n%s", path, body) |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | // An instance that never set a name is Vole, and the shell must not render the |
| 52 | // separator on its own when a page has no title to put in front of it. |
| 53 | func TestChromeDoesNotDangleASeparatorWithoutAName(t *testing.T) { |
| 54 | t.Parallel() |
| 55 | f := newFixture(t) |
| 56 | for _, path := range []string{"/", "/no/such/page"} { |
| 57 | _, body := f.get(path) |
| 58 | if !strings.Contains(body, "· Vole</title>") { |
| 59 | t.Fatalf("%s does not fall back to Vole in the tab title:\n%s", path, body) |
| 60 | } |
| 61 | if strings.Contains(body, "<title>· ") || strings.Contains(body, `aria-label=" home"`) { |
| 62 | t.Fatalf("%s renders a dangling separator or an empty label:\n%s", path, body) |
| 63 | } |
| 64 | } |
| 65 | } |
| 66 | |
| 67 | func TestRepositoryPagesShareTheTabHeader(t *testing.T) { |
| 68 | t.Parallel() |
| 69 | f := newFixture(t) |
| 70 | f.publish(f.repo) |
| 71 | for _, path := range []string{ |
| 72 | "/" + f.repo, |
| 73 | "/" + f.repo + "/activity", |
| 74 | "/" + f.repo + "/code", |
| 75 | "/" + f.repo + "/issues", |
| 76 | "/" + f.repo + "/changes", |
| 77 | "/" + f.repo + "/releases", |
| 78 | "/" + f.repo + "/runs", |
| 79 | "/" + f.repo + "/locks", |
| 80 | } { |
| 81 | code, body := f.get(path) |
| 82 | if code != 200 { |
| 83 | t.Fatalf("%s = %d", path, code) |
| 84 | } |
| 85 | for _, want := range []string{`class="repotab`, ">Activity<", ">Code<", ">Issues <span class=\"chip-count\" id=\"repo-issue-count\">0</span><", ">Changes <span class=\"chip-count\" id=\"repo-request-count\">0</span><", ">Releases<", ">Runs<", ">Locks<"} { |
| 86 | if !strings.Contains(body, want) { |
| 87 | t.Fatalf("%s is missing the %s tab:\n%s", path, want, body) |
| 88 | } |
| 89 | } |
| 90 | if !strings.Contains(body, `class="page-sub"`) { |
| 91 | t.Fatalf("%s is missing its page subtitle:\n%s", path, body) |
| 92 | } |
| 93 | if !strings.Contains(body, `class="page-head"`) { |
| 94 | t.Fatalf("%s is missing the shared page header:\n%s", path, body) |
| 95 | } |
| 96 | } |
| 97 | } |
| 98 | |
| 99 | func TestRepoTabsCountOpenIssuesAndRequests(t *testing.T) { |
| 100 | t.Parallel() |
| 101 | f := newFixture(t) |
| 102 | f.publish(f.repo) |
| 103 | |
| 104 | // Two issues, one closed: the badge counts what the Issues tab lists by |
| 105 | // default, not every record on the metadata branch. |
| 106 | work := f.issuesRepo(t, map[string]string{ |
| 107 | "issues/01M5JR2JK29KKMAA1T9MDNTQ01/issue.md": issueFile( |
| 108 | "Still open", "keenan", "open", "2026-10-22T21:00:00Z", nil, ""), |
| 109 | "issues/01M5JR2JK29KKMAA1T9MDNTQ02/issue.md": issueFile( |
| 110 | "Already closed", "keenan", "closed", "2026-10-22T21:00:00Z", nil, ""), |
| 111 | }) |
| 112 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { |
| 113 | t.Fatalf("push issues: %v\n%s", err, errs) |
| 114 | } |
| 115 | |
| 116 | // The request only has to exist and be open for the header to count it; the |
| 117 | // tab count does not read the branch or the diff. |
| 118 | head := strings.Repeat("a", 40) |
| 119 | meta := f.newMetaRepo(t) |
| 120 | if err := meta.apply(map[string]string{ |
| 121 | "changes/01M5JR2JK29KKMAA1T9MDNTR01/change.md": requestFileTitled( |
| 122 | "Still open", "main", head, "keenan", "open\n"), |
| 123 | "changes/01M5JR2JK29KKMAA1T9MDNTR02/change.md": requestFileState( |
| 124 | "Already closed", "main", head, "keenan", "closed", "closed\n"), |
| 125 | }, "requests"); err != nil { |
| 126 | t.Fatalf("requests push: %v", err) |
| 127 | } |
| 128 | |
| 129 | // The header is shared, so a page that shows nothing but code must carry the |
| 130 | // same counts as the lists themselves. |
| 131 | code, body := f.get("/" + f.repo + "/code") |
| 132 | if code != 200 { |
| 133 | t.Fatalf("code page = %d", code) |
| 134 | } |
| 135 | for _, want := range []string{ |
| 136 | `>Issues <span class="chip-count" id="repo-issue-count">1</span>`, |
| 137 | `>Changes <span class="chip-count" id="repo-request-count">1</span>`, |
| 138 | } { |
| 139 | if !strings.Contains(body, want) { |
| 140 | t.Fatalf("the repository tabs do not show %q:\n%s", want, body) |
| 141 | } |
| 142 | } |
| 143 | } |
| 144 | |
| 145 | func TestRepoTabShowsNameAndVisibility(t *testing.T) { |
| 146 | t.Parallel() |
| 147 | f := newFixture(t) |
| 148 | |
| 149 | // A new repository is private until its ACL default is raised to read. |
| 150 | _, cookie := f.browserSession(t, f.ownerID) |
| 151 | resp, body := f.browserGet(t, "/"+f.repo, cookie) |
| 152 | if resp.StatusCode != 200 || !strings.Contains(body, `class="repotab active"`) || |
| 153 | !strings.Contains(body, `title="Private repository"`) || !strings.Contains(body, "demo</div>") { |
| 154 | t.Fatalf("private repo header = %d:\n%s", resp.StatusCode, body) |
| 155 | } |
| 156 | if strings.Index(body, `class="repo-name"`) > strings.Index(body, `class="tabs"`) { |
| 157 | t.Fatalf("repository title is not above the tabs:\n%s", body) |
| 158 | } |
| 159 | |
| 160 | f.publish(f.repo) |
| 161 | _, body = f.get("/" + f.repo + "/code") |
| 162 | if !strings.Contains(body, `title="Public repository"`) || !strings.Contains(body, ">Overview</a>") { |
| 163 | t.Fatalf("public repo header does not show the globe state or Overview tab:\n%s", body) |
| 164 | } |
| 165 | } |
| 166 | |
| 167 | func TestStaticAssetsAreServedFromTheBinary(t *testing.T) { |
| 168 | t.Parallel() |
| 169 | f := newFixture(t) |
| 170 | code, body := f.get("/-/static/vole.css") |
| 171 | if code != 200 { |
| 172 | t.Fatalf("stylesheet = %d", code) |
| 173 | } |
| 174 | if !strings.Contains(body, "--accent") { |
| 175 | t.Fatalf("stylesheet does not look like the token sheet:\n%s", body[:min(len(body), 200)]) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | // Everforest and the other built-in palettes are scoped to the attribute |
| 180 | // theme.js sets. The shell starts on Everforest dark/medium, the account page |
| 181 | // owns the picker, and every variant is scoped to the attribute theme.js sets. |
| 182 | // If the shell stops naming a sheet the picker does nothing. The instance |
| 183 | // repository may add its own themes; instance_repo_test.go covers that path. |
| 184 | func TestEverforestThemesAndAccountPicker(t *testing.T) { |
| 185 | t.Parallel() |
| 186 | f := newFixture(t) |
| 187 | |
| 188 | code, shell := f.get("/") |
| 189 | if code != 200 { |
| 190 | t.Fatalf("landing page = %d", code) |
| 191 | } |
| 192 | for _, want := range []string{ |
| 193 | `src="/-/static/theme.js"`, |
| 194 | `href="/-/static/everforest.css"`, |
| 195 | `href="/-/static/palettes.css"`, |
| 196 | `data-theme="everforest-dark-medium"`, |
| 197 | `data-font-scale="medium"`, |
| 198 | } { |
| 199 | if !strings.Contains(shell, want) { |
| 200 | t.Errorf("the shell does not carry %s", want) |
| 201 | } |
| 202 | } |
| 203 | if strings.Contains(shell, "data-theme-select") { |
| 204 | t.Error("the theme picker belongs on the account page, not in the global bar") |
| 205 | } |
| 206 | if script, sheet := strings.Index(shell, "/-/static/theme.js"), strings.Index(shell, "/-/static/vole.css"); script < 0 || sheet < 0 || script > sheet { |
| 207 | t.Error("theme.js must run before the stylesheet so a stored choice does not flash") |
| 208 | } |
| 209 | |
| 210 | _, cookie := f.browserSession(t, f.ownerID) |
| 211 | resp, account := f.browserGet(t, "/-/account", cookie) |
| 212 | if resp.StatusCode != 200 { |
| 213 | t.Fatalf("/-/account = %d", resp.StatusCode) |
| 214 | } |
| 215 | if !strings.Contains(account, "data-theme-select") || !strings.Contains(account, `value="system"`) { |
| 216 | t.Fatal("the account page does not carry the theme picker") |
| 217 | } |
| 218 | if !strings.Contains(account, "data-font-scale-select") { |
| 219 | t.Fatal("the account page does not carry the text-size picker") |
| 220 | } |
| 221 | for _, size := range []string{"small", "medium", "large"} { |
| 222 | if !strings.Contains(account, `value="`+size+`"`) { |
| 223 | t.Errorf("the text-size picker does not offer %s", size) |
| 224 | } |
| 225 | } |
| 226 | for _, variant := range []string{ |
| 227 | "everforest-dark-hard", |
| 228 | "everforest-dark-medium", |
| 229 | "everforest-dark-soft", |
| 230 | "everforest-light-hard", |
| 231 | "everforest-light-medium", |
| 232 | "everforest-light-soft", |
| 233 | "catppuccin-mocha", |
| 234 | "catppuccin-latte", |
| 235 | "nord", |
| 236 | "solarized-light", |
| 237 | "rose-pine-moon", |
| 238 | "rose-pine-dawn", |
| 239 | "kanagawa-wave", |
| 240 | "dracula", |
| 241 | "tokyo-night", |
| 242 | "ayu-light", |
| 243 | } { |
| 244 | if !strings.Contains(account, `value="`+variant+`"`) { |
| 245 | t.Errorf("the theme picker does not offer %s", variant) |
| 246 | } |
| 247 | } |
| 248 | |
| 249 | code, css := f.get("/-/static/everforest.css") |
| 250 | if code != 200 { |
| 251 | t.Fatalf("/-/static/everforest.css = %d", code) |
| 252 | } |
| 253 | for _, variant := range []string{ |
| 254 | "everforest-dark-hard", |
| 255 | "everforest-dark-medium", |
| 256 | "everforest-dark-soft", |
| 257 | "everforest-light-hard", |
| 258 | "everforest-light-medium", |
| 259 | "everforest-light-soft", |
| 260 | } { |
| 261 | if !strings.Contains(css, `data-theme="`+variant+`"`) { |
| 262 | t.Errorf("the Everforest sheet does not define %s", variant) |
| 263 | } |
| 264 | } |
| 265 | if !strings.Contains(css, `--ef-green: #a7c080`) { |
| 266 | t.Fatal("the Everforest sheet does not carry the dark palette") |
| 267 | } |
| 268 | if strings.Contains(css, "\nbody {") { |
| 269 | t.Fatal("the Everforest sheet has an unscoped body rule and would restyle the fallback") |
| 270 | } |
| 271 | |
| 272 | code, palettes := f.get("/-/static/palettes.css") |
| 273 | if code != 200 { |
| 274 | t.Fatalf("/-/static/palettes.css = %d", code) |
| 275 | } |
| 276 | for _, variant := range []string{ |
| 277 | "catppuccin-mocha", |
| 278 | "catppuccin-latte", |
| 279 | "nord", |
| 280 | "solarized-light", |
| 281 | "rose-pine-moon", |
| 282 | "rose-pine-dawn", |
| 283 | "kanagawa-wave", |
| 284 | "dracula", |
| 285 | "tokyo-night", |
| 286 | "ayu-light", |
| 287 | } { |
| 288 | if !strings.Contains(palettes, `data-theme="`+variant+`"`) { |
| 289 | t.Errorf("the palette sheet does not define %s", variant) |
| 290 | } |
| 291 | } |
| 292 | if strings.Contains(palettes, "\nbody {") { |
| 293 | t.Fatal("the palette sheet has an unscoped body rule and would restyle the fallback") |
| 294 | } |
| 295 | |
| 296 | code, base := f.get("/-/static/vole.css") |
| 297 | if code != 200 { |
| 298 | t.Fatalf("/-/static/vole.css = %d", code) |
| 299 | } |
| 300 | for _, want := range []string{`--font-scale: 0.9`, `--font-scale: 1.1`, `font-size: calc(13.2px * var(--font-scale))`} { |
| 301 | if !strings.Contains(base, want) { |
| 302 | t.Errorf("the base sheet does not carry the text-size scaling %s", want) |
| 303 | } |
| 304 | } |
| 305 | |
| 306 | code, script := f.get("/-/static/theme.js") |
| 307 | if code != 200 { |
| 308 | t.Fatalf("/-/static/theme.js = %d", code) |
| 309 | } |
| 310 | for _, want := range []string{"localStorage", "dataset.theme", "dataset.themes", "prefers-color-scheme", "everforest-light-medium", "dataset.fontScale", "vole-font-scale"} { |
| 311 | if !strings.Contains(script, want) { |
| 312 | t.Errorf("the theme script does not carry %s", want) |
| 313 | } |
| 314 | } |
| 315 | } |
| 316 | |
| 317 | // The account page used to render its tables bare, so they had no border or |
| 318 | // rounded corners. Every data table on a page surface belongs in a panel. |
| 319 | func TestAccountTablesUseThePanelSurface(t *testing.T) { |
| 320 | t.Parallel() |
| 321 | f := newFixture(t) |
| 322 | _, cookie := f.browserSession(t, f.ownerID) |
| 323 | resp, body := f.browserGet(t, "/-/account", cookie) |
| 324 | if resp.StatusCode != 200 { |
| 325 | t.Fatalf("/-/account = %d", resp.StatusCode) |
| 326 | } |
| 327 | for _, heading := range []string{"SSH keys", "Sessions"} { |
| 328 | at := strings.Index(body, "<h2>"+heading+"</h2>") |
| 329 | if at < 0 { |
| 330 | t.Fatalf("account page has no %s section", heading) |
| 331 | } |
| 332 | section := body[at:] |
| 333 | panel, table := strings.Index(section, `<div class="panel">`), strings.Index(section, "<table") |
| 334 | if panel < 0 || table < 0 || panel > table { |
| 335 | t.Errorf("%s table is not inside a panel", heading) |
| 336 | } |
| 337 | } |
| 338 | } |
| 339 | |
| 340 | // Prism uses "comment" for a syntax token, so an unscoped .comment rule paints |
| 341 | // the discussion card's background and border behind every comment-coloured line |
| 342 | // in a blob or diff. The card is always an <article>, and its base rule names it. |
| 343 | func TestCommentCardStyleDoesNotMatchPrismTokens(t *testing.T) { |
| 344 | t.Parallel() |
| 345 | f := newFixture(t) |
| 346 | code, css := f.get("/-/static/vole.css") |
| 347 | if code != 200 { |
| 348 | t.Fatalf("stylesheet = %d", code) |
| 349 | } |
| 350 | if !strings.Contains(css, "article.comment {") { |
| 351 | t.Fatal("the comment card's base rule is not scoped to article.comment") |
| 352 | } |
| 353 | if strings.Contains(css, "\n.comment {") { |
| 354 | t.Fatal("an unscoped .comment rule also matches Prism's comment token") |
| 355 | } |
| 356 | } |
| 357 | |
| 358 | // HTMX adds its indicator styles with a <style> element unless told not to. |
| 359 | // style-src 'self' blocks that injection, so the shell must carry the config |
| 360 | // that turns it off and the stylesheet must carry the rules it would have added. |
| 361 | func TestHTMXDoesNotInjectInlineStyles(t *testing.T) { |
| 362 | t.Parallel() |
| 363 | f := newFixture(t) |
| 364 | code, shell := f.get("/") |
| 365 | if code != 200 { |
| 366 | t.Fatalf("landing page = %d", code) |
| 367 | } |
| 368 | if !strings.Contains(shell, `name="htmx-config"`) || |
| 369 | !strings.Contains(shell, `"includeIndicatorStyles":false`) { |
| 370 | t.Fatal("the shell does not turn off HTMX's inline indicator styles") |
| 371 | } |
| 372 | code, css := f.get("/-/static/vole.css") |
| 373 | if code != 200 { |
| 374 | t.Fatalf("stylesheet = %d", code) |
| 375 | } |
| 376 | if !strings.Contains(css, ".htmx-indicator") || !strings.Contains(css, ".htmx-request .htmx-indicator") { |
| 377 | t.Fatal("the stylesheet does not carry HTMX's indicator rules") |
| 378 | } |
| 379 | } |
| 380 | |
| 381 | // TestTheEmblemShipsAndTheShellNamesIt: the header mask and the favicon are |
| 382 | // separate assets now, so a page that points at a name nobody ships is still a |
| 383 | // broken mark or tab icon. This is the test that would have caught the rename. |
| 384 | func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) { |
| 385 | t.Parallel() |
| 386 | f := newFixture(t) |
| 387 | |
| 388 | for _, name := range []string{"logo.png", "vole-head.png", "vole-head-solid.png", "favicon-32.png", "favicon-32-dark.png", "favicon-16.png", "favicon-16-dark.png"} { |
| 389 | resp, err := http.Get(f.http.URL + "/-/static/" + name) |
| 390 | if err != nil { |
| 391 | t.Fatal(err) |
| 392 | } |
| 393 | if resp.StatusCode != http.StatusOK { |
| 394 | resp.Body.Close() |
| 395 | t.Fatalf("/-/static/%s = %d", name, resp.StatusCode) |
| 396 | } |
| 397 | if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "png") { |
| 398 | resp.Body.Close() |
| 399 | t.Fatalf("%s Content-Type = %q", name, ct) |
| 400 | } |
| 401 | head := make([]byte, 8) |
| 402 | if _, err := io.ReadFull(resp.Body, head); err != nil { |
| 403 | resp.Body.Close() |
| 404 | t.Fatal(err) |
| 405 | } |
| 406 | resp.Body.Close() |
| 407 | if string(head) != "\x89PNG\r\n\x1a\n" { |
| 408 | t.Errorf("%s is not a PNG: %x", name, head) |
| 409 | } |
| 410 | } |
| 411 | |
| 412 | code, shell := f.get("/") |
| 413 | if code != http.StatusOK { |
| 414 | t.Fatalf("landing page = %d", code) |
| 415 | } |
| 416 | if !strings.Contains(shell, `href="/-/static/favicon-32.png"`) || |
| 417 | !strings.Contains(shell, `href="/-/static/favicon-32-dark.png"`) || |
| 418 | !strings.Contains(shell, `href="/-/static/favicon.svg"`) || |
| 419 | !strings.Contains(shell, `media="(prefers-color-scheme: dark)"`) { |
| 420 | t.Error("the shell does not name the light/dark fallback and SVG favicons") |
| 421 | } |
| 422 | if !strings.Contains(shell, `<span class="mark"`) { |
| 423 | t.Error("the header does not use the head mark") |
| 424 | } |
| 425 | code, css := f.get("/-/static/vole.css") |
| 426 | if code != http.StatusOK { |
| 427 | t.Fatalf("stylesheet = %d", code) |
| 428 | } |
| 429 | if !strings.Contains(css, `url("/-/static/vole-head.png")`) || |
| 430 | !strings.Contains(css, `url("/-/static/vole-head-solid.png")`) || |
| 431 | !strings.Contains(css, "--mark-fill: currentColor") || |
| 432 | !strings.Contains(css, "--mark-outline: transparent") || |
| 433 | !strings.Contains(css, "--mark-fill: var(--bg)") || |
| 434 | !strings.Contains(css, "--mark-outline: var(--fg)") { |
| 435 | t.Error("the head mark does not carry the emblem and light-outline treatment") |
| 436 | } |
| 437 | |
| 438 | code, svg := f.get("/-/static/favicon.svg") |
| 439 | if code != http.StatusOK || !strings.Contains(svg, "<svg") { |
| 440 | t.Fatalf("/-/static/favicon.svg = %d and does not look like an SVG", code) |
| 441 | } |
| 442 | if !strings.Contains(svg, "prefers-color-scheme") || !strings.Contains(svg, "#000") || !strings.Contains(svg, "#fff") { |
| 443 | t.Error("the SVG favicon does not switch between black and white with the browser preference") |
| 444 | } |
| 445 | if _, script := f.get("/-/static/theme.js"); strings.Contains(script, "favicon") { |
| 446 | t.Error("the app theme must not choose the favicon; the browser window preference does") |
| 447 | } |
| 448 | if strings.Contains(shell, "logo.svg") { |
| 449 | t.Error("the shell still points at the removed logo.svg") |
| 450 | } |
| 451 | } |
| 452 | |
| 453 | // Lexend and JetBrains Mono are embedded, not fetched from a CDN. The WOFF2 |
| 454 | // magic and the OFL files beside them are the two things a deployer cannot |
| 455 | // discover from the page shell alone. |
| 456 | func TestSelfHostedFontsAreEmbedded(t *testing.T) { |
| 457 | t.Parallel() |
| 458 | f := newFixture(t) |
| 459 | |
| 460 | for _, name := range []string{ |
| 461 | "lexend-latin.woff2", |
| 462 | "lexend-latin-ext.woff2", |
| 463 | "jetbrains-mono-latin.woff2", |
| 464 | "jetbrains-mono-latin-ext.woff2", |
| 465 | "jetbrains-mono-italic-latin.woff2", |
| 466 | "jetbrains-mono-italic-latin-ext.woff2", |
| 467 | } { |
| 468 | resp, err := http.Get(f.http.URL + "/-/static/fonts/" + name) |
| 469 | if err != nil { |
| 470 | t.Fatal(err) |
| 471 | } |
| 472 | if resp.StatusCode != http.StatusOK { |
| 473 | resp.Body.Close() |
| 474 | t.Fatalf("/-/static/fonts/%s = %d", name, resp.StatusCode) |
| 475 | } |
| 476 | if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "font/woff2") { |
| 477 | resp.Body.Close() |
| 478 | t.Fatalf("%s Content-Type = %q", name, ct) |
| 479 | } |
| 480 | head := make([]byte, 4) |
| 481 | if _, err := io.ReadFull(resp.Body, head); err != nil { |
| 482 | resp.Body.Close() |
| 483 | t.Fatal(err) |
| 484 | } |
| 485 | resp.Body.Close() |
| 486 | if string(head) != "wOF2" { |
| 487 | t.Errorf("%s is not a WOFF2 file: %x", name, head) |
| 488 | } |
| 489 | } |
| 490 | |
| 491 | code, css := f.get("/-/static/vole.css") |
| 492 | if code != http.StatusOK { |
| 493 | t.Fatalf("/-/static/vole.css = %d", code) |
| 494 | } |
| 495 | for _, want := range []string{ |
| 496 | `@font-face`, |
| 497 | `font-family: "Lexend"`, |
| 498 | `font-family: "JetBrains Mono"`, |
| 499 | `/-/static/fonts/lexend-latin.woff2`, |
| 500 | `/-/static/fonts/jetbrains-mono-latin.woff2`, |
| 501 | } { |
| 502 | if !strings.Contains(css, want) { |
| 503 | t.Errorf("the stylesheet does not carry %s", want) |
| 504 | } |
| 505 | } |
| 506 | for _, name := range []string{"OFL-Lexend.txt", "OFL-JetBrainsMono.txt"} { |
| 507 | code, body := f.get("/-/static/fonts/" + name) |
| 508 | if code != http.StatusOK || !strings.Contains(body, "SIL OPEN FONT LICENSE") { |
| 509 | t.Errorf("/-/static/fonts/%s is missing or does not look like the OFL text", name) |
| 510 | } |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | func TestRunsPageListsQueuedWork(t *testing.T) { |
| 515 | t.Parallel() |
| 516 | f := newFixture(t) |
| 517 | f.publish(f.repo) |
| 518 | run := queuedRun("01RUNPAGETEST0000000000000", f.repo, "unit-tests", time.Now().UTC()) |
| 519 | if _, err := f.srv.Runs.Enqueue(run, nil); err != nil { |
| 520 | t.Fatal(err) |
| 521 | } |
| 522 | code, body := f.get("/" + f.repo + "/runs") |
| 523 | if code != 200 { |
| 524 | t.Fatalf("runs page = %d", code) |
| 525 | } |
| 526 | for _, want := range []string{"unit-tests", "/" + f.repo + "/runs/" + run.ID, "Queued"} { |
| 527 | if !strings.Contains(body, want) { |
| 528 | t.Fatalf("runs page does not show %q:\n%s", want, body) |
| 529 | } |
| 530 | } |
| 531 | } |
| 532 | |
| 533 | func TestOverviewAndActivityPages(t *testing.T) { |
| 534 | t.Parallel() |
| 535 | f := newFixture(t) |
| 536 | f.publish(f.repo) |
| 537 | work := workRepo(t, map[string]string{ |
| 538 | "README.md": "# Overview project\n\nA short description.\n", |
| 539 | "main.go": "package main\n", |
| 540 | }) |
| 541 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { |
| 542 | t.Fatalf("push: %s", errs) |
| 543 | } |
| 544 | code, body := f.get("/" + f.repo) |
| 545 | if code != 200 { |
| 546 | t.Fatalf("overview = %d", code) |
| 547 | } |
| 548 | for _, want := range []string{"Overview project", "Recent commits", "Recent issues", "Recent changes", "No open issues.", "No open changes.", "Recent runs", "contributors", "Languages", "Go"} { |
| 549 | if !strings.Contains(body, want) { |
| 550 | t.Fatalf("overview page does not show %q:\n%s", want, body) |
| 551 | } |
| 552 | } |
| 553 | // The activity tab is a separate page. Commits are not feed events: they |
| 554 | // belong to the overview and the code browser. |
| 555 | code, body = f.get("/" + f.repo + "/activity") |
| 556 | if code != 200 || !strings.Contains(body, "Activity") || strings.Contains(body, "committed") { |
| 557 | t.Fatalf("activity page = %d:\n%s", code, body) |
| 558 | } |
| 559 | // Code browsing moved to its own tab, and that page still lists the tree. |
| 560 | if code, codePage := f.get("/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "main.go") { |
| 561 | t.Fatalf("code page = %d:\n%s", code, codePage) |
| 562 | } |
| 563 | } |
| 564 | |
| 565 | func TestOverviewShowsRecentOpenIssuesAndChanges(t *testing.T) { |
| 566 | t.Parallel() |
| 567 | f := newFixture(t) |
| 568 | f.publish(f.repo) |
| 569 | work := workRepo(t, map[string]string{"README.md": "# Overview project\n"}) |
| 570 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { |
| 571 | t.Fatalf("push: %s", errs) |
| 572 | } |
| 573 | |
| 574 | const ( |
| 575 | openIssueID = "01M5JR2JK29KKMAA1T9MDNTP01" |
| 576 | closedIssueID = "01M5JR2JK29KKMAA1T9MDNTP02" |
| 577 | ) |
| 578 | issues := f.issuesRepo(t, map[string]string{ |
| 579 | "issues/" + openIssueID + "/issue.md": issueFile( |
| 580 | "Overview open issue", "keenan", "open", "2026-10-22T21:00:00Z", nil, ""), |
| 581 | "issues/" + closedIssueID + "/issue.md": issueFile( |
| 582 | "Overview closed issue", "keenan", "closed", "2026-10-23T21:00:00Z", nil, ""), |
| 583 | }) |
| 584 | if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { |
| 585 | t.Fatalf("push issues: %v\n%s", err, errs) |
| 586 | } |
| 587 | |
| 588 | const ( |
| 589 | openChangeID = "01M5JR2JK29KKMAA1T9MDNTR01" |
| 590 | closedChangeID = "01M5JR2JK29KKMAA1T9MDNTR02" |
| 591 | ) |
| 592 | head := strings.Repeat("a", 40) |
| 593 | meta := f.newMetaRepo(t) |
| 594 | if err := meta.apply(map[string]string{ |
| 595 | "changes/" + openChangeID + "/change.md": requestFileState( |
| 596 | "Overview open change", "main", head, "keenan", "open", "open\n"), |
| 597 | "changes/" + closedChangeID + "/change.md": requestFileState( |
| 598 | "Overview closed change", "main", head, "keenan", "closed", "closed\n"), |
| 599 | }, "requests"); err != nil { |
| 600 | t.Fatalf("requests push: %v", err) |
| 601 | } |
| 602 | |
| 603 | code, body := f.get("/" + f.repo) |
| 604 | if code != 200 { |
| 605 | t.Fatalf("overview = %d", code) |
| 606 | } |
| 607 | for _, want := range []string{ |
| 608 | "Recent issues", "Recent changes", |
| 609 | "Overview open issue", "Overview open change", |
| 610 | `href="/` + f.repo + `/issues/` + openIssueID + `"`, |
| 611 | `href="/` + f.repo + `/changes/` + openChangeID + `"`, |
| 612 | } { |
| 613 | if !strings.Contains(body, want) { |
| 614 | t.Fatalf("overview does not show %q:\n%s", want, body) |
| 615 | } |
| 616 | } |
| 617 | for _, gone := range []string{"Overview closed issue", "Overview closed change"} { |
| 618 | if strings.Contains(body, gone) { |
| 619 | t.Fatalf("overview shows closed record %q:\n%s", gone, body) |
| 620 | } |
| 621 | } |
| 622 | } |
| 623 | |
| 624 | func TestOverviewContributorsAreAccountsNotCommitAuthors(t *testing.T) { |
| 625 | t.Parallel() |
| 626 | f := newFixture(t) |
| 627 | f.publish(f.repo) |
| 628 | f.addWriter(t) // wilbur |
| 629 | |
| 630 | const ( |
| 631 | keenanIssue = "01M5JR2JK29KKMAA1T9MDNTP11" |
| 632 | wilburIssue = "01M5JR2JK29KKMAA1T9MDNTP12" |
| 633 | changeID = "01M5JR2JK29KKMAA1T9MDNTR11" |
| 634 | ) |
| 635 | head := strings.Repeat("a", 40) |
| 636 | issues := f.issuesRepo(t, map[string]string{ |
| 637 | "issues/" + keenanIssue + "/issue.md": issueFile( |
| 638 | "Reported by keenan", "keenan", "open", "2026-10-22T21:00:00Z", nil, ""), |
| 639 | "issues/" + wilburIssue + "/issue.md": issueFile( |
| 640 | "Reported by wilbur", "wilbur", "open", "2026-10-22T21:01:00Z", nil, ""), |
| 641 | }) |
| 642 | if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { |
| 643 | t.Fatalf("push issues: %v\n%s", err, errs) |
| 644 | } |
| 645 | |
| 646 | // Wilbur's change closes both reports and carries keenan's approval of the |
| 647 | // exact head that merged. |
| 648 | meta := f.newMetaRepo(t) |
| 649 | if err := meta.apply(map[string]string{ |
| 650 | "issues/" + keenanIssue + "/issue.md": issueFile( |
| 651 | "Reported by keenan", "keenan", "closed", "2026-10-22T21:00:00Z", nil, ""), |
| 652 | "issues/" + wilburIssue + "/issue.md": issueFile( |
| 653 | "Reported by wilbur", "wilbur", "closed", "2026-10-22T21:01:00Z", nil, ""), |
| 654 | "changes/" + changeID + "/change.md": requestFileMerged( |
| 655 | "Fix both reports", "main", head, "wilbur", "keenan", |
| 656 | []string{keenanIssue, wilburIssue}), |
| 657 | "changes/" + changeID + "/approvals/keenan.md": approvalFile("main", head), |
| 658 | }, "merge wilbur's change"); err != nil { |
| 659 | t.Fatalf("push merged change: %v", err) |
| 660 | } |
| 661 | |
| 662 | code, body := f.get("/" + f.repo) |
| 663 | if code != 200 { |
| 664 | t.Fatalf("overview = %d", code) |
| 665 | } |
| 666 | for _, want := range []string{ |
| 667 | "Contributors", |
| 668 | `href="/-/u/wilbur"`, |
| 669 | `data-tip="1 change merged"`, `data-tip="2 issues fixed"`, `data-tip="1 issue reported"`, |
| 670 | `href="/-/u/keenan"`, `data-tip="1 review"`, |
| 671 | } { |
| 672 | if !strings.Contains(body, want) { |
| 673 | t.Fatalf("overview does not show %q:\n%s", want, body) |
| 674 | } |
| 675 | } |
| 676 | } |
| 677 | |
| 678 | func TestCodeBrowserTreeAndFragments(t *testing.T) { |
| 679 | t.Parallel() |
| 680 | f := newFixture(t) |
| 681 | f.publish(f.repo) |
| 682 | work := workRepo(t, map[string]string{ |
| 683 | "README.md": "# Code browser\n", |
| 684 | "main.go": "package main\n", |
| 685 | "internal/run.go": "package internal\n", |
| 686 | }) |
| 687 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { |
| 688 | t.Fatalf("push: %s", errs) |
| 689 | } |
| 690 | |
| 691 | code, body := f.get("/" + f.repo + "/code") |
| 692 | if code != 200 { |
| 693 | t.Fatalf("code browser = %d", code) |
| 694 | } |
| 695 | for _, want := range []string{ |
| 696 | `id="file-tree"`, `id="code-main"`, `data-path="internal/run.go"`, |
| 697 | `data-path="README.md"`, "tree-file active", "internal", |
| 698 | } { |
| 699 | if !strings.Contains(body, want) { |
| 700 | t.Fatalf("code browser does not contain %q:\n%s", want, body) |
| 701 | } |
| 702 | } |
| 703 | |
| 704 | // A file click is a fragment: the tree stays put and the main pane is replaced. |
| 705 | resp, fragment := f.htmxGet(t, "/"+f.repo+"/blob/main/internal/run.go", "") |
| 706 | if resp.StatusCode != 200 || !strings.Contains(fragment, `id="code-main"`) || |
| 707 | !strings.Contains(fragment, `data-mode="blob"`) || |
| 708 | strings.Contains(fragment, "<!doctype html>") || |
| 709 | strings.Contains(fragment, `id="file-tree"`) || |
| 710 | !strings.Contains(fragment, `data-highlight="go"`) { |
| 711 | t.Fatalf("blob fragment = %d:\n%s", resp.StatusCode, fragment) |
| 712 | } |
| 713 | |
| 714 | // A directory click replaces the main pane with a listing whose child links |
| 715 | // carry the directory path rather than only the leaf name. |
| 716 | resp, fragment = f.htmxGet(t, "/"+f.repo+"/tree/main/internal", "") |
| 717 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) || |
| 718 | !strings.Contains(fragment, "/"+f.repo+"/blob/main/internal/run.go") { |
| 719 | t.Fatalf("tree fragment = %d:\n%s", resp.StatusCode, fragment) |
| 720 | } |
| 721 | |
| 722 | // The branch crumb returns to the Code overview, not a second directory |
| 723 | // listing inside the main pane. |
| 724 | resp, fragment = f.htmxGet(t, "/"+f.repo+"/code?ref=main", "") |
| 725 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="overview"`) || |
| 726 | !strings.Contains(fragment, "<h1>Code browser</h1>") { |
| 727 | t.Fatalf("branch crumb fragment = %d:\n%s", resp.StatusCode, fragment) |
| 728 | } |
| 729 | if !strings.Contains(body, `href="/`+f.repo+`/code?ref=main"`) { |
| 730 | t.Fatalf("branch crumb does not target the code overview:\n%s", body) |
| 731 | } |
| 732 | |
| 733 | // The branch root still has its own directory URL, used by the ".." row. |
| 734 | resp, fragment = f.htmxGet(t, "/"+f.repo+"/tree/main", "") |
| 735 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) || |
| 736 | !strings.Contains(fragment, "main.go") { |
| 737 | t.Fatalf("root tree fragment = %d:\n%s", resp.StatusCode, fragment) |
| 738 | } |
| 739 | |
| 740 | // A direct directory visit renders the same shell, with the folder open in |
| 741 | // the tree and a link back to its parent. |
| 742 | code, body = f.get("/" + f.repo + "/tree/main/internal") |
| 743 | if code != 200 || !strings.Contains(body, `id="file-tree"`) || |
| 744 | !strings.Contains(body, "internal/run.go") || !strings.Contains(body, "..") { |
| 745 | t.Fatalf("directory page = %d:\n%s", code, body) |
| 746 | } |
| 747 | } |
| 748 | |
| 749 | func TestCodeBrowserRefPickers(t *testing.T) { |
| 750 | t.Parallel() |
| 751 | f := newFixture(t) |
| 752 | f.publish(f.repo) |
| 753 | work := workRepo(t, map[string]string{"README.md": "# refs\n"}) |
| 754 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "release notes"); err != nil { |
| 755 | t.Fatalf("tag: %s", errs) |
| 756 | } |
| 757 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main", "refs/tags/v1.0.0"); err != nil { |
| 758 | t.Fatalf("push: %s", errs) |
| 759 | } |
| 760 | |
| 761 | // Search and the ref pickers share one row under the page header, and the |
| 762 | // tag picker lists tags beside the branch picker. |
| 763 | code, body := f.get("/" + f.repo + "/code") |
| 764 | if code != 200 { |
| 765 | t.Fatalf("code page = %d", code) |
| 766 | } |
| 767 | filters := body[strings.Index(body, `class="filters"`):] |
| 768 | for _, want := range []string{ |
| 769 | `aria-label="Search code"`, |
| 770 | `<span class="filter-label">Branch</span>`, |
| 771 | `<span class="filter-label">Tag</span>`, |
| 772 | `<option value="main" selected>main</option>`, |
| 773 | `<option value="v1.0.0">v1.0.0</option>`, |
| 774 | } { |
| 775 | if !strings.Contains(filters, want) { |
| 776 | t.Fatalf("ref picker row does not contain %q:\n%s", want, body) |
| 777 | } |
| 778 | } |
| 779 | |
| 780 | // Opening a tag selects it in the tag picker and leaves the branch picker |
| 781 | // unclaimed rather than pretending the first branch is open. |
| 782 | code, body = f.get("/" + f.repo + "/code?ref=v1.0.0") |
| 783 | if code != 200 { |
| 784 | t.Fatalf("tag page = %d", code) |
| 785 | } |
| 786 | filters = body[strings.Index(body, `class="filters"`):] |
| 787 | for _, want := range []string{ |
| 788 | `<option value="v1.0.0" selected>v1.0.0</option>`, |
| 789 | `<option value="" disabled selected>—</option>`, |
| 790 | } { |
| 791 | if !strings.Contains(filters, want) { |
| 792 | t.Fatalf("tag page ref pickers do not contain %q:\n%s", want, body) |
| 793 | } |
| 794 | } |
| 795 | if strings.Contains(filters, `<option value="main" selected>main</option>`) { |
| 796 | t.Fatalf("branch picker claims main while a tag is open:\n%s", body) |
| 797 | } |
| 798 | } |
| 799 | |
| 800 | // Filter rows are one component: the same class, a visible label on every |
| 801 | // select, selects that apply on change, and a Search button for text. A new page |
| 802 | // should reuse this row instead of inventing a second shape. |
| 803 | func TestFilterRowsShareOneShape(t *testing.T) { |
| 804 | t.Parallel() |
| 805 | f := newFixture(t) |
| 806 | f.publish(f.repo) |
| 807 | work := workRepo(t, map[string]string{"README.md": "# filters\n"}) |
| 808 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { |
| 809 | t.Fatalf("push: %s", errs) |
| 810 | } |
| 811 | issues := f.issuesRepo(t, map[string]string{ |
| 812 | "issues/01M5JR2JK29KKMAA1T9MDNTPF1/issue.md": issueFile("Filter shape", "keenan", "open", |
| 813 | "2026-10-22T21:00:00Z", []string{"bug"}, "body\n", "wilbur"), |
| 814 | }) |
| 815 | if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { |
| 816 | t.Fatalf("issues push: %s", errs) |
| 817 | } |
| 818 | |
| 819 | for _, row := range []struct { |
| 820 | path string |
| 821 | labels []string |
| 822 | }{ |
| 823 | {"/" + f.repo + "/issues", []string{"Label", "Assignee"}}, |
| 824 | {"/" + f.repo + "/activity", []string{"Actor", "Assignee"}}, |
| 825 | {"/" + f.repo + "/code", []string{"Branch"}}, |
| 826 | } { |
| 827 | code, body := f.get(row.path) |
| 828 | if code != 200 { |
| 829 | t.Fatalf("%s = %d", row.path, code) |
| 830 | } |
| 831 | at := strings.Index(body, `class="filters"`) |
| 832 | if at < 0 { |
| 833 | t.Fatalf("%s has no shared filter row", row.path) |
| 834 | } |
| 835 | filters := body[at:] |
| 836 | for _, label := range row.labels { |
| 837 | if !strings.Contains(filters, `<span class="filter-label">`+label+`</span>`) { |
| 838 | t.Errorf("%s has an unlabeled %s dropdown:\n%s", row.path, label, body) |
| 839 | } |
| 840 | } |
| 841 | if !strings.Contains(filters, "data-autosubmit") { |
| 842 | t.Errorf("%s does not apply its selects on change", row.path) |
| 843 | } |
| 844 | if strings.Contains(filters, ">Filter<") { |
| 845 | t.Errorf("%s still has its own Filter button", row.path) |
| 846 | } |
| 847 | } |
| 848 | |
| 849 | // Text search is the same button on every page: an icon and the word Search. |
| 850 | for _, path := range []string{ |
| 851 | "/" + f.repo + "/issues", |
| 852 | "/" + f.repo + "/changes", |
| 853 | "/" + f.repo + "/search", |
| 854 | "/" + f.repo + "/code", |
| 855 | } { |
| 856 | code, body := f.get(path) |
| 857 | if code != 200 { |
| 858 | t.Fatalf("%s = %d", path, code) |
| 859 | } |
| 860 | at := strings.Index(body, `class="filters"`) |
| 861 | if at < 0 || !strings.Contains(body[at:], "Search</button>") { |
| 862 | t.Errorf("%s does not end its search field with a Search button:\n%s", path, body) |
| 863 | } |
| 864 | } |
| 865 | } |
| 866 | |
| 867 | func TestSourceBlobNamesItsLanguageAndEscapes(t *testing.T) { |
| 868 | t.Parallel() |
| 869 | f := newFixture(t) |
| 870 | f.publish(f.repo) |
| 871 | work := workRepo(t, map[string]string{ |
| 872 | "main.go": "package main\n\n// <script>alert(1)</script>\nfunc main() {}\n", |
| 873 | }) |
| 874 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { |
| 875 | t.Fatalf("push: %s", errs) |
| 876 | } |
| 877 | code, body := f.get("/" + f.repo + "/blob/main/main.go") |
| 878 | if code != 200 { |
| 879 | t.Fatalf("blob = %d", code) |
| 880 | } |
| 881 | if !strings.Contains(body, `data-highlight="go"`) { |
| 882 | t.Fatalf("source did not name its language:\n%s", body[:min(len(body), 500)]) |
| 883 | } |
| 884 | if strings.Contains(body, "<script>alert(1)</script>") { |
| 885 | t.Fatalf("source blob executed a tag:\n%s", body) |
| 886 | } |
| 887 | } |
| 888 | |
| 889 | func TestActivityFeedIncludesIssuesAndFilters(t *testing.T) { |
| 890 | t.Parallel() |
| 891 | f := newFixture(t) |
| 892 | f.publish(f.repo) |
| 893 | work := workRepo(t, map[string]string{"README.md": "# activity\n"}) |
| 894 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "release notes"); err != nil { |
| 895 | t.Fatalf("tag: %s", errs) |
| 896 | } |
| 897 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main", "refs/tags/v1.0.0"); err != nil { |
| 898 | t.Fatalf("main push: %s", errs) |
| 899 | } |
| 900 | issues := f.issuesRepo(t, issueFiles("01M5JR2JK29KKMAA1T9MDNTPE1", "Activity issue", "body\n")) |
| 901 | if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { |
| 902 | t.Fatalf("issues push: %s", errs) |
| 903 | } |
| 904 | |
| 905 | _, body := f.get("/" + f.repo + "/activity") |
| 906 | for _, want := range []string{"Activity issue", "opened issue", "v1.0.0", "released"} { |
| 907 | if !strings.Contains(body, want) { |
| 908 | t.Fatalf("activity feed does not show %q:\n%s", want, body) |
| 909 | } |
| 910 | } |
| 911 | if strings.Contains(body, "committed") { |
| 912 | t.Fatalf("activity feed still shows commits:\n%s", body) |
| 913 | } |
| 914 | _, body = f.get("/" + f.repo + "/activity?kind=issues") |
| 915 | if !strings.Contains(body, "Activity issue") { |
| 916 | t.Fatalf("issue filter dropped the issue:\n%s", body) |
| 917 | } |
| 918 | if strings.Contains(body, "v1.0.0") { |
| 919 | t.Fatalf("issue filter kept a release:\n%s", body) |
| 920 | } |
| 921 | resp, fragment := f.htmxGet(t, "/"+f.repo+"/activity?kind=issues", "") |
| 922 | if resp.StatusCode != 200 || !strings.Contains(fragment, `id="activity-feed"`) || |
| 923 | !strings.Contains(fragment, "Activity issue") { |
| 924 | t.Fatalf("issue activity fragment = %d:\n%s", resp.StatusCode, fragment) |
| 925 | } |
| 926 | } |
| 927 | |
| 928 | // Every script is a static asset: the page references it under /-/static/ and |
| 929 | // nothing is inlined into a template, so the CSP stays script-src 'self' and the |
| 930 | // script is editable as JavaScript rather than as a Go string. The passkey |
| 931 | // ceremony used to be the exception and is not one any more; its old URL is gone |
| 932 | // rather than kept as a second way to reach the same bytes. |
| 933 | func TestPasskeyScriptIsAStaticAsset(t *testing.T) { |
| 934 | t.Parallel() |
| 935 | f := newFixture(t) |
| 936 | code, body := f.get("/-/static/auth.js") |
| 937 | if code != 200 { |
| 938 | t.Fatalf("/-/static/auth.js = %d", code) |
| 939 | } |
| 940 | if !strings.Contains(body, "navigator.credentials") { |
| 941 | t.Fatalf("/-/static/auth.js is not the ceremony script:\n%s", body) |
| 942 | } |
| 943 | if code, _ := f.get("/auth.js"); code != 404 { |
| 944 | t.Fatalf("/auth.js = %d, want 404: the script has one URL", code) |
| 945 | } |
| 946 | if _, page := f.get("/-/login"); !strings.Contains(page, `src="/-/static/auth.js"`) { |
| 947 | t.Fatal("the page shell does not load the passkey script from /-/static/") |
| 948 | } |
| 949 | } |
| 950 | |
| 951 | // The filter row reads left to right on every surface: the chips and labelled |
| 952 | // dropdowns first, the search group last and against the right edge. A page with |
| 953 | // nothing to search puts its clear action in the same slot. |
| 954 | func TestFilterRowPutsFiltersBeforeSearch(t *testing.T) { |
| 955 | t.Parallel() |
| 956 | f := newFixture(t) |
| 957 | f.publish(f.repo) |
| 958 | work := workRepo(t, map[string]string{"README.md": "# row\n"}) |
| 959 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { |
| 960 | t.Fatalf("push: %s", errs) |
| 961 | } |
| 962 | issues := f.issuesRepo(t, map[string]string{ |
| 963 | "issues/01M5JR2JK29KKMAA1T9MDNTPF1/issue.md": issueFile("Filter row", "keenan", "open", |
| 964 | "2026-10-22T21:00:00Z", []string{"bug"}, "body\n", "wilbur"), |
| 965 | }) |
| 966 | if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { |
| 967 | t.Fatalf("issues push: %s", errs) |
| 968 | } |
| 969 | |
| 970 | for _, row := range []struct { |
| 971 | path string |
| 972 | chips bool |
| 973 | }{ |
| 974 | {"/" + f.repo + "/issues", true}, |
| 975 | {"/" + f.repo + "/changes", true}, |
| 976 | {"/" + f.repo + "/activity?actor=keenan", true}, |
| 977 | {"/" + f.repo + "/code", false}, |
| 978 | {"/" + f.repo + "/search", false}, |
| 979 | } { |
| 980 | code, body := f.get(row.path) |
| 981 | if code != 200 { |
| 982 | t.Fatalf("%s = %d", row.path, code) |
| 983 | } |
| 984 | at := strings.Index(body, `class="filters"`) |
| 985 | if at < 0 { |
| 986 | t.Fatalf("%s has no filter row", row.path) |
| 987 | } |
| 988 | line := body[at:] |
| 989 | search := strings.Index(line, `class="filters-search"`) |
| 990 | if search < 0 { |
| 991 | t.Fatalf("%s has no right-aligned search group:\n%s", row.path, body) |
| 992 | } |
| 993 | if !strings.Contains(line[search:], `aria-label="Search`) && |
| 994 | !strings.Contains(line[search:], "Search</button>") && |
| 995 | !strings.Contains(line[search:], ">Clear</a>") { |
| 996 | t.Fatalf("%s's right group holds no search or clear action:\n%s", row.path, body) |
| 997 | } |
| 998 | for _, left := range []string{`class="chips"`, `<span class="filter-label">`} { |
| 999 | if i := strings.Index(line, left); i >= 0 && i > search { |
| 1000 | t.Fatalf("%s puts %q after the search group:\n%s", row.path, left, body) |
| 1001 | } |
| 1002 | } |
| 1003 | if row.chips && !strings.Contains(line[:search], `class="chips"`) { |
| 1004 | t.Fatalf("%s does not keep its chips in the filter row:\n%s", row.path, body) |
| 1005 | } |
| 1006 | } |
| 1007 | } |
| 1008 |