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.
Build a landing page and simplify the forge chrome
074ae1605e177e99533af02d8c69ccd1a6a947bb · Keenan Nemetz · 14d ago · parent 62576fa8
Build a landing page and simplify the forge chrome The instance root is now a landing page rather than the full activity feed: forge identity, project cards with top language, last commit, issue/change counts and latest release, a short activity rail, and a first-run empty state. The full feed moves to /activity with type filters and pagination. Feeds now cover issues, changes, comments, and releases only; commits and CI runs already have their own pages. Configuration gains instance_name and instance_tagline. Navigation: the crowded global bar becomes Runners, Activity, search, and an account menu holding Admin, Status, and Log out. Repository pages show the name and description above the tabs at the home-page header scale, while tab-page titles stay small. Top-level pages keep the larger header. Drop the workflow run chip, assigned-issues strip, repository size stat, and footer Status link.
server/home_test.go +210
| @@ -0,0 +1,210 @@ | |||
| 1 | package server_test | ||
| 2 | |||
| 3 | // The landing page is the instance's front door: identity, projects, and a short | ||
| 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 ( | ||
| 8 | "path/filepath" | ||
| 9 | "strings" | ||
| 10 | "testing" | ||
| 11 | |||
| 12 | "github.com/rodent-software/vole/forge/config" | ||
| 13 | ) | ||
| 14 | |||
| 15 | func TestHomeIsALandingPage(t *testing.T) { | ||
| 16 | t.Parallel() | ||
| 17 | f := newFixtureWith(t, func(c *config.Config) { | ||
| 18 | c.InstanceName = "Acme Forge" | ||
| 19 | c.InstanceTagline = "Code for the Acme team." | ||
| 20 | }) | ||
| 21 | f.publish(f.repo) | ||
| 22 | |||
| 23 | code, body := f.get("/") | ||
| 24 | if code != 200 { | ||
| 25 | t.Fatalf("home = %d", code) | ||
| 26 | } | ||
| 27 | for _, want := range []string{ | ||
| 28 | `class="hero"`, "Acme Forge", "Code for the Acme team.", | ||
| 29 | "Projects", `class="repo-card"`, `href="/r/demo"`, | ||
| 30 | "Latest", `href="/activity"`, | ||
| 31 | } { | ||
| 32 | if !strings.Contains(body, want) { | ||
| 33 | t.Fatalf("home is missing %q:\n%s", want, body) | ||
| 34 | } | ||
| 35 | } | ||
| 36 | if strings.Contains(body, "Recent activity") { | ||
| 37 | t.Fatalf("home still leads with the feed:\n%s", body) | ||
| 38 | } | ||
| 39 | if !strings.Contains(body, "0 repositories") && !strings.Contains(body, "1 repository") { | ||
| 40 | t.Fatalf("home has no vitals line:\n%s", body) | ||
| 41 | } | ||
| 42 | } | ||
| 43 | |||
| 44 | func TestHomeHasTheCleanTopBar(t *testing.T) { | ||
| 45 | t.Parallel() | ||
| 46 | f := newFixture(t) | ||
| 47 | f.publish(f.repo) | ||
| 48 | |||
| 49 | _, body := f.get("/") | ||
| 50 | for _, want := range []string{ | ||
| 51 | `class="topbar"`, `class="topnav"`, `href="/runners"`, `href="/activity"`, | ||
| 52 | `class="topsearch"`, `name="q"`, `href="/login"`, | ||
| 53 | } { | ||
| 54 | if !strings.Contains(body, want) { | ||
| 55 | t.Fatalf("home is missing the top-bar marker %q:\n%s", want, body) | ||
| 56 | } | ||
| 57 | } | ||
| 58 | for _, unwanted := range []string{`class="rail"`, `src="/static/nav.js"`, `>Repositories</a>`} { | ||
| 59 | if strings.Contains(body, unwanted) { | ||
| 60 | t.Fatalf("home still has %q:\n%s", unwanted, body) | ||
| 61 | } | ||
| 62 | } | ||
| 63 | |||
| 64 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 65 | _, body = f.browserGet(t, "/", cookie) | ||
| 66 | for _, want := range []string{`class="account-menu"`, `class="account-pop"`, `href="/account"`, `href="/status"`, `action="/logout"`} { | ||
| 67 | if !strings.Contains(body, want) { | ||
| 68 | t.Fatalf("signed-in top bar is missing %q:\n%s", want, body) | ||
| 69 | } | ||
| 70 | } | ||
| 71 | } | ||
| 72 | |||
| 73 | func TestHomeSearchShowsProjectCards(t *testing.T) { | ||
| 74 | t.Parallel() | ||
| 75 | f := newFixture(t) | ||
| 76 | f.publish(f.repo) | ||
| 77 | |||
| 78 | _, body := f.get("/?q=demo") | ||
| 79 | for _, want := range []string{"Results", `class="repo-card"`, `href="/r/demo"`, "clear search"} { | ||
| 80 | if !strings.Contains(body, want) { | ||
| 81 | t.Fatalf("search results are missing %q:\n%s", want, body) | ||
| 82 | } | ||
| 83 | } | ||
| 84 | if strings.Contains(body, `class="home-rail"`) { | ||
| 85 | t.Fatalf("search results kept the landing-page activity rail:\n%s", body) | ||
| 86 | } | ||
| 87 | |||
| 88 | _, body = f.get("/?q=nomatch") | ||
| 89 | if !strings.Contains(body, "No repositories match") { | ||
| 90 | t.Fatalf("empty search state missing:\n%s", body) | ||
| 91 | } | ||
| 92 | } | ||
| 93 | |||
| 94 | func TestInstanceActivityListsOnlyVisibleRepositories(t *testing.T) { | ||
| 95 | t.Parallel() | ||
| 96 | f := newFixture(t) | ||
| 97 | f.publish(f.repo) | ||
| 98 | |||
| 99 | // The public repository has a commit (not an event), an issue, and a release. | ||
| 100 | work := workRepo(t, map[string]string{"readme.md": "public\n"}) | ||
| 101 | if _, errs, err := runGit(t, work, "commit", "--allow-empty", "-qm", "public-commit"); err != nil { | ||
| 102 | t.Fatalf("public commit: %s", errs) | ||
| 103 | } | ||
| 104 | if _, errs, err := runGit(t, work, "tag", "-a", "v1.0.0", "-m", "public release"); err != nil { | ||
| 105 | t.Fatalf("public tag: %s", errs) | ||
| 106 | } | ||
| 107 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main", "refs/tags/v1.0.0"); err != nil { | ||
| 108 | t.Fatalf("public push: %s", errs) | ||
| 109 | } | ||
| 110 | clone := filepath.Join(t.TempDir(), "clone") | ||
| 111 | if _, errs, err := f.git("", "clone", "-q", f.sshURL(f.repo), clone); err != nil { | ||
| 112 | t.Fatalf("public clone: %s", errs) | ||
| 113 | } | ||
| 114 | if _, errs, err := f.cli(clone, "issue", "new", "--title", "public-issue-marker", "-m", "body"); err != nil { | ||
| 115 | t.Fatalf("public issue: %v\n%s", err, errs) | ||
| 116 | } | ||
| 117 | |||
| 118 | secret, err := f.srv.Repos.Create("secret", f.ownerID, "private") | ||
| 119 | if err != nil { | ||
| 120 | t.Fatal(err) | ||
| 121 | } | ||
| 122 | if err := f.srv.Index.Upsert(secret); err != nil { | ||
| 123 | t.Fatal(err) | ||
| 124 | } | ||
| 125 | privateWork := workRepo(t, map[string]string{"hidden.md": "private\n"}) | ||
| 126 | if _, errs, err := runGit(t, privateWork, "commit", "--allow-empty", "-qm", "private-commit"); err != nil { | ||
| 127 | t.Fatalf("private commit: %s", errs) | ||
| 128 | } | ||
| 129 | if _, errs, err := f.git(privateWork, "push", f.sshURL("secret"), "main:refs/heads/main"); err != nil { | ||
| 130 | t.Fatalf("private push: %s", errs) | ||
| 131 | } | ||
| 132 | privateClone := filepath.Join(t.TempDir(), "private-clone") | ||
| 133 | if _, errs, err := f.git("", "clone", "-q", f.sshURL("secret"), privateClone); err != nil { | ||
| 134 | t.Fatalf("private clone: %s", errs) | ||
| 135 | } | ||
| 136 | if _, errs, err := f.cli(privateClone, "issue", "new", "--title", "private-issue-marker", "-m", "body"); err != nil { | ||
| 137 | t.Fatalf("private issue: %v\n%s", err, errs) | ||
| 138 | } | ||
| 139 | |||
| 140 | code, body := f.get("/activity") | ||
| 141 | if code != 200 { | ||
| 142 | t.Fatalf("activity = %d", code) | ||
| 143 | } | ||
| 144 | for _, want := range []string{`href="/r/demo"`, "public-issue-marker", "v1.0.0", "released"} { | ||
| 145 | if !strings.Contains(body, want) { | ||
| 146 | t.Fatalf("activity feed is missing %q:\n%s", want, body) | ||
| 147 | } | ||
| 148 | } | ||
| 149 | for _, unwanted := range []string{"private-issue-marker", `href="/r/secret"`, "public-commit", "private-commit", "committed"} { | ||
| 150 | if strings.Contains(body, unwanted) { | ||
| 151 | t.Fatalf("activity feed contains %q:\n%s", unwanted, body) | ||
| 152 | } | ||
| 153 | } | ||
| 154 | |||
| 155 | _, body = f.get("/activity?kind=issues") | ||
| 156 | if !strings.Contains(body, "public-issue-marker") || strings.Contains(body, "v1.0.0") { | ||
| 157 | t.Fatalf("issue filter is wrong:\n%s", body) | ||
| 158 | } | ||
| 159 | _, body = f.get("/activity?kind=releases") | ||
| 160 | if !strings.Contains(body, "v1.0.0") || strings.Contains(body, "public-issue-marker") { | ||
| 161 | t.Fatalf("release filter is wrong:\n%s", body) | ||
| 162 | } | ||
| 163 | } | ||
| 164 | |||
| 165 | func TestFirstRunOnboardingIsAnInstanceAdminPage(t *testing.T) { | ||
| 166 | t.Parallel() | ||
| 167 | f := newFixture(t) | ||
| 168 | |||
| 169 | // The fixture's one repository is owned by the account we sign in as, so | ||
| 170 | // remove it to reach the state a fresh instance is in: an admin and no | ||
| 171 | // repositories. | ||
| 172 | m, err := f.srv.Repos.Open(f.repo) | ||
| 173 | if err != nil { | ||
| 174 | t.Fatal(err) | ||
| 175 | } | ||
| 176 | if err := f.srv.Repos.Delete(f.repo); err != nil { | ||
| 177 | t.Fatal(err) | ||
| 178 | } | ||
| 179 | if err := f.srv.Index.Remove(m.ID); err != nil { | ||
| 180 | t.Fatal(err) | ||
| 181 | } | ||
| 182 | if _, err := f.srv.Identity.SetAdmin(f.ownerID, true, "test"); err != nil { | ||
| 183 | t.Fatal(err) | ||
| 184 | } | ||
| 185 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 186 | resp, body := f.browserGet(t, "/", cookie) | ||
| 187 | if resp.StatusCode != 200 { | ||
| 188 | t.Fatalf("home = %d", resp.StatusCode) | ||
| 189 | } | ||
| 190 | for _, want := range []string{"No repositories yet", "New repository", "Or from the instance directory", "--owner keenan", `href="/admin/repos/new"`} { | ||
| 191 | if !strings.Contains(body, want) { | ||
| 192 | t.Fatalf("first-run page is missing %q:\n%s", want, body) | ||
| 193 | } | ||
| 194 | } | ||
| 195 | } | ||
| 196 | |||
| 197 | func TestHomeCardShowsTopLanguage(t *testing.T) { | ||
| 198 | t.Parallel() | ||
| 199 | f := newFixture(t) | ||
| 200 | f.publish(f.repo) | ||
| 201 | work := workRepo(t, map[string]string{"main.go": "package main\n", "README.md": "readme\n"}) | ||
| 202 | if _, errs, err := f.git(work, "push", f.sshURL(f.repo), "main:refs/heads/main"); err != nil { | ||
| 203 | t.Fatalf("push: %s", errs) | ||
| 204 | } | ||
| 205 | |||
| 206 | _, body := f.get("/") | ||
| 207 | if !strings.Contains(body, `class="lang-dot lang-go"`) || !strings.Contains(body, `class="repo-lang"`) { | ||
| 208 | t.Fatalf("home card has no top-language marker:\n%s", body) | ||
| 209 | } | ||
| 210 | } | ||