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.
Serve repositories from the root of the URL space
ed03b3d1fa16ef6b55e2773aaf10e3be13282c3a · Keenan Nemetz · 4h ago · parent d37d301f
Serve repositories from the root of the URL space A repository is now /<name> and its Git transport /<name>.git, so the URL a person opens, the URL a clone uses, and the Go module path are the same name. The forge's own pages moved under /-/ (admin, accounts, the directory, the feed, sign-in, assets), a prefix no repository name can start with: names are [a-z0-9...] and cannot begin with "-". That leaves the router with two disjoint spaces and removes the /r/ and /git/ prefixes, which existed only to keep repositories away from the forge's own paths. Routes: repoRoutes lose /r/; global, auth and admin routes gain /-/; assets move to /-/static/ because a subtree pattern like /static/ cannot coexist with one-segment-wildcard repo routes — Go's mux reports them as conflicting neither-more-specific patterns. The catch-all still renders the styled 404. Transport: githttp.Route parses /<name>.git/<rest> instead of a /git/ prefix, and forgepaths.go hands such requests to the proxy before the mux, because a wildcard fills a whole segment and cannot match "<name>.git". LFS stays on the mux (<name>.git/info/lfs/...), and the Go module discovery probe moves to the same pre-router so a package path under a public repository is answered even when its first segments name a page tab. Names: repo.ValidateName now refuses the four root paths the forge must keep — static, robots.txt, sitemap.xml, sitemap-pages.xml. Everything else is an ordinary repository name, including admin, u, and activity. Docs and the packaged URLs follow the new layout; robots.txt, the sitemaps and the instance activity feed point at the new paths. No aliases: the old /r/ and /git/ URLs are gone. Verified with ./vole workflow check and ./vole workflow run verify.
server/ui_test.go +93−93
| @@ -31,7 +31,7 @@ func TestInstanceNameReachesThePageChrome(t *testing.T) { | |||
| 31 | 31 | t.Parallel() | |
| 32 | 32 | f := newFixtureWith(t, func(c *config.Config) { c.InstanceName = "Acme Forge" }) | |
| 33 | 33 | f.publish(f.repo) | |
| 34 | for _, path := range []string{"/", "/r/" + f.repo, "/r/" + f.repo + "/issues", "/no/such/page"} { | ||
| 34 | for _, path := range []string{"/", "/" + f.repo, "/" + f.repo + "/issues", "/no/such/page"} { | ||
| 35 | 35 | code, body := f.get(path) | |
| 36 | 36 | if code != 200 && code != 404 { | |
| 37 | 37 | t.Fatalf("%s = %d", path, code) | |
| @@ -69,14 +69,14 @@ func TestRepositoryPagesShareTheTabHeader(t *testing.T) { | |||
| 69 | 69 | f := newFixture(t) | |
| 70 | 70 | f.publish(f.repo) | |
| 71 | 71 | for _, path := range []string{ | |
| 72 | "/r/" + f.repo, | ||
| 73 | "/r/" + f.repo + "/activity", | ||
| 74 | "/r/" + f.repo + "/code", | ||
| 75 | "/r/" + f.repo + "/issues", | ||
| 76 | "/r/" + f.repo + "/changes", | ||
| 77 | "/r/" + f.repo + "/releases", | ||
| 78 | "/r/" + f.repo + "/runs", | ||
| 79 | "/r/" + f.repo + "/locks", | ||
| 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 | 80 | } { | |
| 81 | 81 | code, body := f.get(path) | |
| 82 | 82 | if code != 200 { | |
| @@ -128,7 +128,7 @@ func TestRepoTabsCountOpenIssuesAndRequests(t *testing.T) { | |||
| 128 | 128 | ||
| 129 | 129 | // The header is shared, so a page that shows nothing but code must carry the | |
| 130 | 130 | // same counts as the lists themselves. | |
| 131 | code, body := f.get("/r/" + f.repo + "/code") | ||
| 131 | code, body := f.get("/" + f.repo + "/code") | ||
| 132 | 132 | if code != 200 { | |
| 133 | 133 | t.Fatalf("code page = %d", code) | |
| 134 | 134 | } | |
| @@ -148,7 +148,7 @@ func TestRepoTabShowsNameAndVisibility(t *testing.T) { | |||
| 148 | 148 | ||
| 149 | 149 | // A new repository is private until its ACL default is raised to read. | |
| 150 | 150 | _, cookie := f.browserSession(t, f.ownerID) | |
| 151 | resp, body := f.browserGet(t, "/r/"+f.repo, cookie) | ||
| 151 | resp, body := f.browserGet(t, "/"+f.repo, cookie) | ||
| 152 | 152 | if resp.StatusCode != 200 || !strings.Contains(body, `class="repotab active"`) || | |
| 153 | 153 | !strings.Contains(body, `title="Private repository"`) || !strings.Contains(body, "demo</div>") { | |
| 154 | 154 | t.Fatalf("private repo header = %d:\n%s", resp.StatusCode, body) | |
| @@ -158,7 +158,7 @@ func TestRepoTabShowsNameAndVisibility(t *testing.T) { | |||
| 158 | 158 | } | |
| 159 | 159 | ||
| 160 | 160 | f.publish(f.repo) | |
| 161 | _, body = f.get("/r/" + f.repo + "/code") | ||
| 161 | _, body = f.get("/" + f.repo + "/code") | ||
| 162 | 162 | if !strings.Contains(body, `title="Public repository"`) || !strings.Contains(body, ">Overview</a>") { | |
| 163 | 163 | t.Fatalf("public repo header does not show the globe state or Overview tab:\n%s", body) | |
| 164 | 164 | } | |
| @@ -167,7 +167,7 @@ func TestRepoTabShowsNameAndVisibility(t *testing.T) { | |||
| 167 | 167 | func TestStaticAssetsAreServedFromTheBinary(t *testing.T) { | |
| 168 | 168 | t.Parallel() | |
| 169 | 169 | f := newFixture(t) | |
| 170 | code, body := f.get("/static/vole.css") | ||
| 170 | code, body := f.get("/-/static/vole.css") | ||
| 171 | 171 | if code != 200 { | |
| 172 | 172 | t.Fatalf("stylesheet = %d", code) | |
| 173 | 173 | } | |
| @@ -190,9 +190,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) { | |||
| 190 | 190 | t.Fatalf("landing page = %d", code) | |
| 191 | 191 | } | |
| 192 | 192 | for _, want := range []string{ | |
| 193 | `src="/static/theme.js"`, | ||
| 194 | `href="/static/everforest.css"`, | ||
| 195 | `href="/static/palettes.css"`, | ||
| 193 | `src="/-/static/theme.js"`, | ||
| 194 | `href="/-/static/everforest.css"`, | ||
| 195 | `href="/-/static/palettes.css"`, | ||
| 196 | 196 | `data-theme="everforest-dark-medium"`, | |
| 197 | 197 | `data-font-scale="medium"`, | |
| 198 | 198 | } { | |
| @@ -203,14 +203,14 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) { | |||
| 203 | 203 | if strings.Contains(shell, "data-theme-select") { | |
| 204 | 204 | t.Error("the theme picker belongs on the account page, not in the global bar") | |
| 205 | 205 | } | |
| 206 | if script, sheet := strings.Index(shell, "/static/theme.js"), strings.Index(shell, "/static/vole.css"); script < 0 || sheet < 0 || script > sheet { | ||
| 206 | if script, sheet := strings.Index(shell, "/-/static/theme.js"), strings.Index(shell, "/-/static/vole.css"); script < 0 || sheet < 0 || script > sheet { | ||
| 207 | 207 | t.Error("theme.js must run before the stylesheet so a stored choice does not flash") | |
| 208 | 208 | } | |
| 209 | 209 | ||
| 210 | 210 | _, cookie := f.browserSession(t, f.ownerID) | |
| 211 | resp, account := f.browserGet(t, "/account", cookie) | ||
| 211 | resp, account := f.browserGet(t, "/-/account", cookie) | ||
| 212 | 212 | if resp.StatusCode != 200 { | |
| 213 | t.Fatalf("/account = %d", resp.StatusCode) | ||
| 213 | t.Fatalf("/-/account = %d", resp.StatusCode) | ||
| 214 | 214 | } | |
| 215 | 215 | if !strings.Contains(account, "data-theme-select") || !strings.Contains(account, `value="system"`) { | |
| 216 | 216 | t.Fatal("the account page does not carry the theme picker") | |
| @@ -246,9 +246,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) { | |||
| 246 | 246 | } | |
| 247 | 247 | } | |
| 248 | 248 | ||
| 249 | code, css := f.get("/static/everforest.css") | ||
| 249 | code, css := f.get("/-/static/everforest.css") | ||
| 250 | 250 | if code != 200 { | |
| 251 | t.Fatalf("/static/everforest.css = %d", code) | ||
| 251 | t.Fatalf("/-/static/everforest.css = %d", code) | ||
| 252 | 252 | } | |
| 253 | 253 | for _, variant := range []string{ | |
| 254 | 254 | "everforest-dark-hard", | |
| @@ -269,9 +269,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) { | |||
| 269 | 269 | t.Fatal("the Everforest sheet has an unscoped body rule and would restyle the fallback") | |
| 270 | 270 | } | |
| 271 | 271 | ||
| 272 | code, palettes := f.get("/static/palettes.css") | ||
| 272 | code, palettes := f.get("/-/static/palettes.css") | ||
| 273 | 273 | if code != 200 { | |
| 274 | t.Fatalf("/static/palettes.css = %d", code) | ||
| 274 | t.Fatalf("/-/static/palettes.css = %d", code) | ||
| 275 | 275 | } | |
| 276 | 276 | for _, variant := range []string{ | |
| 277 | 277 | "catppuccin-mocha", | |
| @@ -293,9 +293,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) { | |||
| 293 | 293 | t.Fatal("the palette sheet has an unscoped body rule and would restyle the fallback") | |
| 294 | 294 | } | |
| 295 | 295 | ||
| 296 | code, base := f.get("/static/vole.css") | ||
| 296 | code, base := f.get("/-/static/vole.css") | ||
| 297 | 297 | if code != 200 { | |
| 298 | t.Fatalf("/static/vole.css = %d", code) | ||
| 298 | t.Fatalf("/-/static/vole.css = %d", code) | ||
| 299 | 299 | } | |
| 300 | 300 | for _, want := range []string{`--font-scale: 0.9`, `--font-scale: 1.1`, `font-size: calc(13.2px * var(--font-scale))`} { | |
| 301 | 301 | if !strings.Contains(base, want) { | |
| @@ -303,9 +303,9 @@ func TestEverforestThemesAndAccountPicker(t *testing.T) { | |||
| 303 | 303 | } | |
| 304 | 304 | } | |
| 305 | 305 | ||
| 306 | code, script := f.get("/static/theme.js") | ||
| 306 | code, script := f.get("/-/static/theme.js") | ||
| 307 | 307 | if code != 200 { | |
| 308 | t.Fatalf("/static/theme.js = %d", code) | ||
| 308 | t.Fatalf("/-/static/theme.js = %d", code) | ||
| 309 | 309 | } | |
| 310 | 310 | for _, want := range []string{"localStorage", "dataset.theme", "dataset.themes", "prefers-color-scheme", "everforest-light-medium", "dataset.fontScale", "vole-font-scale"} { | |
| 311 | 311 | if !strings.Contains(script, want) { | |
| @@ -320,9 +320,9 @@ func TestAccountTablesUseThePanelSurface(t *testing.T) { | |||
| 320 | 320 | t.Parallel() | |
| 321 | 321 | f := newFixture(t) | |
| 322 | 322 | _, cookie := f.browserSession(t, f.ownerID) | |
| 323 | resp, body := f.browserGet(t, "/account", cookie) | ||
| 323 | resp, body := f.browserGet(t, "/-/account", cookie) | ||
| 324 | 324 | if resp.StatusCode != 200 { | |
| 325 | t.Fatalf("/account = %d", resp.StatusCode) | ||
| 325 | t.Fatalf("/-/account = %d", resp.StatusCode) | ||
| 326 | 326 | } | |
| 327 | 327 | for _, heading := range []string{"SSH keys", "Sessions"} { | |
| 328 | 328 | at := strings.Index(body, "<h2>"+heading+"</h2>") | |
| @@ -343,7 +343,7 @@ func TestAccountTablesUseThePanelSurface(t *testing.T) { | |||
| 343 | 343 | func TestCommentCardStyleDoesNotMatchPrismTokens(t *testing.T) { | |
| 344 | 344 | t.Parallel() | |
| 345 | 345 | f := newFixture(t) | |
| 346 | code, css := f.get("/static/vole.css") | ||
| 346 | code, css := f.get("/-/static/vole.css") | ||
| 347 | 347 | if code != 200 { | |
| 348 | 348 | t.Fatalf("stylesheet = %d", code) | |
| 349 | 349 | } | |
| @@ -369,7 +369,7 @@ func TestHTMXDoesNotInjectInlineStyles(t *testing.T) { | |||
| 369 | 369 | !strings.Contains(shell, `"includeIndicatorStyles":false`) { | |
| 370 | 370 | t.Fatal("the shell does not turn off HTMX's inline indicator styles") | |
| 371 | 371 | } | |
| 372 | code, css := f.get("/static/vole.css") | ||
| 372 | code, css := f.get("/-/static/vole.css") | ||
| 373 | 373 | if code != 200 { | |
| 374 | 374 | t.Fatalf("stylesheet = %d", code) | |
| 375 | 375 | } | |
| @@ -386,13 +386,13 @@ func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) { | |||
| 386 | 386 | f := newFixture(t) | |
| 387 | 387 | ||
| 388 | 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) | ||
| 389 | resp, err := http.Get(f.http.URL + "/-/static/" + name) | ||
| 390 | 390 | if err != nil { | |
| 391 | 391 | t.Fatal(err) | |
| 392 | 392 | } | |
| 393 | 393 | if resp.StatusCode != http.StatusOK { | |
| 394 | 394 | resp.Body.Close() | |
| 395 | t.Fatalf("/static/%s = %d", name, resp.StatusCode) | ||
| 395 | t.Fatalf("/-/static/%s = %d", name, resp.StatusCode) | ||
| 396 | 396 | } | |
| 397 | 397 | if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "png") { | |
| 398 | 398 | resp.Body.Close() | |
| @@ -413,21 +413,21 @@ func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) { | |||
| 413 | 413 | if code != http.StatusOK { | |
| 414 | 414 | t.Fatalf("landing page = %d", code) | |
| 415 | 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"`) || | ||
| 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 | 419 | !strings.Contains(shell, `media="(prefers-color-scheme: dark)"`) { | |
| 420 | 420 | t.Error("the shell does not name the light/dark fallback and SVG favicons") | |
| 421 | 421 | } | |
| 422 | 422 | if !strings.Contains(shell, `<span class="mark"`) { | |
| 423 | 423 | t.Error("the header does not use the head mark") | |
| 424 | 424 | } | |
| 425 | code, css := f.get("/static/vole.css") | ||
| 425 | code, css := f.get("/-/static/vole.css") | ||
| 426 | 426 | if code != http.StatusOK { | |
| 427 | 427 | t.Fatalf("stylesheet = %d", code) | |
| 428 | 428 | } | |
| 429 | if !strings.Contains(css, `url("/static/vole-head.png")`) || | ||
| 430 | !strings.Contains(css, `url("/static/vole-head-solid.png")`) || | ||
| 429 | if !strings.Contains(css, `url("/-/static/vole-head.png")`) || | ||
| 430 | !strings.Contains(css, `url("/-/static/vole-head-solid.png")`) || | ||
| 431 | 431 | !strings.Contains(css, "--mark-fill: currentColor") || | |
| 432 | 432 | !strings.Contains(css, "--mark-outline: transparent") || | |
| 433 | 433 | !strings.Contains(css, "--mark-fill: var(--bg)") || | |
| @@ -435,14 +435,14 @@ func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) { | |||
| 435 | 435 | t.Error("the head mark does not carry the emblem and light-outline treatment") | |
| 436 | 436 | } | |
| 437 | 437 | ||
| 438 | code, svg := f.get("/static/favicon.svg") | ||
| 438 | code, svg := f.get("/-/static/favicon.svg") | ||
| 439 | 439 | if code != http.StatusOK || !strings.Contains(svg, "<svg") { | |
| 440 | t.Fatalf("/static/favicon.svg = %d and does not look like an SVG", code) | ||
| 440 | t.Fatalf("/-/static/favicon.svg = %d and does not look like an SVG", code) | ||
| 441 | 441 | } | |
| 442 | 442 | if !strings.Contains(svg, "prefers-color-scheme") || !strings.Contains(svg, "#000") || !strings.Contains(svg, "#fff") { | |
| 443 | 443 | t.Error("the SVG favicon does not switch between black and white with the browser preference") | |
| 444 | 444 | } | |
| 445 | if _, script := f.get("/static/theme.js"); strings.Contains(script, "favicon") { | ||
| 445 | if _, script := f.get("/-/static/theme.js"); strings.Contains(script, "favicon") { | ||
| 446 | 446 | t.Error("the app theme must not choose the favicon; the browser window preference does") | |
| 447 | 447 | } | |
| 448 | 448 | if strings.Contains(shell, "logo.svg") { | |
| @@ -465,13 +465,13 @@ func TestSelfHostedFontsAreEmbedded(t *testing.T) { | |||
| 465 | 465 | "jetbrains-mono-italic-latin.woff2", | |
| 466 | 466 | "jetbrains-mono-italic-latin-ext.woff2", | |
| 467 | 467 | } { | |
| 468 | resp, err := http.Get(f.http.URL + "/static/fonts/" + name) | ||
| 468 | resp, err := http.Get(f.http.URL + "/-/static/fonts/" + name) | ||
| 469 | 469 | if err != nil { | |
| 470 | 470 | t.Fatal(err) | |
| 471 | 471 | } | |
| 472 | 472 | if resp.StatusCode != http.StatusOK { | |
| 473 | 473 | resp.Body.Close() | |
| 474 | t.Fatalf("/static/fonts/%s = %d", name, resp.StatusCode) | ||
| 474 | t.Fatalf("/-/static/fonts/%s = %d", name, resp.StatusCode) | ||
| 475 | 475 | } | |
| 476 | 476 | if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "font/woff2") { | |
| 477 | 477 | resp.Body.Close() | |
| @@ -488,25 +488,25 @@ func TestSelfHostedFontsAreEmbedded(t *testing.T) { | |||
| 488 | 488 | } | |
| 489 | 489 | } | |
| 490 | 490 | ||
| 491 | code, css := f.get("/static/vole.css") | ||
| 491 | code, css := f.get("/-/static/vole.css") | ||
| 492 | 492 | if code != http.StatusOK { | |
| 493 | t.Fatalf("/static/vole.css = %d", code) | ||
| 493 | t.Fatalf("/-/static/vole.css = %d", code) | ||
| 494 | 494 | } | |
| 495 | 495 | for _, want := range []string{ | |
| 496 | 496 | `@font-face`, | |
| 497 | 497 | `font-family: "Lexend"`, | |
| 498 | 498 | `font-family: "JetBrains Mono"`, | |
| 499 | `/static/fonts/lexend-latin.woff2`, | ||
| 500 | `/static/fonts/jetbrains-mono-latin.woff2`, | ||
| 499 | `/-/static/fonts/lexend-latin.woff2`, | ||
| 500 | `/-/static/fonts/jetbrains-mono-latin.woff2`, | ||
| 501 | 501 | } { | |
| 502 | 502 | if !strings.Contains(css, want) { | |
| 503 | 503 | t.Errorf("the stylesheet does not carry %s", want) | |
| 504 | 504 | } | |
| 505 | 505 | } | |
| 506 | 506 | for _, name := range []string{"OFL-Lexend.txt", "OFL-JetBrainsMono.txt"} { | |
| 507 | code, body := f.get("/static/fonts/" + name) | ||
| 507 | code, body := f.get("/-/static/fonts/" + name) | ||
| 508 | 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) | ||
| 509 | t.Errorf("/-/static/fonts/%s is missing or does not look like the OFL text", name) | ||
| 510 | 510 | } | |
| 511 | 511 | } | |
| 512 | 512 | } | |
| @@ -519,11 +519,11 @@ func TestRunsPageListsQueuedWork(t *testing.T) { | |||
| 519 | 519 | if _, err := f.srv.Runs.Enqueue(run, nil); err != nil { | |
| 520 | 520 | t.Fatal(err) | |
| 521 | 521 | } | |
| 522 | code, body := f.get("/r/" + f.repo + "/runs") | ||
| 522 | code, body := f.get("/" + f.repo + "/runs") | ||
| 523 | 523 | if code != 200 { | |
| 524 | 524 | t.Fatalf("runs page = %d", code) | |
| 525 | 525 | } | |
| 526 | for _, want := range []string{"unit-tests", "/r/" + f.repo + "/runs/" + run.ID, "Queued"} { | ||
| 526 | for _, want := range []string{"unit-tests", "/" + f.repo + "/runs/" + run.ID, "Queued"} { | ||
| 527 | 527 | if !strings.Contains(body, want) { | |
| 528 | 528 | t.Fatalf("runs page does not show %q:\n%s", want, body) | |
| 529 | 529 | } | |
| @@ -541,7 +541,7 @@ func TestOverviewAndActivityPages(t *testing.T) { | |||
| 541 | 541 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 542 | 542 | t.Fatalf("push: %s", errs) | |
| 543 | 543 | } | |
| 544 | code, body := f.get("/r/" + f.repo) | ||
| 544 | code, body := f.get("/" + f.repo) | ||
| 545 | 545 | if code != 200 { | |
| 546 | 546 | t.Fatalf("overview = %d", code) | |
| 547 | 547 | } | |
| @@ -552,12 +552,12 @@ func TestOverviewAndActivityPages(t *testing.T) { | |||
| 552 | 552 | } | |
| 553 | 553 | // The activity tab is a separate page. Commits are not feed events: they | |
| 554 | 554 | // belong to the overview and the code browser. | |
| 555 | code, body = f.get("/r/" + f.repo + "/activity") | ||
| 555 | code, body = f.get("/" + f.repo + "/activity") | ||
| 556 | 556 | if code != 200 || !strings.Contains(body, "Activity") || strings.Contains(body, "committed") { | |
| 557 | 557 | t.Fatalf("activity page = %d:\n%s", code, body) | |
| 558 | 558 | } | |
| 559 | 559 | // Code browsing moved to its own tab, and that page still lists the tree. | |
| 560 | if code, codePage := f.get("/r/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "main.go") { | ||
| 560 | if code, codePage := f.get("/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "main.go") { | ||
| 561 | 561 | t.Fatalf("code page = %d:\n%s", code, codePage) | |
| 562 | 562 | } | |
| 563 | 563 | } | |
| @@ -600,15 +600,15 @@ func TestOverviewShowsRecentOpenIssuesAndChanges(t *testing.T) { | |||
| 600 | 600 | t.Fatalf("requests push: %v", err) | |
| 601 | 601 | } | |
| 602 | 602 | ||
| 603 | code, body := f.get("/r/" + f.repo) | ||
| 603 | code, body := f.get("/" + f.repo) | ||
| 604 | 604 | if code != 200 { | |
| 605 | 605 | t.Fatalf("overview = %d", code) | |
| 606 | 606 | } | |
| 607 | 607 | for _, want := range []string{ | |
| 608 | 608 | "Recent issues", "Recent changes", | |
| 609 | 609 | "Overview open issue", "Overview open change", | |
| 610 | `href="/r/` + f.repo + `/issues/` + openIssueID + `"`, | ||
| 611 | `href="/r/` + f.repo + `/changes/` + openChangeID + `"`, | ||
| 610 | `href="/` + f.repo + `/issues/` + openIssueID + `"`, | ||
| 611 | `href="/` + f.repo + `/changes/` + openChangeID + `"`, | ||
| 612 | 612 | } { | |
| 613 | 613 | if !strings.Contains(body, want) { | |
| 614 | 614 | t.Fatalf("overview does not show %q:\n%s", want, body) | |
| @@ -659,15 +659,15 @@ func TestOverviewContributorsAreAccountsNotCommitAuthors(t *testing.T) { | |||
| 659 | 659 | t.Fatalf("push merged change: %v", err) | |
| 660 | 660 | } | |
| 661 | 661 | ||
| 662 | code, body := f.get("/r/" + f.repo) | ||
| 662 | code, body := f.get("/" + f.repo) | ||
| 663 | 663 | if code != 200 { | |
| 664 | 664 | t.Fatalf("overview = %d", code) | |
| 665 | 665 | } | |
| 666 | 666 | for _, want := range []string{ | |
| 667 | 667 | "Contributors", | |
| 668 | `href="/u/wilbur"`, | ||
| 668 | `href="/-/u/wilbur"`, | ||
| 669 | 669 | `data-tip="1 change merged"`, `data-tip="2 issues fixed"`, `data-tip="1 issue reported"`, | |
| 670 | `href="/u/keenan"`, `data-tip="1 review"`, | ||
| 670 | `href="/-/u/keenan"`, `data-tip="1 review"`, | ||
| 671 | 671 | } { | |
| 672 | 672 | if !strings.Contains(body, want) { | |
| 673 | 673 | t.Fatalf("overview does not show %q:\n%s", want, body) | |
| @@ -688,7 +688,7 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) { | |||
| 688 | 688 | t.Fatalf("push: %s", errs) | |
| 689 | 689 | } | |
| 690 | 690 | ||
| 691 | code, body := f.get("/r/" + f.repo + "/code") | ||
| 691 | code, body := f.get("/" + f.repo + "/code") | ||
| 692 | 692 | if code != 200 { | |
| 693 | 693 | t.Fatalf("code browser = %d", code) | |
| 694 | 694 | } | |
| @@ -702,7 +702,7 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) { | |||
| 702 | 702 | } | |
| 703 | 703 | ||
| 704 | 704 | // A file click is a fragment: the tree stays put and the main pane is replaced. | |
| 705 | resp, fragment := f.htmxGet(t, "/r/"+f.repo+"/blob/main/internal/run.go", "") | ||
| 705 | resp, fragment := f.htmxGet(t, "/"+f.repo+"/blob/main/internal/run.go", "") | ||
| 706 | 706 | if resp.StatusCode != 200 || !strings.Contains(fragment, `id="code-main"`) || | |
| 707 | 707 | !strings.Contains(fragment, `data-mode="blob"`) || | |
| 708 | 708 | strings.Contains(fragment, "<!doctype html>") || | |
| @@ -713,25 +713,25 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) { | |||
| 713 | 713 | ||
| 714 | 714 | // A directory click replaces the main pane with a listing whose child links | |
| 715 | 715 | // carry the directory path rather than only the leaf name. | |
| 716 | resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/tree/main/internal", "") | ||
| 716 | resp, fragment = f.htmxGet(t, "/"+f.repo+"/tree/main/internal", "") | ||
| 717 | 717 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) || | |
| 718 | !strings.Contains(fragment, "/r/"+f.repo+"/blob/main/internal/run.go") { | ||
| 718 | !strings.Contains(fragment, "/"+f.repo+"/blob/main/internal/run.go") { | ||
| 719 | 719 | t.Fatalf("tree fragment = %d:\n%s", resp.StatusCode, fragment) | |
| 720 | 720 | } | |
| 721 | 721 | ||
| 722 | 722 | // The branch crumb returns to the Code overview, not a second directory | |
| 723 | 723 | // listing inside the main pane. | |
| 724 | resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/code?ref=main", "") | ||
| 724 | resp, fragment = f.htmxGet(t, "/"+f.repo+"/code?ref=main", "") | ||
| 725 | 725 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="overview"`) || | |
| 726 | 726 | !strings.Contains(fragment, "<h1>Code browser</h1>") { | |
| 727 | 727 | t.Fatalf("branch crumb fragment = %d:\n%s", resp.StatusCode, fragment) | |
| 728 | 728 | } | |
| 729 | if !strings.Contains(body, `href="/r/`+f.repo+`/code?ref=main"`) { | ||
| 729 | if !strings.Contains(body, `href="/`+f.repo+`/code?ref=main"`) { | ||
| 730 | 730 | t.Fatalf("branch crumb does not target the code overview:\n%s", body) | |
| 731 | 731 | } | |
| 732 | 732 | ||
| 733 | 733 | // The branch root still has its own directory URL, used by the ".." row. | |
| 734 | resp, fragment = f.htmxGet(t, "/r/"+f.repo+"/tree/main", "") | ||
| 734 | resp, fragment = f.htmxGet(t, "/"+f.repo+"/tree/main", "") | ||
| 735 | 735 | if resp.StatusCode != 200 || !strings.Contains(fragment, `data-mode="tree"`) || | |
| 736 | 736 | !strings.Contains(fragment, "main.go") { | |
| 737 | 737 | t.Fatalf("root tree fragment = %d:\n%s", resp.StatusCode, fragment) | |
| @@ -739,7 +739,7 @@ func TestCodeBrowserTreeAndFragments(t *testing.T) { | |||
| 739 | 739 | ||
| 740 | 740 | // A direct directory visit renders the same shell, with the folder open in | |
| 741 | 741 | // the tree and a link back to its parent. | |
| 742 | code, body = f.get("/r/" + f.repo + "/tree/main/internal") | ||
| 742 | code, body = f.get("/" + f.repo + "/tree/main/internal") | ||
| 743 | 743 | if code != 200 || !strings.Contains(body, `id="file-tree"`) || | |
| 744 | 744 | !strings.Contains(body, "internal/run.go") || !strings.Contains(body, "..") { | |
| 745 | 745 | t.Fatalf("directory page = %d:\n%s", code, body) | |
| @@ -760,7 +760,7 @@ func TestCodeBrowserRefPickers(t *testing.T) { | |||
| 760 | 760 | ||
| 761 | 761 | // Search and the ref pickers share one row under the page header, and the | |
| 762 | 762 | // tag picker lists tags beside the branch picker. | |
| 763 | code, body := f.get("/r/" + f.repo + "/code") | ||
| 763 | code, body := f.get("/" + f.repo + "/code") | ||
| 764 | 764 | if code != 200 { | |
| 765 | 765 | t.Fatalf("code page = %d", code) | |
| 766 | 766 | } | |
| @@ -779,7 +779,7 @@ func TestCodeBrowserRefPickers(t *testing.T) { | |||
| 779 | 779 | ||
| 780 | 780 | // Opening a tag selects it in the tag picker and leaves the branch picker | |
| 781 | 781 | // unclaimed rather than pretending the first branch is open. | |
| 782 | code, body = f.get("/r/" + f.repo + "/code?ref=v1.0.0") | ||
| 782 | code, body = f.get("/" + f.repo + "/code?ref=v1.0.0") | ||
| 783 | 783 | if code != 200 { | |
| 784 | 784 | t.Fatalf("tag page = %d", code) | |
| 785 | 785 | } | |
| @@ -820,9 +820,9 @@ func TestFilterRowsShareOneShape(t *testing.T) { | |||
| 820 | 820 | path string | |
| 821 | 821 | labels []string | |
| 822 | 822 | }{ | |
| 823 | {"/r/" + f.repo + "/issues", []string{"Label", "Assignee"}}, | ||
| 824 | {"/r/" + f.repo + "/activity", []string{"Actor", "Assignee"}}, | ||
| 825 | {"/r/" + f.repo + "/code", []string{"Branch"}}, | ||
| 823 | {"/" + f.repo + "/issues", []string{"Label", "Assignee"}}, | ||
| 824 | {"/" + f.repo + "/activity", []string{"Actor", "Assignee"}}, | ||
| 825 | {"/" + f.repo + "/code", []string{"Branch"}}, | ||
| 826 | 826 | } { | |
| 827 | 827 | code, body := f.get(row.path) | |
| 828 | 828 | if code != 200 { | |
| @@ -848,10 +848,10 @@ func TestFilterRowsShareOneShape(t *testing.T) { | |||
| 848 | 848 | ||
| 849 | 849 | // Text search is the same button on every page: an icon and the word Search. | |
| 850 | 850 | for _, path := range []string{ | |
| 851 | "/r/" + f.repo + "/issues", | ||
| 852 | "/r/" + f.repo + "/changes", | ||
| 853 | "/r/" + f.repo + "/search", | ||
| 854 | "/r/" + f.repo + "/code", | ||
| 851 | "/" + f.repo + "/issues", | ||
| 852 | "/" + f.repo + "/changes", | ||
| 853 | "/" + f.repo + "/search", | ||
| 854 | "/" + f.repo + "/code", | ||
| 855 | 855 | } { | |
| 856 | 856 | code, body := f.get(path) | |
| 857 | 857 | if code != 200 { | |
| @@ -874,7 +874,7 @@ func TestSourceBlobNamesItsLanguageAndEscapes(t *testing.T) { | |||
| 874 | 874 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 875 | 875 | t.Fatalf("push: %s", errs) | |
| 876 | 876 | } | |
| 877 | code, body := f.get("/r/" + f.repo + "/blob/main/main.go") | ||
| 877 | code, body := f.get("/" + f.repo + "/blob/main/main.go") | ||
| 878 | 878 | if code != 200 { | |
| 879 | 879 | t.Fatalf("blob = %d", code) | |
| 880 | 880 | } | |
| @@ -902,7 +902,7 @@ func TestActivityFeedIncludesIssuesAndFilters(t *testing.T) { | |||
| 902 | 902 | t.Fatalf("issues push: %s", errs) | |
| 903 | 903 | } | |
| 904 | 904 | ||
| 905 | _, body := f.get("/r/" + f.repo + "/activity") | ||
| 905 | _, body := f.get("/" + f.repo + "/activity") | ||
| 906 | 906 | for _, want := range []string{"Activity issue", "opened issue", "v1.0.0", "released"} { | |
| 907 | 907 | if !strings.Contains(body, want) { | |
| 908 | 908 | t.Fatalf("activity feed does not show %q:\n%s", want, body) | |
| @@ -911,21 +911,21 @@ func TestActivityFeedIncludesIssuesAndFilters(t *testing.T) { | |||
| 911 | 911 | if strings.Contains(body, "committed") { | |
| 912 | 912 | t.Fatalf("activity feed still shows commits:\n%s", body) | |
| 913 | 913 | } | |
| 914 | _, body = f.get("/r/" + f.repo + "/activity?kind=issues") | ||
| 914 | _, body = f.get("/" + f.repo + "/activity?kind=issues") | ||
| 915 | 915 | if !strings.Contains(body, "Activity issue") { | |
| 916 | 916 | t.Fatalf("issue filter dropped the issue:\n%s", body) | |
| 917 | 917 | } | |
| 918 | 918 | if strings.Contains(body, "v1.0.0") { | |
| 919 | 919 | t.Fatalf("issue filter kept a release:\n%s", body) | |
| 920 | 920 | } | |
| 921 | resp, fragment := f.htmxGet(t, "/r/"+f.repo+"/activity?kind=issues", "") | ||
| 921 | resp, fragment := f.htmxGet(t, "/"+f.repo+"/activity?kind=issues", "") | ||
| 922 | 922 | if resp.StatusCode != 200 || !strings.Contains(fragment, `id="activity-feed"`) || | |
| 923 | 923 | !strings.Contains(fragment, "Activity issue") { | |
| 924 | 924 | t.Fatalf("issue activity fragment = %d:\n%s", resp.StatusCode, fragment) | |
| 925 | 925 | } | |
| 926 | 926 | } | |
| 927 | 927 | ||
| 928 | // Every script is a static asset: the page references it under /static/ and | ||
| 928 | // Every script is a static asset: the page references it under /-/static/ and | ||
| 929 | 929 | // nothing is inlined into a template, so the CSP stays script-src 'self' and the | |
| 930 | 930 | // script is editable as JavaScript rather than as a Go string. The passkey | |
| 931 | 931 | // ceremony used to be the exception and is not one any more; its old URL is gone | |
| @@ -933,18 +933,18 @@ func TestActivityFeedIncludesIssuesAndFilters(t *testing.T) { | |||
| 933 | 933 | func TestPasskeyScriptIsAStaticAsset(t *testing.T) { | |
| 934 | 934 | t.Parallel() | |
| 935 | 935 | f := newFixture(t) | |
| 936 | code, body := f.get("/static/auth.js") | ||
| 936 | code, body := f.get("/-/static/auth.js") | ||
| 937 | 937 | if code != 200 { | |
| 938 | t.Fatalf("/static/auth.js = %d", code) | ||
| 938 | t.Fatalf("/-/static/auth.js = %d", code) | ||
| 939 | 939 | } | |
| 940 | 940 | if !strings.Contains(body, "navigator.credentials") { | |
| 941 | t.Fatalf("/static/auth.js is not the ceremony script:\n%s", body) | ||
| 941 | t.Fatalf("/-/static/auth.js is not the ceremony script:\n%s", body) | ||
| 942 | 942 | } | |
| 943 | 943 | if code, _ := f.get("/auth.js"); code != 404 { | |
| 944 | 944 | t.Fatalf("/auth.js = %d, want 404: the script has one URL", code) | |
| 945 | 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/") | ||
| 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 | 948 | } | |
| 949 | 949 | } | |
| 950 | 950 | ||
| @@ -971,11 +971,11 @@ func TestFilterRowPutsFiltersBeforeSearch(t *testing.T) { | |||
| 971 | 971 | path string | |
| 972 | 972 | chips bool | |
| 973 | 973 | }{ | |
| 974 | {"/r/" + f.repo + "/issues", true}, | ||
| 975 | {"/r/" + f.repo + "/changes", true}, | ||
| 976 | {"/r/" + f.repo + "/activity?actor=keenan", true}, | ||
| 977 | {"/r/" + f.repo + "/code", false}, | ||
| 978 | {"/r/" + f.repo + "/search", false}, | ||
| 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 | 979 | } { | |
| 980 | 980 | code, body := f.get(row.path) | |
| 981 | 981 | if code != 200 { | |