vole
Clone
Read only, no credentials:
https://rodent.software/git/vole.git
Write over SSH with a registered key:
git clone ssh://git@0.0.0.0:2222/vole.git
A free and open-source forge that lives in your Git repo.
Build out the forge web UI
f09ef39efe44de693748053a7b596ef328fcf06b · Keenan Nemetz · 15d ago · parent 70b70264
Build out the forge web UI Turn the web surface into a compact, server-rendered forge with HTMX as the progressive dynamic layer. - Split templates and assets into embedded files; add a tokenised dark theme, Lucide icons, and vendored HTMX. - Add the repository Overview page and a separate chronological Activity feed with type filters and HTMX fragment updates. - Finish the issue and change workflows: create pages, comments, close/reopen, labels and assignees, approval, discussion, line-anchored review comments, and HTMX list filters. - Add syntax-highlighted blobs, contributor/statistics derivation, branch-aware code browsing, and a repository Runs list. - Add a loopback-only development login for local write testing, with explicit configuration and listener checks. - Extend tests for the new pages, fragments, labels, assignees, anchors, and development login.
server/ui_test.go +170
| @@ -0,0 +1,170 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // The shell is shared by every page: a missing path, a private repository, and a | ||
| 4 | // normal page must all come back as Vole HTML, not as a bare Go error string. | ||
| 5 | |||
| 6 | import ( | ||
| 7 | "strings" | ||
| 8 | "testing" | ||
| 9 | "time" | ||
| 10 | ) | ||
| 11 | |||
| 12 | func TestUnknownPathIsAVolePage(t *testing.T) { | ||
| 13 | t.Parallel() | ||
| 14 | f := newFixture(t) | ||
| 15 | code, body := f.get("/no/such/page") | ||
| 16 | if code != 404 { | ||
| 17 | t.Fatalf("unknown path = %d, want 404", code) | ||
| 18 | } | ||
| 19 | if !strings.Contains(body, "Back to repositories") { | ||
| 20 | t.Fatalf("unknown path is not the styled not-found page:\n%s", body) | ||
| 21 | } | ||
| 22 | } | ||
| 23 | |||
| 24 | func TestRepositoryPagesShareTheTabHeader(t *testing.T) { | ||
| 25 | t.Parallel() | ||
| 26 | f := newFixture(t) | ||
| 27 | f.publish(f.repo) | ||
| 28 | for _, path := range []string{ | ||
| 29 | "/r/" + f.repo, | ||
| 30 | "/r/" + f.repo + "/activity", | ||
| 31 | "/r/" + f.repo + "/code", | ||
| 32 | "/r/" + f.repo + "/issues", | ||
| 33 | "/r/" + f.repo + "/requests", | ||
| 34 | "/r/" + f.repo + "/releases", | ||
| 35 | "/r/" + f.repo + "/runs", | ||
| 36 | "/r/" + f.repo + "/locks", | ||
| 37 | } { | ||
| 38 | code, body := f.get(path) | ||
| 39 | if code != 200 { | ||
| 40 | t.Fatalf("%s = %d", path, code) | ||
| 41 | } | ||
| 42 | for _, want := range []string{">Overview<", ">Activity<", ">Code<", ">Issues<", ">Changes<", ">Releases<", ">Runs<", ">Locks<"} { | ||
| 43 | if !strings.Contains(body, want) { | ||
| 44 | t.Fatalf("%s is missing the %s tab:\n%s", path, want, body) | ||
| 45 | } | ||
| 46 | } | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | func TestStaticAssetsAreServedFromTheBinary(t *testing.T) { | ||
| 51 | t.Parallel() | ||
| 52 | f := newFixture(t) | ||
| 53 | code, body := f.get("/static/vole.css") | ||
| 54 | if code != 200 { | ||
| 55 | t.Fatalf("stylesheet = %d", code) | ||
| 56 | } | ||
| 57 | if !strings.Contains(body, "--accent") { | ||
| 58 | t.Fatalf("stylesheet does not look like the token sheet:\n%s", body[:min(len(body), 200)]) | ||
| 59 | } | ||
| 60 | } | ||
| 61 | |||
| 62 | func TestRunsPageListsQueuedWork(t *testing.T) { | ||
| 63 | t.Parallel() | ||
| 64 | f := newFixture(t) | ||
| 65 | f.publish(f.repo) | ||
| 66 | run := queuedRun("01RUNPAGETEST0000000000000", f.repo, "unit-tests", time.Now().UTC()) | ||
| 67 | if _, err := f.srv.Runs.Enqueue(run, nil); err != nil { | ||
| 68 | t.Fatal(err) | ||
| 69 | } | ||
| 70 | code, body := f.get("/r/" + f.repo + "/runs") | ||
| 71 | if code != 200 { | ||
| 72 | t.Fatalf("runs page = %d", code) | ||
| 73 | } | ||
| 74 | for _, want := range []string{"unit-tests", "/r/" + f.repo + "/runs/" + run.ID, "Queued"} { | ||
| 75 | if !strings.Contains(body, want) { | ||
| 76 | t.Fatalf("runs page does not show %q:\n%s", want, body) | ||
| 77 | } | ||
| 78 | } | ||
| 79 | } | ||
| 80 | |||
| 81 | func TestOverviewAndActivityPages(t *testing.T) { | ||
| 82 | t.Parallel() | ||
| 83 | f := newFixture(t) | ||
| 84 | f.publish(f.repo) | ||
| 85 | work := workRepo(t, map[string]string{ | ||
| 86 | "README.md": "# Overview project\n\nA short description.\n", | ||
| 87 | "main.go": "package main\n", | ||
| 88 | }) | ||
| 89 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 90 | t.Fatalf("push: %s", errs) | ||
| 91 | } | ||
| 92 | code, body := f.get("/r/" + f.repo) | ||
| 93 | if code != 200 { | ||
| 94 | t.Fatalf("overview = %d", code) | ||
| 95 | } | ||
| 96 | for _, want := range []string{"Overview project", "Recent commits", "Recent runs", "contributors", "Languages", "Go"} { | ||
| 97 | if !strings.Contains(body, want) { | ||
| 98 | t.Fatalf("overview page does not show %q:\n%s", want, body) | ||
| 99 | } | ||
| 100 | } | ||
| 101 | // The activity tab is a separate page, and its commit filter is a fragment. | ||
| 102 | code, body = f.get("/r/" + f.repo + "/activity") | ||
| 103 | if code != 200 || !strings.Contains(body, "Activity") || !strings.Contains(body, "initial") { | ||
| 104 | t.Fatalf("activity page = %d:\n%s", code, body) | ||
| 105 | } | ||
| 106 | resp, fragment := f.htmxGet(t, "/r/"+f.repo+"/activity?kind=commits", "") | ||
| 107 | if resp.StatusCode != 200 || !strings.Contains(fragment, `id="activity-feed"`) || | ||
| 108 | strings.Contains(fragment, "<!doctype html>") || !strings.Contains(fragment, "initial") { | ||
| 109 | t.Fatalf("commit activity fragment = %d:\n%s", resp.StatusCode, fragment) | ||
| 110 | } | ||
| 111 | // Code browsing moved to its own tab, and that page still lists the tree. | ||
| 112 | if code, codePage := f.get("/r/" + f.repo + "/code"); code != 200 || !strings.Contains(codePage, "main.go") { | ||
| 113 | t.Fatalf("code page = %d:\n%s", code, codePage) | ||
| 114 | } | ||
| 115 | } | ||
| 116 | |||
| 117 | func TestSourceBlobIsHighlightedAndEscaped(t *testing.T) { | ||
| 118 | t.Parallel() | ||
| 119 | f := newFixture(t) | ||
| 120 | f.publish(f.repo) | ||
| 121 | work := workRepo(t, map[string]string{ | ||
| 122 | "main.go": "package main\n\n// <script>alert(1)</script>\nfunc main() {}\n", | ||
| 123 | }) | ||
| 124 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 125 | t.Fatalf("push: %s", errs) | ||
| 126 | } | ||
| 127 | code, body := f.get("/r/" + f.repo + "/blob/main/main.go") | ||
| 128 | if code != 200 { | ||
| 129 | t.Fatalf("blob = %d", code) | ||
| 130 | } | ||
| 131 | if !strings.Contains(body, `class="chroma"`) { | ||
| 132 | t.Fatalf("source was not highlighted:\n%s", body[:min(len(body), 500)]) | ||
| 133 | } | ||
| 134 | if strings.Contains(body, "<script>alert(1)</script>") { | ||
| 135 | t.Fatalf("source blob executed a tag:\n%s", body) | ||
| 136 | } | ||
| 137 | } | ||
| 138 | |||
| 139 | func TestActivityFeedIncludesIssuesAndFilters(t *testing.T) { | ||
| 140 | t.Parallel() | ||
| 141 | f := newFixture(t) | ||
| 142 | f.publish(f.repo) | ||
| 143 | work := workRepo(t, map[string]string{"README.md": "# activity\n"}) | ||
| 144 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 145 | t.Fatalf("main push: %s", errs) | ||
| 146 | } | ||
| 147 | issues := f.issuesRepo(t, issueFiles("01M5JR2JK29KKMAA1T9MDNTPE1", "Activity issue", "body\n")) | ||
| 148 | if _, errs, err := f.git(issues, "push", f.sshURL(f.repo), "HEAD:refs/heads/vole"); err != nil { | ||
| 149 | t.Fatalf("issues push: %s", errs) | ||
| 150 | } | ||
| 151 | |||
| 152 | _, body := f.get("/r/" + f.repo + "/activity") | ||
| 153 | for _, want := range []string{"Activity issue", "opened issue", "committed"} { | ||
| 154 | if !strings.Contains(body, want) { | ||
| 155 | t.Fatalf("activity feed does not show %q:\n%s", want, body) | ||
| 156 | } | ||
| 157 | } | ||
| 158 | _, body = f.get("/r/" + f.repo + "/activity?kind=issues") | ||
| 159 | if !strings.Contains(body, "Activity issue") { | ||
| 160 | t.Fatalf("issue filter dropped the issue:\n%s", body) | ||
| 161 | } | ||
| 162 | if strings.Contains(body, "committed") { | ||
| 163 | t.Fatalf("issue filter kept a commit:\n%s", body) | ||
| 164 | } | ||
| 165 | resp, fragment := f.htmxGet(t, "/r/"+f.repo+"/activity?kind=issues", "") | ||
| 166 | if resp.StatusCode != 200 || !strings.Contains(fragment, `id="activity-feed"`) || | ||
| 167 | !strings.Contains(fragment, "Activity issue") { | ||
| 168 | t.Fatalf("issue activity fragment = %d:\n%s", resp.StatusCode, fragment) | ||
| 169 | } | ||
| 170 | } | ||