Sign in

Search issue titles and bodies, change requests and comments. Private repositories you cannot read are not included.

vole
Clone

Read only, no credentials:

https://rodent.software/git/vole.git

Write over SSH with a registered key:

git clone ssh://git@0.0.0.0:2222/vole.git

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

Code

392 commit(s) on main

Merge change request 01M70WES5ZZDJ6RP226WB11BBZ: Let people rename and remove their own passkeys keenan · 21m ago 4b8c9e1d
history blame raw 35.6 KiB
1package 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
6import (
7 "io"
8 "net/http"
9 "strings"
10 "testing"
11 "time"
12
13 "rodent.software/vole/forge/config"
14)
15
16func 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.
30func 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.
53func 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
67func 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
99func 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
145func 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
167func 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.
184func 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.
319func 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.
343func 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.
361func 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.
384func 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.
456func 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
514func 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
533func 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
565func 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
624func 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
678func 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
749func 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.
803func 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
867func 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
889func 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.
933func 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.
954func 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