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.
|
074ae160
Keenan Nemetz
14d ago
|
1 | package server_test |
| 2 | ||
|
ab326d3a
Keenan Nemetz
14d ago
|
3 | // The landing page is the instance's front door: identity, repositories, and a short |
|
074ae160
Keenan Nemetz
14d ago
|
4 | // activity rail. The full feed has its own page so the landing page can stay |
| 5 | // about the work rather than the log. | |
| 6 | ||
| 7 | import ( | |
|
20442b59
Keenan Nemetz
9d ago
|
8 | "fmt" |
|
074ae160
Keenan Nemetz
14d ago
|
9 | "path/filepath" |
| 10 | "strings" | |
| 11 | "testing" | |
|
20442b59
Keenan Nemetz
9d ago
|
12 | "time" |
|
074ae160
Keenan Nemetz
14d ago
|
13 | |
|
d37d301f
Keenan Nemetz
16h ago
|
14 | "rodent.software/vole/forge/config" |
| 15 | "rodent.software/vole/metadata" | |
|
074ae160
Keenan Nemetz
14d ago
|
16 | ) |
| 17 | ||
| 18 | func TestHomeIsALandingPage(t *testing.T) { | |
| 19 | t.Parallel() | |
|
164ad9f5
Keenan Nemetz
1d ago
|
20 | f := newFixtureWith(t, func(c *config.Config) { |
| 21 | c.InstanceName = "Acme Forge" | |
| 22 | }) | |
|
074ae160
Keenan Nemetz
14d ago
|
23 | f.publish(f.repo) |
|
6ce635a8
Keenan Nemetz
1d ago
|
24 | f.instanceRepo(t, map[string][]byte{"profile/README.md": []byte("What this forge is for.")}) |
|
074ae160
Keenan Nemetz
14d ago
|
25 | |
| 26 | code, body := f.get("/") | |
| 27 | if code != 200 { | |
| 28 | t.Fatalf("home = %d", code) | |
| 29 | } | |
| 30 | for _, want := range []string{ | |
|
164ad9f5
Keenan Nemetz
1d ago
|
31 | `class="hero"`, "<h1>Acme Forge</h1>", |
|
ff9870f1
Keenan Nemetz
1d ago
|
32 | `readme-card`, `class="md readme-body"`, "What this forge is for.", |
|
ed03b3d1
Keenan Nemetz
3h ago
|
33 | "Repositories", `class="repo-card"`, `href="/demo"`, |
| 34 | "Latest", `href="/-/activity"`, | |
|
074ae160
Keenan Nemetz
14d ago
|
35 | } { |
| 36 | if !strings.Contains(body, want) { | |
| 37 | t.Fatalf("home is missing %q:\n%s", want, body) | |
| 38 | } | |
| 39 | } | |
|
ff9870f1
Keenan Nemetz
1d ago
|
40 | for _, unwanted := range []string{ |
|
164ad9f5
Keenan Nemetz
1d ago
|
41 | `class="readme-head"`, `class="readme-name"`, |
|
ff9870f1
Keenan Nemetz
1d ago
|
42 | `href="/?edit=1`, `name="intro"`, |
| 43 | } { | |
| 44 | if strings.Contains(body, unwanted) { | |
|
164ad9f5
Keenan Nemetz
1d ago
|
45 | t.Fatalf("home still has the old introduction header or editor %q:\n%s", unwanted, body) |
|
ff9870f1
Keenan Nemetz
1d ago
|
46 | } |
| 47 | } | |
|
074ae160
Keenan Nemetz
14d ago
|
48 | if strings.Contains(body, "Recent activity") { |
| 49 | t.Fatalf("home still leads with the feed:\n%s", body) | |
| 50 | } | |
|
ff9870f1
Keenan Nemetz
1d ago
|
51 | if !strings.Contains(body, `<h2>Repositories</h2>`) { |
| 52 | t.Fatalf("home has no repository list heading:\n%s", body) | |
| 53 | } | |
|
164ad9f5
Keenan Nemetz
1d ago
|
54 | // The introduction belongs to the repository column, not the whole page. |
| 55 | main := strings.Index(body, `class="home-main"`) | |
| 56 | readme := strings.Index(body, "readme-card") | |
| 57 | rail := strings.Index(body, `class="home-rail"`) | |
| 58 | if main < 0 || readme < 0 || rail < 0 || main > readme || readme > rail { | |
| 59 | t.Fatalf("the introduction is not inside the repository column (main=%d readme=%d rail=%d):\n%s", main, readme, rail, body) | |
| 60 | } | |
| 61 | } | |
| 62 | ||
| 63 | func TestHomeHeadingFallsBackToVole(t *testing.T) { | |
| 64 | t.Parallel() | |
| 65 | f := newFixture(t) | |
| 66 | _, body := f.get("/") | |
| 67 | if !strings.Contains(body, "<h1>Vole</h1>") { | |
| 68 | t.Fatalf("home has no fallback heading:\n%s", body) | |
| 69 | } | |
|
6ce635a8
Keenan Nemetz
1d ago
|
70 | } |
| 71 | ||
|
164ad9f5
Keenan Nemetz
1d ago
|
72 | func TestHomePutsNewRepositoryInTheHero(t *testing.T) { |
|
6ce635a8
Keenan Nemetz
1d ago
|
73 | t.Parallel() |
|
ff9870f1
Keenan Nemetz
1d ago
|
74 | f := browserFixture(t) |
| 75 | f.publish(f.repo) | |
| 76 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | |
| 77 | t.Fatal(err) | |
| 78 | } | |
| 79 | _, cookie := f.browserSession(t, f.ownerID) | |
| 80 | _, body := f.browserGet(t, "/", cookie) | |
| 81 | ||
|
164ad9f5
Keenan Nemetz
1d ago
|
82 | hero := strings.Index(body, `class="hero"`) |
|
ed03b3d1
Keenan Nemetz
3h ago
|
83 | button := strings.Index(body, `href="/-/repos/new"`) |
|
164ad9f5
Keenan Nemetz
1d ago
|
84 | grid := strings.Index(body, `class="home-grid"`) |
| 85 | if hero < 0 || button < 0 || grid < 0 || hero > button || button > grid { | |
| 86 | t.Fatalf("the New repository action is not in the page header (hero=%d button=%d grid=%d):\n%s", hero, button, grid, body) | |
|
074ae160
Keenan Nemetz
14d ago
|
87 | } |
| 88 | } | |
| 89 | ||
| 90 | func TestHomeHasTheCleanTopBar(t *testing.T) { | |
| 91 | t.Parallel() | |
| 92 | f := newFixture(t) | |
| 93 | f.publish(f.repo) | |
| 94 | ||
| 95 | _, body := f.get("/") | |
| 96 | for _, want := range []string{ | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
97 | `class="topbar"`, `class="topnav"`, `href="/-/activity"`, |
| 98 | `class="topsearch"`, `class="topsearch-box"`, `href="/-/login"`, | |
|
074ae160
Keenan Nemetz
14d ago
|
99 | } { |
| 100 | if !strings.Contains(body, want) { | |
| 101 | t.Fatalf("home is missing the top-bar marker %q:\n%s", want, body) | |
| 102 | } | |
| 103 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
104 | for _, unwanted := range []string{`class="rail"`, `src="/-/static/nav.js"`, `>Projects</a>`, `>Repositories</a>`, `href="/-/runners"`, `href="/-/admin"`} { |
|
074ae160
Keenan Nemetz
14d ago
|
105 | if strings.Contains(body, unwanted) { |
| 106 | t.Fatalf("home still has %q:\n%s", unwanted, body) | |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | _, cookie := f.browserSession(t, f.ownerID) | |
| 111 | _, body = f.browserGet(t, "/", cookie) | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
112 | for _, want := range []string{`class="account-menu"`, `class="account-pop"`, `href="/-/account"`, `action="/-/logout"`} { |
|
074ae160
Keenan Nemetz
14d ago
|
113 | if !strings.Contains(body, want) { |
| 114 | t.Fatalf("signed-in top bar is missing %q:\n%s", want, body) | |
| 115 | } | |
| 116 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
117 | if strings.Contains(body, `href="/-/admin"`) { |
|
4506a4d3
Keenan Nemetz
13d ago
|
118 | t.Fatalf("non-admin sees the Status link:\n%s", body) |
| 119 | } | |
| 120 | ||
| 121 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | |
| 122 | t.Fatal(err) | |
| 123 | } | |
| 124 | _, body = f.browserGet(t, "/", cookie) | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
125 | if !strings.Contains(body, `href="/-/admin"`) { |
|
4506a4d3
Keenan Nemetz
13d ago
|
126 | t.Fatalf("instance admin does not see the Status link:\n%s", body) |
| 127 | } | |
|
074ae160
Keenan Nemetz
14d ago
|
128 | } |
| 129 | ||
| 130 | func TestHomeSearchShowsProjectCards(t *testing.T) { | |
| 131 | t.Parallel() | |
| 132 | f := newFixture(t) | |
| 133 | f.publish(f.repo) | |
| 134 | ||
| 135 | _, body := f.get("/?q=demo") | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
136 | for _, want := range []string{"Results", `class="repo-card"`, `href="/demo"`, "clear search"} { |
|
074ae160
Keenan Nemetz
14d ago
|
137 | if !strings.Contains(body, want) { |
| 138 | t.Fatalf("search results are missing %q:\n%s", want, body) | |
| 139 | } | |
| 140 | } | |
| 141 | if strings.Contains(body, `class="home-rail"`) { | |
| 142 | t.Fatalf("search results kept the landing-page activity rail:\n%s", body) | |
| 143 | } | |
| 144 | ||
| 145 | _, body = f.get("/?q=nomatch") | |
| 146 | if !strings.Contains(body, "No repositories match") { | |
| 147 | t.Fatalf("empty search state missing:\n%s", body) | |
| 148 | } | |
| 149 | } | |
| 150 | ||
| 151 | func TestInstanceActivityListsOnlyVisibleRepositories(t *testing.T) { | |
| 152 | t.Parallel() | |
| 153 | f := newFixture(t) | |
| 154 | f.publish(f.repo) | |
| 155 | ||
| 156 | // The public repository has a commit (not an event), an issue, and a release. | |
| 157 | work := workRepo(t, map[string]string{"readme.md": "public\n"}) | |
| 158 | if _, errs, err := runGit(t, work, "commit", "--allow-empty", "-qm", "public-commit"); err != nil { | |
| 159 | t.Fatalf("public commit: %s", errs) | |
| 160 | } | |
| 161 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "public release"); err != nil { | |
| 162 | t.Fatalf("public tag: %s", errs) | |
| 163 | } | |
| 164 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main", "refs/tags/v1.0.0"); err != nil { | |
| 165 | t.Fatalf("public push: %s", errs) | |
| 166 | } | |
| 167 | clone := filepath.Join(t.TempDir(), "clone") | |
| 168 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil { | |
| 169 | t.Fatalf("public clone: %s", errs) | |
| 170 | } | |
| 171 | if _, errs, err := f.cli(clone, "issue", "new", "--title", "public-issue-marker", "-m", "body"); err != nil { | |
| 172 | t.Fatalf("public issue: %v\n%s", err, errs) | |
| 173 | } | |
| 174 | ||
| 175 | secret, err := f.srv.Repos.Create("secret", f.ownerID, "private") | |
| 176 | if err != nil { | |
| 177 | t.Fatal(err) | |
| 178 | } | |
| 179 | if err := f.srv.Index.Upsert(secret); err != nil { | |
| 180 | t.Fatal(err) | |
| 181 | } | |
| 182 | privateWork := workRepo(t, map[string]string{"hidden.md": "private\n"}) | |
| 183 | if _, errs, err := runGit(t, privateWork, "commit", "--allow-empty", "-qm", "private-commit"); err != nil { | |
| 184 | t.Fatalf("private commit: %s", errs) | |
| 185 | } | |
| 186 | if _, errs, err := f.git(privateWork, "push", f.sshURL("secret"), "main:refs/heads/main"); err != nil { | |
| 187 | t.Fatalf("private push: %s", errs) | |
| 188 | } | |
| 189 | privateClone := filepath.Join(t.TempDir(), "private-clone") | |
| 190 | if _, errs, err := f.git("", "clone", "-q", f.sshURL("secret"), privateClone); err != nil { | |
| 191 | t.Fatalf("private clone: %s", errs) | |
| 192 | } | |
| 193 | if _, errs, err := f.cli(privateClone, "issue", "new", "--title", "private-issue-marker", "-m", "body"); err != nil { | |
| 194 | t.Fatalf("private issue: %v\n%s", err, errs) | |
| 195 | } | |
| 196 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
197 | code, body := f.get("/-/activity") |
|
074ae160
Keenan Nemetz
14d ago
|
198 | if code != 200 { |
| 199 | t.Fatalf("activity = %d", code) | |
| 200 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
201 | for _, want := range []string{`href="/demo"`, "public-issue-marker", "v1.0.0", "released"} { |
|
074ae160
Keenan Nemetz
14d ago
|
202 | if !strings.Contains(body, want) { |
| 203 | t.Fatalf("activity feed is missing %q:\n%s", want, body) | |
| 204 | } | |
| 205 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
206 | for _, unwanted := range []string{"private-issue-marker", `href="/secret"`, "public-commit", "private-commit", "committed"} { |
|
074ae160
Keenan Nemetz
14d ago
|
207 | if strings.Contains(body, unwanted) { |
| 208 | t.Fatalf("activity feed contains %q:\n%s", unwanted, body) | |
| 209 | } | |
| 210 | } | |
| 211 | ||
|
ed03b3d1
Keenan Nemetz
3h ago
|
212 | _, body = f.get("/-/activity?kind=issues") |
|
074ae160
Keenan Nemetz
14d ago
|
213 | if !strings.Contains(body, "public-issue-marker") || strings.Contains(body, "v1.0.0") { |
| 214 | t.Fatalf("issue filter is wrong:\n%s", body) | |
| 215 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
216 | _, body = f.get("/-/activity?kind=releases") |
|
074ae160
Keenan Nemetz
14d ago
|
217 | if !strings.Contains(body, "v1.0.0") || strings.Contains(body, "public-issue-marker") { |
| 218 | t.Fatalf("release filter is wrong:\n%s", body) | |
| 219 | } | |
| 220 | } | |
| 221 | ||
| 222 | func TestFirstRunOnboardingIsAnInstanceAdminPage(t *testing.T) { | |
| 223 | t.Parallel() | |
| 224 | f := newFixture(t) | |
| 225 | ||
| 226 | // The fixture's one repository is owned by the account we sign in as, so | |
| 227 | // remove it to reach the state a fresh instance is in: an admin and no | |
| 228 | // repositories. | |
| 229 | m, err := f.srv.Repos.Open(f.repo) | |
| 230 | if err != nil { | |
| 231 | t.Fatal(err) | |
| 232 | } | |
|
b1499c72
Keenan Nemetz
12d ago
|
233 | if err := f.srv.Repos.Delete(f.repo, f.ownerID); err != nil { |
|
074ae160
Keenan Nemetz
14d ago
|
234 | t.Fatal(err) |
| 235 | } | |
| 236 | if err := f.srv.Index.Remove(m.ID); err != nil { | |
| 237 | t.Fatal(err) | |
| 238 | } | |
| 239 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | |
| 240 | t.Fatal(err) | |
| 241 | } | |
| 242 | _, cookie := f.browserSession(t, f.ownerID) | |
| 243 | resp, body := f.browserGet(t, "/", cookie) | |
| 244 | if resp.StatusCode != 200 { | |
| 245 | t.Fatalf("home = %d", resp.StatusCode) | |
| 246 | } | |
|
ed03b3d1
Keenan Nemetz
3h ago
|
247 | for _, want := range []string{"No repositories yet", "New repository", "Or from the instance directory", "--owner keenan", `href="/-/repos/new"`} { |
|
074ae160
Keenan Nemetz
14d ago
|
248 | if !strings.Contains(body, want) { |
| 249 | t.Fatalf("first-run page is missing %q:\n%s", want, body) | |
| 250 | } | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | func TestHomeCardShowsTopLanguage(t *testing.T) { | |
| 255 | t.Parallel() | |
| 256 | f := newFixture(t) | |
| 257 | f.publish(f.repo) | |
| 258 | work := workRepo(t, map[string]string{"main.go": "package main\n", "README.md": "readme\n"}) | |
| 259 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | |
| 260 | t.Fatalf("push: %s", errs) | |
| 261 | } | |
| 262 | ||
| 263 | _, body := f.get("/") | |
| 264 | if !strings.Contains(body, `class="lang-dot lang-go"`) || !strings.Contains(body, `class="repo-lang"`) { | |
| 265 | t.Fatalf("home card has no top-language marker:\n%s", body) | |
| 266 | } | |
| 267 | } | |
|
20442b59
Keenan Nemetz
9d ago
|
268 | |
| 269 | // TestHomeRailShowsTheNewestEventsAcrossRepositories pins the rail to the events | |
| 270 | // table: the newest events, bounded to what fits, with no per-repository walk of | |
| 271 | // the whole feed. | |
| 272 | func TestHomeRailShowsTheNewestEventsAcrossRepositories(t *testing.T) { | |
| 273 | t.Parallel() | |
| 274 | f := newFixture(t) | |
| 275 | f.publish(f.repo) | |
|
6ce635a8
Keenan Nemetz
1d ago
|
276 | f.instanceRepo(t, map[string][]byte{"profile/README.md": []byte("What this forge is for.")}) |
|
20442b59
Keenan Nemetz
9d ago
|
277 | m := mustRepo(t, f) |
| 278 | base := time.Date(2026, 10, 30, 12, 0, 0, 0, time.UTC) | |
| 279 | for i := 0; i < 12; i++ { | |
| 280 | iss, err := metadata.NewIssue(fmt.Sprintf("rail %02d", i), "body\n", "keenan", nil, nil, base.Add(time.Duration(i)*time.Minute)) | |
| 281 | if err != nil { | |
| 282 | t.Fatal(err) | |
| 283 | } | |
| 284 | if err := metadata.CreateIssue(m.Git(), iss, "keenan"); err != nil { | |
| 285 | t.Fatal(err) | |
| 286 | } | |
| 287 | } | |
| 288 | _, body := f.get("/") | |
| 289 | if n := strings.Count(body, `class="activity-event"`); n != 10 { | |
| 290 | t.Fatalf("the rail shows %d events, want %d", n, 10) | |
| 291 | } | |
| 292 | if !strings.Contains(body, "rail 11") || strings.Contains(body, "rail 00") { | |
| 293 | t.Fatalf("the rail is not the newest events:\n%s", body) | |
| 294 | } | |
| 295 | } |