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.
Remove the avatars and read people by name again
28b19c7433fa6f6fe1ce826c443870385d856eb8 · Keenan Nemetz · 8d ago · parent 0fbeca85
Remove the avatars and read people by name again The marks were harder to read than the names they replaced, so they are gone: the account menu is back to the user icon, and the issue and change tables, the profile, the directory and the contributor list all say the account name. This takes out everything that existed to draw a mark: the DiceBear bundle and its licence file, the entry source that built it, the Landscape style, avatar.go and its tests, the avatar template and its three helper functions, the avatar palette in everforest.css and the illustration rules in vole.css, and the app.js block that rendered and re-rendered them on a theme change. The tables keep the layout from the previous change — an author column and an assignee column — because that part reads better with names too. The assignee cells are the @name chips that already linked to the filtered list. `vole workflow run verify` passes, and the licence inventory is back to one entry fewer.
server/web/avatar_test.go −173
| @@ -1,173 +0,0 @@ | |||
| 1 | package web | ||
| 2 | |||
| 3 | // The server renders the ground and the fallback letter; the mark itself is drawn | ||
| 4 | // in the browser by the embedded DiceBear bundle. These tests hold the server's | ||
| 5 | // half of that contract: a stable ground, a stable letter, the name as the seed, | ||
| 6 | // a profile link only for real accounts, and the assets the browser needs. | ||
| 7 | |||
| 8 | import ( | ||
| 9 | "bytes" | ||
| 10 | "strings" | ||
| 11 | "testing" | ||
| 12 | ) | ||
| 13 | |||
| 14 | func TestAvatarIsStableForAName(t *testing.T) { | ||
| 15 | t.Parallel() | ||
| 16 | first := portrait("alice") | ||
| 17 | for i := 0; i < 5; i++ { | ||
| 18 | again := portrait("alice") | ||
| 19 | if again.Tone != first.Tone || again.Initial != first.Initial { | ||
| 20 | t.Fatalf("avatar for alice changed between calls: %+v then %+v", first, again) | ||
| 21 | } | ||
| 22 | } | ||
| 23 | if first.Initial != "A" { | ||
| 24 | t.Fatalf("initial = %q, want A", first.Initial) | ||
| 25 | } | ||
| 26 | if portrait("").Initial != "?" { | ||
| 27 | t.Fatalf("empty name initial = %q, want ?", portrait("").Initial) | ||
| 28 | } | ||
| 29 | if portrait("alice").Tone == portrait("bob").Tone && portrait("alice").Tone == portrait("adam").Tone { | ||
| 30 | t.Fatal("three names share one ground, so the tone is not deriving from the name") | ||
| 31 | } | ||
| 32 | } | ||
| 33 | |||
| 34 | func TestAvatarToneIsInRange(t *testing.T) { | ||
| 35 | t.Parallel() | ||
| 36 | seen := map[int]bool{} | ||
| 37 | for i := 0; i < 200; i++ { | ||
| 38 | name := "account-" + string(rune('a'+i%26)) + string(rune('a'+i/26)) | ||
| 39 | tone := portrait(name).Tone | ||
| 40 | if tone < 1 || tone > 7 { | ||
| 41 | t.Fatalf("portrait(%q).Tone = %d, want 1..7", name, tone) | ||
| 42 | } | ||
| 43 | seen[tone] = true | ||
| 44 | } | ||
| 45 | if len(seen) < 6 { | ||
| 46 | t.Fatalf("only %d of 7 grounds appeared across 200 names", len(seen)) | ||
| 47 | } | ||
| 48 | } | ||
| 49 | |||
| 50 | // The seed is the name and it reaches the page as an attribute, so the escaping | ||
| 51 | // the template does is all that stands between a hostile name and the DOM. A Git | ||
| 52 | // author is a string somebody typed, so this is a real input and not a | ||
| 53 | // hypothetical one. | ||
| 54 | func TestAvatarMarkupEscapesTheName(t *testing.T) { | ||
| 55 | t.Parallel() | ||
| 56 | s := newSite(t) | ||
| 57 | hostile := `"><script>alert(1)</script>` | ||
| 58 | var out bytes.Buffer | ||
| 59 | if err := s.web.Tmpl.ExecuteTemplate(&out, "avatar", s.web.avatar(hostile)); err != nil { | ||
| 60 | t.Fatal(err) | ||
| 61 | } | ||
| 62 | markup := out.String() | ||
| 63 | if strings.Contains(markup, "<script") { | ||
| 64 | t.Fatalf("a hostile name escaped the attribute:\n%s", markup) | ||
| 65 | } | ||
| 66 | if !strings.Contains(markup, "data-avatar=") { | ||
| 67 | t.Fatalf("the avatar lost its seed:\n%s", markup) | ||
| 68 | } | ||
| 69 | } | ||
| 70 | |||
| 71 | func TestAvatarLinkedOnlyWhenTheNameIsAnAccount(t *testing.T) { | ||
| 72 | t.Parallel() | ||
| 73 | s := newSite(t) | ||
| 74 | |||
| 75 | plain := s.web.avatar("alice") | ||
| 76 | if plain.Href != "" || plain.Name != "alice" { | ||
| 77 | t.Fatalf("a bare avatar = %+v", plain) | ||
| 78 | } | ||
| 79 | linked := s.web.avatarUser(s.web.userRef("alice")) | ||
| 80 | if linked.Href != "/u/alice" || linked.Name != "alice" { | ||
| 81 | t.Fatalf("linked avatar = %+v", linked) | ||
| 82 | } | ||
| 83 | // A name that is not an account still gets an avatar, but no link to a 404. | ||
| 84 | stranger := s.web.avatarUser(s.web.userRef("nobody")) | ||
| 85 | if stranger.Href != "" || stranger.Name != "nobody" { | ||
| 86 | t.Fatalf("unknown account avatar = %+v", stranger) | ||
| 87 | } | ||
| 88 | } | ||
| 89 | |||
| 90 | func TestAvatarRendersInTheChrome(t *testing.T) { | ||
| 91 | t.Parallel() | ||
| 92 | s := newSite(t) | ||
| 93 | body := s.serve(t, nil, "/u/alice").Body.String() | ||
| 94 | |||
| 95 | // The server's half: a ground, the letter, and the name for the browser. | ||
| 96 | if !strings.Contains(body, `data-avatar="alice"`) { | ||
| 97 | t.Fatalf("the profile does not carry the avatar seed:\n%s", body) | ||
| 98 | } | ||
| 99 | if !strings.Contains(body, `>A</span>`) && !strings.Contains(body, `>A</a>`) { | ||
| 100 | t.Fatalf("the profile has no fallback letter:\n%s", body) | ||
| 101 | } | ||
| 102 | // No server-rendered SVG any more: the mark is the browser's job. | ||
| 103 | if strings.Contains(body, `class="vole-avatar"`) { | ||
| 104 | t.Fatalf("the server is still drawing a mark:\n%s", body) | ||
| 105 | } | ||
| 106 | // The page loads the bundle, and the avatar is decoration beside the name. | ||
| 107 | if !strings.Contains(body, `/static/dicebear-avatars.min.js`) { | ||
| 108 | t.Fatalf("the profile does not load the avatar bundle:\n%s", body) | ||
| 109 | } | ||
| 110 | if !strings.Contains(body, `data-avatar="alice" aria-hidden="true"`) { | ||
| 111 | t.Fatalf("the avatar is not hidden from assistive technology:\n%s", body) | ||
| 112 | } | ||
| 113 | } | ||
| 114 | |||
| 115 | // The bundle and its licences are embedded like every other asset, because the | ||
| 116 | // binary has to be the whole program and the licence inventory in NOTICE has to | ||
| 117 | // match what ships. | ||
| 118 | func TestAvatarAssetsAreEmbedded(t *testing.T) { | ||
| 119 | t.Parallel() | ||
| 120 | bundle, err := staticFS.ReadFile("static/dicebear-avatars.min.js") | ||
| 121 | if err != nil { | ||
| 122 | t.Fatalf("the avatar bundle is not embedded: %v", err) | ||
| 123 | } | ||
| 124 | if len(bundle) < 4_000 { | ||
| 125 | t.Fatalf("the avatar bundle is %d bytes, which cannot be DiceBear", len(bundle)) | ||
| 126 | } | ||
| 127 | for _, want := range []string{"VoleAvatars", "DiceBear", "Landscape"} { | ||
| 128 | if !strings.Contains(string(bundle), want) { | ||
| 129 | t.Fatalf("the avatar bundle does not mention %q", want) | ||
| 130 | } | ||
| 131 | } | ||
| 132 | |||
| 133 | core, err := staticFS.ReadFile("static/dicebear.LICENSE") | ||
| 134 | if err != nil { | ||
| 135 | t.Fatalf("the DiceBear licence is not embedded: %v", err) | ||
| 136 | } | ||
| 137 | if !strings.Contains(string(core), "MIT License") { | ||
| 138 | t.Fatalf("dicebear.LICENSE does not read as MIT:\n%s", core) | ||
| 139 | } | ||
| 140 | } | ||
| 141 | |||
| 142 | // In an author or assignee cell the avatar is the whole content, so it stops | ||
| 143 | // being decoration: it must not hide from assistive technology, and it needs the | ||
| 144 | // name it stands for. | ||
| 145 | func TestAvatarOnlyCarriesTheName(t *testing.T) { | ||
| 146 | t.Parallel() | ||
| 147 | s := newSite(t) | ||
| 148 | view := s.web.avatarOnly(s.web.userRef("alice")) | ||
| 149 | if view.Label != "alice" || view.Href != "/u/alice" { | ||
| 150 | t.Fatalf("avatarOnly = %+v", view) | ||
| 151 | } | ||
| 152 | var out bytes.Buffer | ||
| 153 | if err := s.web.Tmpl.ExecuteTemplate(&out, "avatar", view); err != nil { | ||
| 154 | t.Fatal(err) | ||
| 155 | } | ||
| 156 | markup := out.String() | ||
| 157 | if strings.Contains(markup, `aria-hidden`) || strings.Contains(markup, `tabindex="-1"`) { | ||
| 158 | t.Fatalf("an image-only avatar is hidden from assistive technology:\n%s", markup) | ||
| 159 | } | ||
| 160 | if !strings.Contains(markup, `aria-label="alice"`) { | ||
| 161 | t.Fatalf("the image-only avatar has no accessible name:\n%s", markup) | ||
| 162 | } | ||
| 163 | |||
| 164 | // A name that is not an account still needs a name, but no link. | ||
| 165 | stranger := s.web.avatarOnly(s.web.userRef("nobody")) | ||
| 166 | out.Reset() | ||
| 167 | if err := s.web.Tmpl.ExecuteTemplate(&out, "avatar", stranger); err != nil { | ||
| 168 | t.Fatal(err) | ||
| 169 | } | ||
| 170 | if !strings.Contains(out.String(), `role="img" aria-label="nobody"`) { | ||
| 171 | t.Fatalf("an unknown name lost its label:\n%s", out.String()) | ||
| 172 | } | ||
| 173 | } | ||