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.
Refresh the web surface with Everforest, readable type, and clearer navigation
796ac8c0ce77461174cf520d5fad4dea37744662 · Keenan Nemetz · 12d ago · parent ac00929f
server/ui_test.go +215−11
| @@ -86,6 +86,130 @@ func TestStaticAssetsAreServedFromTheBinary(t *testing.T) { | |||
| 86 | 86 | } | |
| 87 | 87 | } | |
| 88 | 88 | ||
| 89 | // Everforest is the permanent theme: the shell starts on dark/medium, the | ||
| 90 | // account page owns the picker, and every variant is scoped to the attribute | ||
| 91 | // theme.js sets. If the shell stops naming either file the picker does nothing. | ||
| 92 | func TestEverforestThemesAndAccountPicker(t *testing.T) { | ||
| 93 | t.Parallel() | ||
| 94 | f := newFixture(t) | ||
| 95 | |||
| 96 | code, shell := f.get("/") | ||
| 97 | if code != 200 { | ||
| 98 | t.Fatalf("landing page = %d", code) | ||
| 99 | } | ||
| 100 | for _, want := range []string{ | ||
| 101 | `src="/static/theme.js"`, | ||
| 102 | `href="/static/everforest.css"`, | ||
| 103 | `data-theme="everforest-dark-medium"`, | ||
| 104 | `data-font-scale="medium"`, | ||
| 105 | } { | ||
| 106 | if !strings.Contains(shell, want) { | ||
| 107 | t.Errorf("the shell does not carry %s", want) | ||
| 108 | } | ||
| 109 | } | ||
| 110 | if strings.Contains(shell, "data-theme-select") { | ||
| 111 | t.Error("the theme picker belongs on the account page, not in the global bar") | ||
| 112 | } | ||
| 113 | if script, sheet := strings.Index(shell, "/static/theme.js"), strings.Index(shell, "/static/vole.css"); script < 0 || sheet < 0 || script > sheet { | ||
| 114 | t.Error("theme.js must run before the stylesheet so a stored choice does not flash") | ||
| 115 | } | ||
| 116 | |||
| 117 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 118 | resp, account := f.browserGet(t, "/account", cookie) | ||
| 119 | if resp.StatusCode != 200 { | ||
| 120 | t.Fatalf("/account = %d", resp.StatusCode) | ||
| 121 | } | ||
| 122 | if !strings.Contains(account, "data-theme-select") || !strings.Contains(account, `value="system"`) { | ||
| 123 | t.Fatal("the account page does not carry the theme picker") | ||
| 124 | } | ||
| 125 | if !strings.Contains(account, "data-font-scale-select") { | ||
| 126 | t.Fatal("the account page does not carry the text-size picker") | ||
| 127 | } | ||
| 128 | for _, size := range []string{"small", "medium", "large"} { | ||
| 129 | if !strings.Contains(account, `value="`+size+`"`) { | ||
| 130 | t.Errorf("the text-size picker does not offer %s", size) | ||
| 131 | } | ||
| 132 | } | ||
| 133 | for _, variant := range []string{ | ||
| 134 | "everforest-dark-hard", | ||
| 135 | "everforest-dark-medium", | ||
| 136 | "everforest-dark-soft", | ||
| 137 | "everforest-light-hard", | ||
| 138 | "everforest-light-medium", | ||
| 139 | "everforest-light-soft", | ||
| 140 | } { | ||
| 141 | if !strings.Contains(account, `value="`+variant+`"`) { | ||
| 142 | t.Errorf("the theme picker does not offer %s", variant) | ||
| 143 | } | ||
| 144 | } | ||
| 145 | |||
| 146 | code, css := f.get("/static/everforest.css") | ||
| 147 | if code != 200 { | ||
| 148 | t.Fatalf("/static/everforest.css = %d", code) | ||
| 149 | } | ||
| 150 | for _, variant := range []string{ | ||
| 151 | "everforest-dark-hard", | ||
| 152 | "everforest-dark-medium", | ||
| 153 | "everforest-dark-soft", | ||
| 154 | "everforest-light-hard", | ||
| 155 | "everforest-light-medium", | ||
| 156 | "everforest-light-soft", | ||
| 157 | } { | ||
| 158 | if !strings.Contains(css, `data-theme="`+variant+`"`) { | ||
| 159 | t.Errorf("the Everforest sheet does not define %s", variant) | ||
| 160 | } | ||
| 161 | } | ||
| 162 | if !strings.Contains(css, `--ef-green: #a7c080`) { | ||
| 163 | t.Fatal("the Everforest sheet does not carry the dark palette") | ||
| 164 | } | ||
| 165 | if strings.Contains(css, "\nbody {") { | ||
| 166 | t.Fatal("the Everforest sheet has an unscoped body rule and would restyle the fallback") | ||
| 167 | } | ||
| 168 | |||
| 169 | code, base := f.get("/static/vole.css") | ||
| 170 | if code != 200 { | ||
| 171 | t.Fatalf("/static/vole.css = %d", code) | ||
| 172 | } | ||
| 173 | for _, want := range []string{`--font-scale: 0.9`, `--font-scale: 1.1`, `font-size: calc(13.2px * var(--font-scale))`} { | ||
| 174 | if !strings.Contains(base, want) { | ||
| 175 | t.Errorf("the base sheet does not carry the text-size scaling %s", want) | ||
| 176 | } | ||
| 177 | } | ||
| 178 | |||
| 179 | code, script := f.get("/static/theme.js") | ||
| 180 | if code != 200 { | ||
| 181 | t.Fatalf("/static/theme.js = %d", code) | ||
| 182 | } | ||
| 183 | for _, want := range []string{"localStorage", "dataset.theme", "prefers-color-scheme", "everforest-light-medium", "dataset.fontScale", "vole-font-scale"} { | ||
| 184 | if !strings.Contains(script, want) { | ||
| 185 | t.Errorf("the theme script does not carry %s", want) | ||
| 186 | } | ||
| 187 | } | ||
| 188 | } | ||
| 189 | |||
| 190 | // The account page used to render its tables bare, so they had no border or | ||
| 191 | // rounded corners. Every data table on a page surface belongs in a panel. | ||
| 192 | func TestAccountTablesUseThePanelSurface(t *testing.T) { | ||
| 193 | t.Parallel() | ||
| 194 | f := newFixture(t) | ||
| 195 | _, cookie := f.browserSession(t, f.ownerID) | ||
| 196 | resp, body := f.browserGet(t, "/account", cookie) | ||
| 197 | if resp.StatusCode != 200 { | ||
| 198 | t.Fatalf("/account = %d", resp.StatusCode) | ||
| 199 | } | ||
| 200 | for _, heading := range []string{"SSH keys", "Sessions"} { | ||
| 201 | at := strings.Index(body, "<h2>"+heading+"</h2>") | ||
| 202 | if at < 0 { | ||
| 203 | t.Fatalf("account page has no %s section", heading) | ||
| 204 | } | ||
| 205 | section := body[at:] | ||
| 206 | panel, table := strings.Index(section, `<div class="panel">`), strings.Index(section, "<table") | ||
| 207 | if panel < 0 || table < 0 || panel > table { | ||
| 208 | t.Errorf("%s table is not inside a panel", heading) | ||
| 209 | } | ||
| 210 | } | ||
| 211 | } | ||
| 212 | |||
| 89 | 213 | // HTMX adds its indicator styles with a <style> element unless told not to. | |
| 90 | 214 | // style-src 'self' blocks that injection, so the shell must carry the config | |
| 91 | 215 | // that turns it off and the stylesheet must carry the rules it would have added. | |
| @@ -109,14 +233,14 @@ func TestHTMXDoesNotInjectInlineStyles(t *testing.T) { | |||
| 109 | 233 | } | |
| 110 | 234 | } | |
| 111 | 235 | ||
| 112 | // TestTheEmblemShipsAndTheShellNamesIt: the mark and the favicon are one file, | ||
| 113 | // so a page that points at a name nobody ships is a broken tab icon on every | ||
| 114 | // page. This is the test that would have caught the rename. | ||
| 236 | // TestTheEmblemShipsAndTheShellNamesIt: the header mask and the favicon are | ||
| 237 | // separate assets now, so a page that points at a name nobody ships is still a | ||
| 238 | // broken mark or tab icon. This is the test that would have caught the rename. | ||
| 115 | 239 | func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) { | |
| 116 | 240 | t.Parallel() | |
| 117 | 241 | f := newFixture(t) | |
| 118 | 242 | ||
| 119 | for _, name := range []string{"logo.png", "favicon-32.png", "favicon-16.png"} { | ||
| 243 | for _, name := range []string{"logo.png", "vole-head.png", "favicon-32.png", "favicon-32-dark.png", "favicon-16.png", "favicon-16-dark.png"} { | ||
| 120 | 244 | resp, err := http.Get(f.http.URL + "/static/" + name) | |
| 121 | 245 | if err != nil { | |
| 122 | 246 | t.Fatal(err) | |
| @@ -144,15 +268,95 @@ func TestTheEmblemShipsAndTheShellNamesIt(t *testing.T) { | |||
| 144 | 268 | if code != http.StatusOK { | |
| 145 | 269 | t.Fatalf("landing page = %d", code) | |
| 146 | 270 | } | |
| 147 | if !strings.Contains(shell, `href="/static/favicon-32.png"`) { | ||
| 148 | t.Error("the shell does not name the emblem as its icon") | ||
| 271 | if !strings.Contains(shell, `href="/static/favicon-32.png"`) || | ||
| 272 | !strings.Contains(shell, `href="/static/favicon-32-dark.png"`) || | ||
| 273 | !strings.Contains(shell, `href="/static/favicon.svg"`) || | ||
| 274 | !strings.Contains(shell, `media="(prefers-color-scheme: dark)"`) { | ||
| 275 | t.Error("the shell does not name the light/dark fallback and SVG favicons") | ||
| 276 | } | ||
| 277 | if !strings.Contains(shell, `<span class="mark"`) { | ||
| 278 | t.Error("the header does not use the head mark") | ||
| 149 | 279 | } | |
| 150 | if !strings.Contains(shell, `src="/static/logo.png"`) { | ||
| 151 | t.Error("the header does not use the emblem") | ||
| 280 | code, css := f.get("/static/vole.css") | ||
| 281 | if code != http.StatusOK { | ||
| 282 | t.Fatalf("stylesheet = %d", code) | ||
| 283 | } | ||
| 284 | if !strings.Contains(css, `url("/static/vole-head.png")`) || !strings.Contains(css, "background-color: currentColor") { | ||
| 285 | t.Error("the head mark is not a theme-coloured mask") | ||
| 286 | } | ||
| 287 | |||
| 288 | code, svg := f.get("/static/favicon.svg") | ||
| 289 | if code != http.StatusOK || !strings.Contains(svg, "<svg") { | ||
| 290 | t.Fatalf("/static/favicon.svg = %d and does not look like an SVG", code) | ||
| 291 | } | ||
| 292 | if !strings.Contains(svg, "prefers-color-scheme") || !strings.Contains(svg, "#000") || !strings.Contains(svg, "#fff") { | ||
| 293 | t.Error("the SVG favicon does not switch between black and white with the browser preference") | ||
| 294 | } | ||
| 295 | if _, script := f.get("/static/theme.js"); strings.Contains(script, "favicon") { | ||
| 296 | t.Error("the app theme must not choose the favicon; the browser window preference does") | ||
| 297 | } | ||
| 298 | if strings.Contains(shell, "logo.svg") { | ||
| 299 | t.Error("the shell still points at the removed logo.svg") | ||
| 300 | } | ||
| 301 | } | ||
| 302 | |||
| 303 | // Lexend and JetBrains Mono are embedded, not fetched from a CDN. The WOFF2 | ||
| 304 | // magic and the OFL files beside them are the two things a deployer cannot | ||
| 305 | // discover from the page shell alone. | ||
| 306 | func TestSelfHostedFontsAreEmbedded(t *testing.T) { | ||
| 307 | t.Parallel() | ||
| 308 | f := newFixture(t) | ||
| 309 | |||
| 310 | for _, name := range []string{ | ||
| 311 | "lexend-latin.woff2", | ||
| 312 | "lexend-latin-ext.woff2", | ||
| 313 | "jetbrains-mono-latin.woff2", | ||
| 314 | "jetbrains-mono-latin-ext.woff2", | ||
| 315 | "jetbrains-mono-italic-latin.woff2", | ||
| 316 | "jetbrains-mono-italic-latin-ext.woff2", | ||
| 317 | } { | ||
| 318 | resp, err := http.Get(f.http.URL + "/static/fonts/" + name) | ||
| 319 | if err != nil { | ||
| 320 | t.Fatal(err) | ||
| 321 | } | ||
| 322 | if resp.StatusCode != http.StatusOK { | ||
| 323 | resp.Body.Close() | ||
| 324 | t.Fatalf("/static/fonts/%s = %d", name, resp.StatusCode) | ||
| 325 | } | ||
| 326 | if ct := resp.Header.Get("Content-Type"); !strings.Contains(ct, "font/woff2") { | ||
| 327 | resp.Body.Close() | ||
| 328 | t.Fatalf("%s Content-Type = %q", name, ct) | ||
| 329 | } | ||
| 330 | head := make([]byte, 4) | ||
| 331 | if _, err := io.ReadFull(resp.Body, head); err != nil { | ||
| 332 | resp.Body.Close() | ||
| 333 | t.Fatal(err) | ||
| 334 | } | ||
| 335 | resp.Body.Close() | ||
| 336 | if string(head) != "wOF2" { | ||
| 337 | t.Errorf("%s is not a WOFF2 file: %x", name, head) | ||
| 338 | } | ||
| 339 | } | ||
| 340 | |||
| 341 | code, css := f.get("/static/vole.css") | ||
| 342 | if code != http.StatusOK { | ||
| 343 | t.Fatalf("/static/vole.css = %d", code) | ||
| 344 | } | ||
| 345 | for _, want := range []string{ | ||
| 346 | `@font-face`, | ||
| 347 | `font-family: "Lexend"`, | ||
| 348 | `font-family: "JetBrains Mono"`, | ||
| 349 | `/static/fonts/lexend-latin.woff2`, | ||
| 350 | `/static/fonts/jetbrains-mono-latin.woff2`, | ||
| 351 | } { | ||
| 352 | if !strings.Contains(css, want) { | ||
| 353 | t.Errorf("the stylesheet does not carry %s", want) | ||
| 354 | } | ||
| 152 | 355 | } | |
| 153 | for _, gone := range []string{"logo.svg", "favicon.svg"} { | ||
| 154 | if strings.Contains(shell, gone) { | ||
| 155 | t.Errorf("the shell still points at the removed %s", gone) | ||
| 356 | for _, name := range []string{"OFL-Lexend.txt", "OFL-JetBrainsMono.txt"} { | ||
| 357 | code, body := f.get("/static/fonts/" + name) | ||
| 358 | if code != http.StatusOK || !strings.Contains(body, "SIL OPEN FONT LICENSE") { | ||
| 359 | t.Errorf("/static/fonts/%s is missing or does not look like the OFL text", name) | ||
| 156 | 360 | } | |
| 157 | 361 | } | |
| 158 | 362 | } | |