internal/web/web_test.go

f327db6192d9a0877606a40385b24c0cda29fd2d
gitbay/internal/web/web_test.go history · blame · raw

249 lines · 9997 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, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true}
 93	bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "adminusers.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	tagRe := regexp.MustCompile(`(?s)<a class="railicon".*?</a>|<summary class="railicon".*?</summary>|<button [^>]*class="railicon".*?</button>`)
164	controls := tagRe.FindAllString(src, -1)
165	if len(controls) < 3 {
166		t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls))
167	}
168	for _, c := range controls {
169		for _, want := range []string{`aria-label="`, `<span class="vh">`} {
170			if !strings.Contains(c, want) {
171				t.Errorf("railicon control missing %s: %.80s", want, c)
172			}
173		}
174		if !glyph(c) {
175			t.Errorf("railicon control draws no glyph: %.80s", c)
176		}
177	}
178
179	// An icon button in the rail's foot is under the same rule: Log out
180	// is the only one today. The More menu's own rows are not icon
181	// buttons — their visible text is their name.
182	foot := src[strings.Index(src, `<div class="railfoot">`):]
183	foot = foot[:strings.Index(foot, "</nav>")]
184	buttons := regexp.MustCompile(`(?s)<button [^>]*class="railicon".*?</button>`).FindAllString(foot, -1)
185	if len(buttons) == 0 {
186		t.Fatal("no icon button in the rail foot")
187	}
188	for _, b := range buttons {
189		for _, want := range []string{`aria-label="`, `<span class="vh">`} {
190			if !strings.Contains(b, want) {
191				t.Errorf("rail foot button missing %s: %.80s", want, b)
192			}
193		}
194		if !glyph(b) {
195			t.Errorf("rail foot button draws no glyph: %.80s", b)
196		}
197	}
198
199	// No decorative graphic anywhere in the layout is exposed, the brand
200	// mark included: the link around it carries the name.
201	for _, svg := range regexp.MustCompile(`<svg[^>]*>`).FindAllString(src, -1) {
202		if !strings.Contains(svg, `aria-hidden="true"`) {
203			t.Errorf("svg without aria-hidden: %s", svg)
204		}
205	}
206}
207
208// Every pre in the stylesheet scrolls sideways, which makes it a
209// scrollable region: a keyboard reaches its content only if the element
210// can take focus, so every authored pre carries tabindex="0" — the same
211// attribute focusableBlocks puts on the pre blocks of rendered markup
212// (#226).
213func TestPreBlocksAreFocusable(t *testing.T) {
214	if !regexp.MustCompile(`(?s)\npre \{[^}]*overflow-x: auto`).Match(StyleCSS) {
215		t.Fatal("style.css no longer scrolls every pre; this test's premise is gone")
216	}
217	preRe := regexp.MustCompile(`<pre[^>]*>`)
218	n := 0
219	for _, name := range append(Pages(), "layout.html") {
220		src, err := TemplateSource(name)
221		if err != nil {
222			t.Fatal(err)
223		}
224		for _, tag := range preRe.FindAllString(src, -1) {
225			n++
226			if !strings.Contains(tag, `tabindex="0"`) {
227				t.Errorf("%s: %s is a scrollable region with no way to focus it", name, tag)
228			}
229		}
230	}
231	if n < 10 {
232		t.Fatalf("found %d pre blocks in the templates, want the site's set", n)
233	}
234}
235
236// A link mixed into other text is told apart by its underline. The rule
237// covers running text; these are the other places a link sits in a line
238// of text it has to be picked out of — a path, a heading, an empty-state
239// note. Link and muted text are 1.07:1 apart in dark, so colour alone is
240// no cue (#226).
241func TestMixedTextLinksAreUnderlined(t *testing.T) {
242	css := string(StyleCSS)
243	for _, sel := range []string{".crumbs a", "h1 a", "li.empty a"} {
244		re := regexp.MustCompile(`(?m)^[^{}\n]*` + regexp.QuoteMeta(sel) + `[^{}\n]*\{[^}]*text-decoration: underline`)
245		if !re.MatchString(css) {
246			t.Errorf("%s is not underlined", sel)
247		}
248	}
249}