internal/web/web_test.go
283 lines · 11401 bytes
1package web
2
3import (
4 "io/fs"
5 "regexp"
6 "strings"
7 "testing"
8)
9
10// Every page template parses with the layout at start-up, so a broken
11// template fails the process rather than the first visit to its page.
12// Package init has already done the work; this asserts it covered every
13// file (#116).
14func TestEveryPageTemplateParses(t *testing.T) {
15 names := Pages()
16 if len(names) < 30 {
17 t.Fatalf("only %d page templates parsed: %v", len(names), names)
18 }
19 for _, want := range []string{"tree.html", "blob.html", "mr.html", "issue.html", "dashboard.html", "registered.html"} {
20 if _, ok := pages[want]; !ok {
21 t.Errorf("%s not parsed", want)
22 }
23 }
24}
25
26// A th with no rule of its own falls back to the browser's centred default
27// (#151). The classes that do left-align one are read out of the stylesheet
28// rather than listed here, so a new rule does not need a test change.
29func TestHeaderRowsAreLeftAligned(t *testing.T) {
30 aligned := map[string]bool{}
31 for _, rule := range strings.Split(string(StyleCSS), "}") {
32 sel, decls, ok := strings.Cut(rule, "{")
33 if !ok || !strings.Contains(decls, "text-align: left") || !strings.Contains(sel, "th") {
34 continue
35 }
36 for _, class := range regexp.MustCompile(`\.([a-z-]+)`).FindAllStringSubmatch(sel, -1) {
37 aligned[class[1]] = true
38 }
39 }
40 if len(aligned) == 0 {
41 t.Fatal("no left-aligning th rule found in the stylesheet")
42 }
43
44 classes := regexp.MustCompile(`class="([^"]*)"`)
45 entries, err := fs.ReadDir(templateFS, "templates")
46 if err != nil {
47 t.Fatal(err)
48 }
49 for _, e := range entries {
50 b, err := templateFS.ReadFile("templates/" + e.Name())
51 if err != nil {
52 t.Fatal(err)
53 }
54 // The table and row tags a header sits under, plus the header row
55 // itself: any one of them may carry the class.
56 for _, head := range strings.Split(string(b), "<thead>")[1:] {
57 open, _, _ := strings.Cut(head, "</tr>")
58 before := strings.Split(string(b), "<thead>"+head)[0]
59 if i := strings.LastIndex(before, "<table"); i >= 0 {
60 open += before[i:]
61 }
62 hit := false
63 for _, m := range classes.FindAllStringSubmatch(open, -1) {
64 for _, c := range strings.Fields(m[1]) {
65 hit = hit || aligned[c]
66 }
67 }
68 if !hit {
69 t.Errorf("%s: header row is not left-aligned by any rule: %.60s", e.Name(), open)
70 }
71 }
72 }
73}
74
75// when names the zone rather than leaving an absolute time ambiguous, and
76// passes an unparseable value through unchanged (#182).
77func TestWhenNamesTheZone(t *testing.T) {
78 got := funcs["when"].(func(string) string)("2026-09-12T02:18:07.123Z")
79 if got != "2026-09-12 02:18 UTC" {
80 t.Fatalf("when: %q", got)
81 }
82 if got := funcs["when"].(func(string) string)("not a time"); got != "not a time" {
83 t.Fatalf("passthrough: %q", got)
84 }
85}
86
87// TestMainWidthClass checks each template's source for the width define
88// the spec assigns it (wide or bounded), and that a reading page defines
89// none. The merge request page picks wide for its diff view, so it gets
90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "symbols.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "mrrangediff.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true, "queries.html": true}
93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() {
96 src, err := TemplateSource(name)
97 if err != nil {
98 t.Fatal(err)
99 }
100 has := strings.Contains(src, `{{define "width"}}`)
101 switch {
102 case perView[name] != "" && !strings.Contains(src, perView[name]):
103 t.Errorf("%s: want per-view width define %s", name, perView[name])
104 case wide[name] && !strings.Contains(src, `{{define "width"}}wide{{end}}`):
105 t.Errorf("%s: want width wide", name)
106 case bounded[name] && !strings.Contains(src, `{{define "width"}}bounded{{end}}`):
107 t.Errorf("%s: want width bounded", name)
108 case !wide[name] && !bounded[name] && perView[name] == "" && has:
109 t.Errorf("%s: defines a width but the spec calls it reading", name)
110 }
111 }
112 layout, err := TemplateSource("layout.html")
113 if err != nil {
114 t.Fatal(err)
115 }
116 if !strings.Contains(layout, `<main id="content" class="content {{template "width" .}}">`) {
117 t.Error("layout.html: main does not carry the width block")
118 }
119 if !strings.Contains(layout, `{{define "width"}}reading{{end}}`) {
120 t.Error("layout.html: no default width")
121 }
122}
123
124// The rail carries no visible text, so every one of its controls has to
125// name itself twice over: an aria-label for the accessibility tree and a
126// visually hidden span for anything reading the DOM text, with the glyph
127// itself hidden from both so it is never announced as a graphic. This
128// parses layout.html, so an icon link that forgets one fails here rather
129// than on the page.
130func TestRailIconsAreLabelled(t *testing.T) {
131 src, err := TemplateSource("layout.html")
132 if err != nil {
133 t.Fatal(err)
134 }
135 // A control names its glyph either through the icon partial or with an
136 // svg of its own; the aria-hidden loop below covers both.
137 glyph := func(s string) bool {
138 return strings.Contains(s, `{{template "icon" `) || strings.Contains(s, "<svg")
139 }
140 // Every square in the strip comes from the raillink partial, so the
141 // rule is checked once there and once for each control written out
142 // in full: the More summary, Log out and Sign in.
143 link := src[strings.Index(src, `{{define "raillink"}}`):]
144 link = link[:strings.Index(link, "\n")] // the partial is one line
145 for _, want := range []string{`aria-label="{{.Name}}"`, `<span class="vh">{{.Name}}</span>`} {
146 if !strings.Contains(link, want) {
147 t.Errorf("raillink partial missing %s", want)
148 }
149 }
150 if !strings.Contains(link, `{{template "icon" .Icon}}`) {
151 t.Error("raillink partial draws no glyph")
152 }
153 // A call site that names no icon or no name would render a square
154 // with neither, which the partial alone cannot catch.
155 for _, call := range regexp.MustCompile(`{{template "raillink" dict [^}]*}}`).FindAllString(src, -1) {
156 for _, want := range []string{`"Icon" `, `"Name" `} {
157 if !strings.Contains(call, want) {
158 t.Errorf("raillink call missing %s: %.90s", want, call)
159 }
160 }
161 }
162
163 // Every railicon control in the file, wherever it sits and whatever
164 // else its class carries — the strip, the More summary, and the
165 // foot's Log out and Sign in. Log out used to be a button and had a
166 // check of its own here; it is a link to the confirmation page now,
167 // and this loop covers the foot either way. The More menu's own rows
168 // are not icon controls — their visible text is their name.
169 tagRe := regexp.MustCompile(`(?s)<a [^>]*class="[^"]*railicon.*?</a>|<summary [^>]*class="[^"]*railicon.*?</summary>|<button [^>]*class="[^"]*railicon.*?</button>`)
170 controls := tagRe.FindAllString(src, -1)
171 if len(controls) < 3 {
172 t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls))
173 }
174 for _, c := range controls {
175 for _, want := range []string{`aria-label="`, `<span class="vh">`} {
176 if !strings.Contains(c, want) {
177 t.Errorf("railicon control missing %s: %.80s", want, c)
178 }
179 }
180 if !glyph(c) {
181 t.Errorf("railicon control draws no glyph: %.80s", c)
182 }
183 }
184
185 // The foot holds controls of its own rather than raillink squares, so
186 // check it is in what tagRe just scanned: a foot written with none
187 // would pass the loop above by being empty.
188 foot := src[strings.Index(src, `<div class="railfoot">`):]
189 foot = foot[:strings.Index(foot, "</nav>")]
190 if len(tagRe.FindAllString(foot, -1)) == 0 {
191 t.Error("no railicon control in the rail foot")
192 }
193
194 // No decorative graphic anywhere in the layout is exposed, the brand
195 // mark included: the link around it carries the name.
196 for _, svg := range regexp.MustCompile(`<svg[^>]*>`).FindAllString(src, -1) {
197 if !strings.Contains(svg, `aria-hidden="true"`) {
198 t.Errorf("svg without aria-hidden: %s", svg)
199 }
200 }
201}
202
203// Every pre in the stylesheet scrolls sideways, which makes it a
204// scrollable region: a keyboard reaches its content only if the element
205// can take focus, so every authored pre carries tabindex="0" — the same
206// attribute focusableBlocks puts on the pre blocks of rendered markup
207// (#226).
208func TestPreBlocksAreFocusable(t *testing.T) {
209 if !regexp.MustCompile(`(?s)\npre \{[^}]*overflow-x: auto`).Match(StyleCSS) {
210 t.Fatal("style.css no longer scrolls every pre; this test's premise is gone")
211 }
212 preRe := regexp.MustCompile(`<pre[^>]*>`)
213 n := 0
214 for _, name := range append(Pages(), "layout.html") {
215 src, err := TemplateSource(name)
216 if err != nil {
217 t.Fatal(err)
218 }
219 for _, tag := range preRe.FindAllString(src, -1) {
220 n++
221 if !strings.Contains(tag, `tabindex="0"`) {
222 t.Errorf("%s: %s is a scrollable region with no way to focus it", name, tag)
223 }
224 }
225 }
226 if n < 10 {
227 t.Fatalf("found %d pre blocks in the templates, want the site's set", n)
228 }
229}
230
231// A link mixed into other text is told apart by its underline. The rule
232// covers running text; these are the other places a link sits in a line
233// of text it has to be picked out of — a path, a heading, an empty-state
234// note. Link and muted text are 1.07:1 apart in dark, so colour alone is
235// no cue (#226).
236func TestMixedTextLinksAreUnderlined(t *testing.T) {
237 css := string(StyleCSS)
238 for _, sel := range []string{".crumbs a", "h1 a", "li.empty a"} {
239 re := regexp.MustCompile(`(?m)^[^{}\n]*` + regexp.QuoteMeta(sel) + `[^{}\n]*\{[^}]*text-decoration: underline`)
240 if !re.MatchString(css) {
241 t.Errorf("%s is not underlined", sel)
242 }
243 }
244}
245
246// The main rail and the phone "More" menu render New repository,
247// Settings, Admin and Log out from one list, so adding a destination in
248// one place reaches both (#271).
249func TestRailOptItemsDriveBothRailAndMoreMenu(t *testing.T) {
250 items := railOptItems(struct {
251 Tab string
252 Admin bool
253 }{Tab: "admin", Admin: true})
254 if len(items) != 4 {
255 t.Fatalf("got %d items, want 4 (New repository, Settings, Admin, Log out)", len(items))
256 }
257 if items[2].Name != "Admin" || !items[2].Show {
258 t.Errorf("Admin item: %+v", items[2])
259 }
260 if !items[2].Current {
261 t.Error("Admin item should be Current when Tab is admin")
262 }
263
264 nonAdmin := railOptItems(struct {
265 Tab string
266 Admin bool
267 }{Tab: "account"})
268 if nonAdmin[2].Show {
269 t.Error("Admin item should not Show for a non-admin viewer")
270 }
271 if !nonAdmin[1].Current {
272 t.Error("Settings item should be Current when Tab is account")
273 }
274
275 // TestRailIconsAreLabelled's regex checks a raillink call site for a
276 // literal "Icon" and "Name" argument; a call built off railOptItems
277 // does not match that pattern, so it is checked here instead.
278 for i, it := range items {
279 if it.Icon == "" || it.Name == "" || it.Href == "" {
280 t.Errorf("item %d missing a field: %+v", i, it)
281 }
282 }
283}