Sign in

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

vole
Clone

Read only, no credentials:

https://rodent.software/vole.git

Write over SSH with a registered key:

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

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

Blame · server/ui_test.go

Line attribution on main · 1007 line(s)

f09ef39e
Keenan Nemetz
15d ago
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 (
6416cab1
Keenan Nemetz
13d ago
7 "io"
8 "net/http"
f09ef39e
Keenan Nemetz
15d ago
9 "strings"
10 "testing"
11 "time"
3d707ea7
Keenan Nemetz
21h ago
12
d37d301f
Keenan Nemetz
19h ago
13 "rodent.software/vole/forge/config"
f09ef39e
Keenan Nemetz
15d ago
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
3d707ea7
Keenan Nemetz
21h ago
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)
ed03b3d1
Keenan Nemetz
6h ago
34 for _, path := range []string{"/", "/" + f.repo, "/" + f.repo + "/issues", "/no/such/page"} {
3d707ea7
Keenan Nemetz
21h ago
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
f09ef39e
Keenan Nemetz
15d ago
67 func TestRepositoryPagesShareTheTabHeader(t *testing.T) {
68 t.Parallel()
69 f := newFixture(t)
70 f.publish(f.repo)
71 for _, path := range []string{
ed03b3d1
Keenan Nemetz
6h ago
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",
f09ef39e
Keenan Nemetz
15d ago
80 } {
81 code, body := f.get(path)
82 if code != 200 {
83 t.Fatalf("%s = %d", path, code)
84 }
a2ec9561
Keenan Nemetz
9d ago
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<"} {
f09ef39e
Keenan Nemetz
15d ago
86 if !strings.Contains(body, want) {
87 t.Fatalf("%s is missing the %s tab:\n%s", path, want, body)
88 }
89 }
20d459f5
Keenan Nemetz
11d ago
90 if !strings.Contains(body, `class="page-sub"`) {
91 t.Fatalf("%s is missing its page subtitle:\n%s", path, body)
92 }
a464f892
Keenan Nemetz
15d ago
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
a196e404
Keenan Nemetz
11d ago
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{
b492b557
Keenan Nemetz
3d ago
121 "changes/01M5JR2JK29KKMAA1T9MDNTR01/change.md": requestFileTitled(
a196e404
Keenan Nemetz
11d ago
122 "Still open", "main", head, "keenan", "open\n"),
b492b557
Keenan Nemetz
3d ago
123 "changes/01M5JR2JK29KKMAA1T9MDNTR02/change.md": requestFileState(
a196e404
Keenan Nemetz
11d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
131 code, body := f.get("/" + f.repo + "/code")
a196e404
Keenan Nemetz
11d ago
132 if code != 200 {
133 t.Fatalf("code page = %d", code)
134 }
135 for _, want := range []string{
a2ec9561
Keenan Nemetz
9d ago
136 `>Issues <span class="chip-count" id="repo-issue-count">1</span>`,
137 `>Changes <span class="chip-count" id="repo-request-count">1</span>`,
a196e404
Keenan Nemetz
11d ago
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
a464f892
Keenan Nemetz
15d ago
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)
ed03b3d1
Keenan Nemetz
6h ago
151 resp, body := f.browserGet(t, "/"+f.repo, cookie)
a464f892
Keenan Nemetz
15d ago
152 if resp.StatusCode != 200 || !strings.Contains(body, `class="repotab active"`) ||
074ae160
Keenan Nemetz
14d ago
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)
a464f892
Keenan Nemetz
15d ago
158 }
159
160 f.publish(f.repo)
ed03b3d1
Keenan Nemetz
6h ago
161 _, body = f.get("/" + f.repo + "/code")
074ae160
Keenan Nemetz
14d ago
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)
f09ef39e
Keenan Nemetz
15d ago
164 }
165 }
166
167 func TestStaticAssetsAreServedFromTheBinary(t *testing.T) {
168 t.Parallel()
169 f := newFixture(t)
ed03b3d1
Keenan Nemetz
6h ago
170 code, body := f.get("/-/static/vole.css")
f09ef39e
Keenan Nemetz
15d ago
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
0584bb70
Keenan Nemetz
1d ago
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
f7a34d87
Keenan Nemetz
1d ago
183 // repository may add its own themes; instance_repo_test.go covers that path.
796ac8c0
Keenan Nemetz
11d ago
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{
ed03b3d1
Keenan Nemetz
6h ago
193 `src="/-/static/theme.js"`,
194 `href="/-/static/everforest.css"`,
195 `href="/-/static/palettes.css"`,
796ac8c0
Keenan Nemetz
11d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
206 if script, sheet := strings.Index(shell, "/-/static/theme.js"), strings.Index(shell, "/-/static/vole.css"); script < 0 || sheet < 0 || script > sheet {
796ac8c0
Keenan Nemetz
11d ago
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)
ed03b3d1
Keenan Nemetz
6h ago
211 resp, account := f.browserGet(t, "/-/account", cookie)
796ac8c0
Keenan Nemetz
11d ago
212 if resp.StatusCode != 200 {
ed03b3d1
Keenan Nemetz
6h ago
213 t.Fatalf("/-/account = %d", resp.StatusCode)
796ac8c0
Keenan Nemetz
11d ago
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",
0584bb70
Keenan Nemetz
1d ago
233 "catppuccin-mocha",
234 "catppuccin-latte",
235 "nord",
236 "solarized-light",
97b3b022
Keenan Nemetz
1d ago
237 "rose-pine-moon",
238 "rose-pine-dawn",
239 "kanagawa-wave",
240 "dracula",
241 "tokyo-night",
242 "ayu-light",
796ac8c0
Keenan Nemetz
11d ago
243 } {
244 if !strings.Contains(account, `value="`+variant+`"`) {
245 t.Errorf("the theme picker does not offer %s", variant)
246 }
247 }
248
ed03b3d1
Keenan Nemetz
6h ago
249 code, css := f.get("/-/static/everforest.css")
796ac8c0
Keenan Nemetz
11d ago
250 if code != 200 {
ed03b3d1
Keenan Nemetz
6h ago
251 t.Fatalf("/-/static/everforest.css = %d", code)
796ac8c0
Keenan Nemetz
11d ago
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
ed03b3d1
Keenan Nemetz
6h ago
272 code, palettes := f.get("/-/static/palettes.css")
0584bb70
Keenan Nemetz
1d ago
273 if code != 200 {
ed03b3d1
Keenan Nemetz
6h ago
274 t.Fatalf("/-/static/palettes.css = %d", code)
0584bb70
Keenan Nemetz
1d ago
275 }
276 for _, variant := range []string{
277 "catppuccin-mocha",
278 "catppuccin-latte",
279 "nord",
280 "solarized-light",
97b3b022
Keenan Nemetz
1d ago
281 "rose-pine-moon",
282 "rose-pine-dawn",
283 "kanagawa-wave",
284 "dracula",
285 "tokyo-night",
286 "ayu-light",
0584bb70
Keenan Nemetz
1d ago
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
ed03b3d1
Keenan Nemetz
6h ago
296 code, base := f.get("/-/static/vole.css")
796ac8c0
Keenan Nemetz
11d ago
297 if code != 200 {
ed03b3d1
Keenan Nemetz
6h ago
298 t.Fatalf("/-/static/vole.css = %d", code)
796ac8c0
Keenan Nemetz
11d ago
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
ed03b3d1
Keenan Nemetz
6h ago
306 code, script := f.get("/-/static/theme.js")
796ac8c0
Keenan Nemetz
11d ago
307 if code != 200 {
ed03b3d1
Keenan Nemetz
6h ago
308 t.Fatalf("/-/static/theme.js = %d", code)
796ac8c0
Keenan Nemetz
11d ago
309 }
f7a34d87
Keenan Nemetz
1d ago
310 for _, want := range []string{"localStorage", "dataset.theme", "dataset.themes", "prefers-color-scheme", "everforest-light-medium", "dataset.fontScale", "vole-font-scale"} {
796ac8c0
Keenan Nemetz
11d ago
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)
ed03b3d1
Keenan Nemetz
6h ago
323 resp, body := f.browserGet(t, "/-/account", cookie)
796ac8c0
Keenan Nemetz
11d ago
324 if resp.StatusCode != 200 {
ed03b3d1
Keenan Nemetz
6h ago
325 t.Fatalf("/-/account = %d", resp.StatusCode)
796ac8c0
Keenan Nemetz
11d ago
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
cfd43dcd
Keenan Nemetz
12d ago
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)
ed03b3d1
Keenan Nemetz
6h ago
346 code, css := f.get("/-/static/vole.css")
cfd43dcd
Keenan Nemetz
12d ago
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
ac00929f
Keenan Nemetz
12d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
372 code, css := f.get("/-/static/vole.css")
ac00929f
Keenan Nemetz
12d ago
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
796ac8c0
Keenan Nemetz
11d ago
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.
6416cab1
Keenan Nemetz
13d ago
384 func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) {
385 t.Parallel()
386 f := newFixture(t)
387
8f0f3e8d
Keenan Nemetz
1d ago
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"} {
ed03b3d1
Keenan Nemetz
6h ago
389 resp, err := http.Get(f.http.URL + "/-/static/" + name)
6416cab1
Keenan Nemetz
13d ago
390 if err != nil {
391 t.Fatal(err)
392 }
393 if resp.StatusCode != http.StatusOK {
394 resp.Body.Close()
ed03b3d1
Keenan Nemetz
6h ago
395 t.Fatalf("/-/static/%s = %d", name, resp.StatusCode)
6416cab1
Keenan Nemetz
13d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
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"`) ||
796ac8c0
Keenan Nemetz
11d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
425 code, css := f.get("/-/static/vole.css")
796ac8c0
Keenan Nemetz
11d ago
426 if code != http.StatusOK {
427 t.Fatalf("stylesheet = %d", code)
428 }
ed03b3d1
Keenan Nemetz
6h ago
429 if !strings.Contains(css, `url("/-/static/vole-head.png")`) ||
430 !strings.Contains(css, `url("/-/static/vole-head-solid.png")`) ||
e3b9c83c
Keenan Nemetz
1d ago
431 !strings.Contains(css, "--mark-fill: currentColor") ||
432 !strings.Contains(css, "--mark-outline: transparent") ||
433 !strings.Contains(css, "--mark-fill: var(--bg)") ||
ed667a0e
Keenan Nemetz
1d ago
434 !strings.Contains(css, "--mark-outline: var(--fg)") {
e3b9c83c
Keenan Nemetz
1d ago
435 t.Error("the head mark does not carry the emblem and light-outline treatment")
6416cab1
Keenan Nemetz
13d ago
436 }
796ac8c0
Keenan Nemetz
11d ago
437
ed03b3d1
Keenan Nemetz
6h ago
438 code, svg := f.get("/-/static/favicon.svg")
796ac8c0
Keenan Nemetz
11d ago
439 if code != http.StatusOK || !strings.Contains(svg, "<svg") {
ed03b3d1
Keenan Nemetz
6h ago
440 t.Fatalf("/-/static/favicon.svg = %d and does not look like an SVG", code)
796ac8c0
Keenan Nemetz
11d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
445 if _, script := f.get("/-/static/theme.js"); strings.Contains(script, "favicon") {
796ac8c0
Keenan Nemetz
11d ago
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 } {
ed03b3d1
Keenan Nemetz
6h ago
468 resp, err := http.Get(f.http.URL + "/-/static/fonts/" + name)
796ac8c0
Keenan Nemetz
11d ago
469 if err != nil {
470 t.Fatal(err)
471 }
472 if resp.StatusCode != http.StatusOK {
473 resp.Body.Close()
ed03b3d1
Keenan Nemetz
6h ago
474 t.Fatalf("/-/static/fonts/%s = %d", name, resp.StatusCode)
796ac8c0
Keenan Nemetz
11d ago
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
ed03b3d1
Keenan Nemetz
6h ago
491 code, css := f.get("/-/static/vole.css")
796ac8c0
Keenan Nemetz
11d ago
492 if code != http.StatusOK {
ed03b3d1
Keenan Nemetz
6h ago
493 t.Fatalf("/-/static/vole.css = %d", code)
796ac8c0
Keenan Nemetz
11d ago
494 }
495 for _, want := range []string{
496 `@font-face`,
497 `font-family: "Lexend"`,
498 `font-family: "JetBrains Mono"`,
ed03b3d1
Keenan Nemetz
6h ago
499 `/-/static/fonts/lexend-latin.woff2`,
500 `/-/static/fonts/jetbrains-mono-latin.woff2`,
796ac8c0
Keenan Nemetz
11d ago
501 } {
502 if !strings.Contains(css, want) {
503 t.Errorf("the stylesheet does not carry %s", want)
504 }
6416cab1
Keenan Nemetz
13d ago
505 }
796ac8c0
Keenan Nemetz
11d ago
506 for _, name := range []string{"OFL-Lexend.txt", "OFL-JetBrainsMono.txt"} {
ed03b3d1
Keenan Nemetz
6h ago
507 code, body := f.get("/-/static/fonts/" + name)
796ac8c0
Keenan Nemetz
11d ago
508 if code != http.StatusOK || !strings.Contains(body, "SIL OPEN FONT LICENSE") {
ed03b3d1
Keenan Nemetz
6h ago
509 t.Errorf("/-/static/fonts/%s is missing or does not look like the OFL text", name)
6416cab1
Keenan Nemetz
13d ago
510 }
511 }
512 }
513
f09ef39e
Keenan Nemetz
15d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
522 code, body := f.get("/" + f.repo + "/runs")
f09ef39e
Keenan Nemetz
15d ago
523 if code != 200 {
524 t.Fatalf("runs page = %d", code)
525 }
ed03b3d1
Keenan Nemetz
6h ago
526 for _, want := range []string{"unit-tests", "/" + f.repo + "/runs/" + run.ID, "Queued"} {
f09ef39e
Keenan Nemetz
15d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
544 code, body := f.get("/" + f.repo)
f09ef39e
Keenan Nemetz
15d ago
545 if code != 200 {
546 t.Fatalf("overview = %d", code)
547 }
b06184d1
Keenan Nemetz
11d ago
548 for _, want := range []string{"Overview project", "Recent commits", "Recent issues", "Recent changes", "No open issues.", "No open changes.", "Recent runs", "contributors", "Languages", "Go"} {
f09ef39e
Keenan Nemetz
15d ago
549 if !strings.Contains(body, want) {
550 t.Fatalf("overview page does not show %q:\n%s", want, body)
551 }
552 }
074ae160
Keenan Nemetz
14d ago
553 // The activity tab is a separate page. Commits are not feed events: they
554 // belong to the overview and the code browser.
ed03b3d1
Keenan Nemetz
6h ago
555 code, body = f.get("/" + f.repo + "/activity")
074ae160
Keenan Nemetz
14d ago
556 if code != 200 || !strings.Contains(body, "Activity") || strings.Contains(body, "committed") {
f09ef39e
Keenan Nemetz
15d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
560 if code, codePage := f.get("/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "main.go") {
f09ef39e
Keenan Nemetz
15d ago
561 t.Fatalf("code page = %d:\n%s", code, codePage)
562 }
563 }
564
a196e404
Keenan Nemetz
11d ago
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{
b492b557
Keenan Nemetz
3d ago
595 "changes/" + openChangeID + "/change.md": requestFileState(
a196e404
Keenan Nemetz
11d ago
596 "Overview open change", "main", head, "keenan", "open", "open\n"),
b492b557
Keenan Nemetz
3d ago
597 "changes/" + closedChangeID + "/change.md": requestFileState(
a196e404
Keenan Nemetz
11d ago
598 "Overview closed change", "main", head, "keenan", "closed", "closed\n"),
599 }, "requests"); err != nil {
600 t.Fatalf("requests push: %v", err)
601 }
602
ed03b3d1
Keenan Nemetz
6h ago
603 code, body := f.get("/" + f.repo)
a196e404
Keenan Nemetz
11d ago
604 if code != 200 {
605 t.Fatalf("overview = %d", code)
606 }
607 for _, want := range []string{
b06184d1
Keenan Nemetz
11d ago
608 "Recent issues", "Recent changes",
a196e404
Keenan Nemetz
11d ago
609 "Overview open issue", "Overview open change",
ed03b3d1
Keenan Nemetz
6h ago
610 `href="/` + f.repo + `/issues/` + openIssueID + `"`,
611 `href="/` + f.repo + `/changes/` + openChangeID + `"`,
a196e404
Keenan Nemetz
11d ago
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
a32a859a
Keenan Nemetz
5d ago
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, ""),
b492b557
Keenan Nemetz
3d ago
654 "changes/" + changeID + "/change.md": requestFileMerged(
a32a859a
Keenan Nemetz
5d ago
655 "Fix both reports", "main", head, "wilbur", "keenan",
656 []string{keenanIssue, wilburIssue}),
b492b557
Keenan Nemetz
3d ago
657 "changes/" + changeID + "/approvals/keenan.md": approvalFile("main", head),
a32a859a
Keenan Nemetz
5d ago
658 }, "merge wilbur's change"); err != nil {
659 t.Fatalf("push merged change: %v", err)
660 }
661
ed03b3d1
Keenan Nemetz
6h ago
662 code, body := f.get("/" + f.repo)
a32a859a
Keenan Nemetz
5d ago
663 if code != 200 {
664 t.Fatalf("overview = %d", code)
665 }
666 for _, want := range []string{
667 "Contributors",
ed03b3d1
Keenan Nemetz
6h ago
668 `href="/-/u/wilbur"`,
a32a859a
Keenan Nemetz
5d ago
669 `data-tip="1 change merged"`, `data-tip="2 issues fixed"`, `data-tip="1 issue reported"`,
ed03b3d1
Keenan Nemetz
6h ago
670 `href="/-/u/keenan"`, `data-tip="1 review"`,
a32a859a
Keenan Nemetz
5d ago
671 } {
672 if !strings.Contains(body, want) {
673 t.Fatalf("overview does not show %q:\n%s", want, body)
674 }
675 }
676 }
677
a464f892
Keenan Nemetz
15d ago
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
ed03b3d1
Keenan Nemetz
6h ago
691 code, body := f.get("/" + f.repo + "/code")
a464f892
Keenan Nemetz
15d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
705 resp, fragment := f.htmxGet(t, "/"+f.repo+"/blob/main/internal/run.go", "")
a464f892
Keenan Nemetz
15d ago
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"`) ||
306f36fc
Keenan Nemetz
12d ago
710 !strings.Contains(fragment, `data-highlight="go"`) {
a464f892
Keenan Nemetz
15d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
716 resp, fragment = f.htmxGet(t, "/"+f.repo+"/tree/main/internal", "")
a464f892
Keenan Nemetz
15d ago
717 if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) ||
ed03b3d1
Keenan Nemetz
6h ago
718 !strings.Contains(fragment, "/"+f.repo+"/blob/main/internal/run.go") {
a464f892
Keenan Nemetz
15d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
724 resp, fragment = f.htmxGet(t, "/"+f.repo+"/code?ref=main", "")
a464f892
Keenan Nemetz
15d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
729 if !strings.Contains(body, `href="/`+f.repo+`/code?ref=main"`) {
a464f892
Keenan Nemetz
15d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
734 resp, fragment = f.htmxGet(t, "/"+f.repo+"/tree/main", "")
a464f892
Keenan Nemetz
15d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
742 code, body = f.get("/" + f.repo + "/tree/main/internal")
a464f892
Keenan Nemetz
15d ago
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
55615c7a
Keenan Nemetz
11d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
763 code, body := f.get("/" + f.repo + "/code")
55615c7a
Keenan Nemetz
11d ago
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.
ed03b3d1
Keenan Nemetz
6h ago
782 code, body = f.get("/" + f.repo + "/code?ref=v1.0.0")
55615c7a
Keenan Nemetz
11d ago
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 }{
ed03b3d1
Keenan Nemetz
6h ago
823 {"/" + f.repo + "/issues", []string{"Label", "Assignee"}},
824 {"/" + f.repo + "/activity", []string{"Actor", "Assignee"}},
825 {"/" + f.repo + "/code", []string{"Branch"}},
55615c7a
Keenan Nemetz
11d ago
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{
ed03b3d1
Keenan Nemetz
6h ago
851 "/" + f.repo + "/issues",
852 "/" + f.repo + "/changes",
853 "/" + f.repo + "/search",
854 "/" + f.repo + "/code",
55615c7a
Keenan Nemetz
11d ago
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
306f36fc
Keenan Nemetz
12d ago
867 func TestSourceBlobNamesItsLanguageAndEscapes(t *testing.T) {
f09ef39e
Keenan Nemetz
15d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
877 code, body := f.get("/" + f.repo + "/blob/main/main.go")
f09ef39e
Keenan Nemetz
15d ago
878 if code != 200 {
879 t.Fatalf("blob = %d", code)
880 }
306f36fc
Keenan Nemetz
12d ago
881 if !strings.Contains(body, `data-highlight="go"`) {
882 t.Fatalf("source did not name its language:\n%s", body[:min(len(body), 500)])
f09ef39e
Keenan Nemetz
15d ago
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"})
074ae160
Keenan Nemetz
14d ago
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 {
f09ef39e
Keenan Nemetz
15d ago
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
ed03b3d1
Keenan Nemetz
6h ago
905 _, body := f.get("/" + f.repo + "/activity")
074ae160
Keenan Nemetz
14d ago
906 for _, want := range []string{"Activity issue", "opened issue", "v1.0.0", "released"} {
f09ef39e
Keenan Nemetz
15d ago
907 if !strings.Contains(body, want) {
908 t.Fatalf("activity feed does not show %q:\n%s", want, body)
909 }
910 }
074ae160
Keenan Nemetz
14d ago
911 if strings.Contains(body, "committed") {
912 t.Fatalf("activity feed still shows commits:\n%s", body)
913 }
ed03b3d1
Keenan Nemetz
6h ago
914 _, body = f.get("/" + f.repo + "/activity?kind=issues")
f09ef39e
Keenan Nemetz
15d ago
915 if !strings.Contains(body, "Activity issue") {
916 t.Fatalf("issue filter dropped the issue:\n%s", body)
917 }
074ae160
Keenan Nemetz
14d ago
918 if strings.Contains(body, "v1.0.0") {
919 t.Fatalf("issue filter kept a release:\n%s", body)
f09ef39e
Keenan Nemetz
15d ago
920 }
ed03b3d1
Keenan Nemetz
6h ago
921 resp, fragment := f.htmxGet(t, "/"+f.repo+"/activity?kind=issues", "")
f09ef39e
Keenan Nemetz
15d ago
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 }
c717d4eb
Keenan Nemetz
13d ago
927
ed03b3d1
Keenan Nemetz
6h ago
928 // Every script is a static asset: the page references it under /-/static/ and
c717d4eb
Keenan Nemetz
13d ago
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)
ed03b3d1
Keenan Nemetz
6h ago
936 code, body := f.get("/-/static/auth.js")
c717d4eb
Keenan Nemetz
13d ago
937 if code != 200 {
ed03b3d1
Keenan Nemetz
6h ago
938 t.Fatalf("/-/static/auth.js = %d", code)
c717d4eb
Keenan Nemetz
13d ago
939 }
940 if !strings.Contains(body, "navigator.credentials") {
ed03b3d1
Keenan Nemetz
6h ago
941 t.Fatalf("/-/static/auth.js is not the ceremony script:\n%s", body)
c717d4eb
Keenan Nemetz
13d ago
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 }
ed03b3d1
Keenan Nemetz
6h ago
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/")
c717d4eb
Keenan Nemetz
13d ago
948 }
949 }
6c98a271
Keenan Nemetz
9d ago
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 }{
ed03b3d1
Keenan Nemetz
6h ago
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},
6c98a271
Keenan Nemetz
9d ago
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 }