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/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 }