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.

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 }