diff --git a/.editorconfig b/.editorconfig index 5c6fe22..4bd6737 100644 --- a/.editorconfig +++ b/.editorconfig @@ -42,3 +42,10 @@ indent_size = 4 # there is one place to argue with that rather than two. [*.md] indent_size = 4 + +# The go command writes the module file's require block with tabs, so this +# follows the toolchain for the same reason the Go section above does. go.sum +# is not here because nothing in it is indented. +[go.mod] +indent_style = tab +indent_size = 4 diff --git a/.github/workflows/harness.yml b/.github/workflows/harness.yml index 2b2ed1f..0e790a7 100644 --- a/.github/workflows/harness.yml +++ b/.github/workflows/harness.yml @@ -8,6 +8,11 @@ # Nothing here blocks a merge. decisions/headless-and-unelevated.md says the gate # never depends on this file, and the gate's own report says on every run that # none of these ran, so a green gate is not read as a green harness. +# +# One job elevates and the rest do not. needs-browser runs a single privileged +# command, with the reason beside it in that job, because Chrome will not start +# on this image otherwise. The unelevated rule that file states is about the +# gate, and no leg of the gate reaches anything here. name: Harness on: @@ -69,6 +74,35 @@ jobs: - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version-file: go.mod + # Chrome puts every renderer in a namespace sandbox, and building that + # sandbox needs an unprivileged user namespace. The runner image restricts + # those with AppArmor, so the browser does not start without isolation, it + # refuses to start at all: + # + # FATAL:content/browser/zygote_host/zygote_host_impl_linux.cc:128] + # No usable sandbox! + # + # The line below lifts that one restriction, on this runner, for the length + # of this job, on a machine that is destroyed when the job ends. The + # browser keeps its own sandbox. The alternative is a flag that turns that + # sandbox off instead, which would follow the check onto every machine it + # ever ran on, a contributor's workstation included, so the weakening is + # scoped to a throwaway runner rather than travelling with the test. + # + # It is the only privileged command in this file and it stays inside this + # job. A second job needing a browser gets the same step and the same + # reason rather than the setting moving somewhere it applies to everything. + # + # It goes when the image stops needing it, and that is read rather than + # guessed: the step prints the setting before it changes it, and the day + # that line already reads 0 is the day the step is deleted. + # + # `sudo -n` never prompts. It fails rather than asking, which is the same + # reason gate.yml gives for asking about elevation in a job at all. + - name: Allow the unprivileged user namespace Chrome's sandbox is built from + run: | + sysctl -n kernel.apparmor_restrict_unprivileged_userns + sudo -n sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 - run: go run . harness needs-browser needs-jellyfin: diff --git a/docs/design-system.html b/docs/design-system.html index 68ec96a..d2aebb8 100644 --- a/docs/design-system.html +++ b/docs/design-system.html @@ -299,7 +299,7 @@

Phone · from 35 cm

The tile

Three states, and the third one decides

-
+
The Shaft
2019 · 1 hr 34
Northern Light
2021 · 2 hr 06
Cold Coast
loading …
@@ -332,7 +332,7 @@

The only place with colour in it

Move with , or click. Switch the colour vision preset above and look at the same focus again.

-
+
diff --git a/go.mod b/go.mod index d79375d..cef9e21 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,15 @@ module flowfin.dev/hub go 1.25 + +require github.com/chromedp/chromedp v0.14.2 + +require ( + github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect + github.com/chromedp/sysutil v1.1.0 // indirect + github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect + github.com/gobwas/httphead v0.1.0 // indirect + github.com/gobwas/pool v0.2.1 // indirect + github.com/gobwas/ws v1.4.0 // indirect + golang.org/x/sys v0.34.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a62ad28 --- /dev/null +++ b/go.sum @@ -0,0 +1,21 @@ +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= +github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM= +github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= +github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= +github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= diff --git a/internal/format/format.go b/internal/format/format.go index 071f7b2..69e156e 100644 --- a/internal/format/format.go +++ b/internal/format/format.go @@ -99,11 +99,18 @@ func CheckFile(name string, content []byte) []Finding { return findings } -// tabIndented says whether .editorconfig indents this path with tabs. Go is the -// only such type in the tree, because gofmt indents with tabs and rewriting it -// would be a fight with the toolchain's own formatter rather than a style. +// tabIndented says whether .editorconfig indents this path with tabs. +// +// Two cases, and both are the same argument rather than two exceptions. gofmt +// indents Go with tabs, and the module file's require block is written by the +// go command with tabs. Both are files a tool rewrites on its own, so a rule +// that indented them with spaces would be refused again by the next command +// anybody ran, and the check would be measuring who ran what last. +// +// go.sum has no indentation at all and is not here for that reason rather than +// by omission. func tabIndented(name string) bool { - return filepath.Ext(name) == ".go" + return filepath.Ext(name) == ".go" || filepath.Base(name) == "go.mod" } // TrackedFiles returns the paths git tracks under root, so an untracked scratch diff --git a/internal/format/format_test.go b/internal/format/format_test.go index 0221b81..e09ddd5 100644 --- a/internal/format/format_test.go +++ b/internal/format/format_test.go @@ -115,3 +115,18 @@ func TestTrackedTreeIsFormatted(t *testing.T) { t.Logf("%d tracked file(s) checked", len(paths)) } } + +func TestCheckFileAllowsATabIndentInTheModuleFile(t *testing.T) { + // The go command writes the require block with tabs and rewrites the file on + // its own, so a rule refusing that would be undone by the next `go get` + // anybody ran and the check would be measuring who ran what last. + mod := "module m\n\ngo 1.25\n\nrequire (\n\tx v1.0.0\n)\n" + if findings := CheckFile("go.mod", []byte(mod)); len(findings) != 0 { + t.Fatalf("refused %v, and the go command indents the require block with tabs", findings) + } + // The name is matched whole rather than as a substring, so a file that + // merely carries it is still held to the rule. + if findings := CheckFile("docs/go.mod.md", []byte("\tindented prose\n")); len(findings) != 1 { + t.Fatalf("refused %v over a file whose name only contains go.mod, want exactly one finding", findings) + } +} diff --git a/internal/keyboard/browser_test.go b/internal/keyboard/browser_test.go new file mode 100644 index 0000000..82ca2e7 --- /dev/null +++ b/internal/keyboard/browser_test.go @@ -0,0 +1,223 @@ +//go:build needs_browser + +// The half of internal/keyboard that needs a browser: what a real render with +// the page's own script running actually puts in the tab order, and what the +// keys do once focus is there. +// +// It is here rather than in the gate because deciding it needs a browser +// installed, which decisions/headless-and-unelevated.md keeps out of the merge +// gate. The expectation it is measured against is derived in the untagged file +// beside this one and runs in the gate, so a page that grew a control nobody can +// reach reds here rather than passing quietly in both places. +package keyboard + +import ( + "context" + "fmt" + "net/url" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/chromedp/chromedp" + "github.com/chromedp/chromedp/kb" +) + +// tabCap bounds the walk through the tab order. +// +// A page whose focus handling traps the keyboard would otherwise run until the +// job timed out, and a timeout says "the runner" where this should say "the +// page". It is several times the largest control count the pages hold, so it +// does not quietly become the thing that ends the walk on a page that grew. +const tabCap = 60 + +func browser(t *testing.T) (context.Context, func()) { + t.Helper() + alloc, cancelAlloc := chromedp.NewExecAllocator(context.Background(), + append(chromedp.DefaultExecAllocatorOptions[:], + chromedp.Flag("headless", true), + chromedp.Flag("disable-extensions", true), + )...) + ctx, cancelCtx := chromedp.NewContext(alloc) + ctx, cancelTimeout := context.WithTimeout(ctx, 120*time.Second) + return ctx, func() { cancelTimeout(); cancelCtx(); cancelAlloc() } +} + +// pageURL is the file address of a served page in this checkout. +// +// The pages are opened off the disk rather than off a server, so this check +// needs a browser and nothing else. A render that fetched anything would make it +// depend on somebody else's service while claiming to depend only on a browser, +// and site-fetches-nothing-outside is the leg that holds the pages to that. +func pageURL(t *testing.T, page string) string { + t.Helper() + abs, err := filepath.Abs(filepath.Join("..", "..", filepath.FromSlash(page))) + if err != nil { + t.Fatalf("resolving %s: %v", page, err) + } + return (&url.URL{Scheme: "file", Path: "/" + filepath.ToSlash(abs)}).String() +} + +// focused reads the element that has focus, as the Control the untagged reader +// would have produced for it. +// +// The label is taken the same way: an explicit one where there is one, the text +// otherwise, whitespace collapsed. The tag name carries no space and a label +// may, so the first space is the separator and nothing else has to be escaped. +const focused = `(function(){ + var e = document.activeElement; + if (!e || e === document.body || e === document.documentElement) { return "" } + var label = e.getAttribute("aria-label"); + if (label === null) { label = e.textContent } + return e.tagName.toLowerCase() + " " + label.split(/\s+/).filter(Boolean).join(" "); +})()` + +func parseFocused(s string) (Control, bool) { + tag, label, ok := strings.Cut(s, " ") + if !ok || tag == "" { + return Control{}, false + } + return Control{Tag: tag, Label: label}, true +} + +// walk tabs from the top of the document and collects what focus lands on, in +// order, stopping when it comes back round to the first control or when the cap +// is reached. +func walk(ctx context.Context, t *testing.T, page string) []Control { + t.Helper() + if err := chromedp.Run(ctx, + chromedp.Navigate(pageURL(t, page)), + chromedp.WaitReady("body"), + // Focus starts at the document rather than wherever the previous + // navigation left it, so this is the walk a reader who has just opened + // the page makes. + chromedp.Evaluate(`document.activeElement && document.activeElement.blur && document.activeElement.blur()`, nil), + ); err != nil { + t.Fatalf("%s: opening: %v", page, err) + } + + var out []Control + for i := 0; i < tabCap; i++ { + var got string + if err := chromedp.Run(ctx, + chromedp.KeyEvent(kb.Tab), + chromedp.Evaluate(focused, &got), + ); err != nil { + t.Fatalf("%s: tab %d: %v", page, i+1, err) + } + c, ok := parseFocused(got) + if !ok { + // Focus has left the page's own controls, which is the browser's + // own interface. The walk is over. + break + } + if len(out) > 0 && c == out[0] { + break + } + out = append(out, c) + } + return out +} + +func TestEveryControlOnEveryServedPageIsReachableByTabAlone(t *testing.T) { + ctx, done := browser(t) + defer done() + + for _, page := range Pages { + declared, err := Interactive(read(t, page)) + if err != nil { + t.Fatalf("%s: %v", page, err) + } + if len(declared) == 0 { + t.Fatalf("%s declares no control, so a green here would say nothing", page) + } + + reached := walk(ctx, t, page) + t.Logf("%s: %d control(s) declared, %d reached by Tab", page, len(declared), len(reached)) + if missing := Missing(declared, reached); len(missing) > 0 { + t.Errorf("%s: the keyboard never reaches %s", page, Format(missing)) + } + if extra := Extra(declared, reached); len(extra) > 0 { + t.Errorf("%s: focus lands on %s, which the markup does not declare as a control, so this comparison no longer describes the page", page, Format(extra)) + } + } +} + +func TestEveryBrightnessAndPresetButtonIsOperableFromTheKeyboard(t *testing.T) { + // Reachable and operable are different claims. A button that takes focus and + // does nothing on Enter is the failure a pointer never shows. + ctx, done := browser(t) + defer done() + + page := "docs/design-system.html" + var count int + if err := chromedp.Run(ctx, + chromedp.Navigate(pageURL(t, page)), + chromedp.WaitReady("body"), + chromedp.Evaluate(`document.querySelectorAll(".mode").length`, &count), + ); err != nil { + t.Fatalf("opening %s: %v", page, err) + } + if count == 0 { + t.Fatal("the page carries no brightness or preset button, so this proves nothing") + } + t.Logf("%s: %d brightness and preset button(s)", page, count) + + for i := 0; i < count; i++ { + sel := fmt.Sprintf(`document.querySelectorAll(".mode")[%d]`, i) + var before, after, label string + if err := chromedp.Run(ctx, + chromedp.Evaluate(sel+`.textContent.trim()`, &label), + chromedp.Evaluate(sel+`.focus()`, nil), + chromedp.Evaluate(sel+`.getAttribute("aria-pressed")`, &before), + chromedp.KeyEvent(kb.Enter), + chromedp.Evaluate(sel+`.getAttribute("aria-pressed")`, &after), + ); err != nil { + t.Fatalf("operating button %d: %v", i, err) + } + if after != "true" { + t.Errorf("%q read %s before Enter and %s after, so pressing it from the keyboard did not select it", + label, before, after) + } + } +} + +func TestTheExampleRowMovesUnderTheArrowKeys(t *testing.T) { + // The behaviour with no pointer equivalent at all, and the reason this half + // cannot be decided by a Go test reading the file: the handling is in the + // page's own script and only exists once that script has run. + ctx, done := browser(t) + defer done() + + page := "docs/design-system.html" + at := `[].slice.call(document.querySelectorAll(".fcell")).findIndex(function(c){return c.getAttribute("aria-selected")==="true"})` + + var cells, start, right, back int + if err := chromedp.Run(ctx, + chromedp.Navigate(pageURL(t, page)), + chromedp.WaitReady("body"), + chromedp.Evaluate(`document.querySelectorAll(".fcell").length`, &cells), + chromedp.Evaluate(at, &start), + chromedp.Evaluate(`document.querySelectorAll(".fcell")[0].focus()`, nil), + chromedp.KeyEvent(kb.ArrowRight), + chromedp.Evaluate(at, &right), + chromedp.KeyEvent(kb.ArrowLeft), + chromedp.Evaluate(at, &back), + ); err != nil { + t.Fatalf("operating the row: %v", err) + } + if cells < 2 { + t.Fatalf("the row holds %d cell(s), so an arrow key has nowhere to go and this proves nothing", cells) + } + t.Logf("%s: %d cell(s) in the row", page, cells) + if start != 0 { + t.Fatalf("the row opens with cell %d selected, want the first", start) + } + if right != 1 { + t.Errorf("ArrowRight moved the selection to %d, want 1", right) + } + if back != 0 { + t.Errorf("ArrowLeft moved the selection back to %d, want 0", back) + } +} diff --git a/internal/keyboard/keyboard.go b/internal/keyboard/keyboard.go new file mode 100644 index 0000000..d3ba2f4 --- /dev/null +++ b/internal/keyboard/keyboard.go @@ -0,0 +1,300 @@ +// Package keyboard says what a keyboard has to be able to reach on the served +// pages, and keeps that answer apart from the browser that measures it. +// +// The design system promises that everything is operable from the keyboard +// alone. The page is where a reader first meets that promise, so it is the worst +// place for it to be untrue, and it is also the only place in this tree where +// deciding it needs a page rendered and its script executed. That half is the +// file beside this one, behind the needs_browser tag; this half is the +// expectation, and it runs in the gate like anything else. +// +// The expectation is DERIVED FROM THE MARKUP rather than written down. A list of +// controls maintained beside a page stops matching it, and the control that gets +// added without keyboard reach is exactly the one nobody remembers to add to the +// list. So a page growing a button grows this expectation in the same edit, and +// the browser has to find that button reachable or the check reds. +// +// What it does not decide. Whether the focus ring is drawn, whether the label a +// screen reader announces is the text this reader takes, and whether the control +// does anything useful once operated are all outside it. The first needs painted +// pixels, the second needs an accessibility tree, and the third is what the +// browser-side assertions are for. +package keyboard + +import ( + "fmt" + "sort" + "strings" +) + +// Pages are the served pages this package judges, relative to the repository +// root. +// +// Both, and not only the one carrying the script. A landing page with one link +// is the easier of the two to get wrong, because there is nothing on it that +// looks like it needs checking. +var Pages = []string{"docs/index.html", "docs/design-system.html"} + +// Control is one thing on a page that a keyboard has to reach. +type Control struct { + // Tag is the element's tag name, lower case. + Tag string + // Label is the text a reader sees on it, with whitespace collapsed. It is + // the key the rendered page is compared on, because it is the one property + // a person and a browser agree about without an accessibility tree. + Label string +} + +func (c Control) String() string { + if c.Label == "" { + return c.Tag + " with no text on it" + } + return c.Tag + " " + quoted(c.Label) +} + +func quoted(s string) string { return "\"" + s + "\"" } + +// focusable are the tag names a browser puts in the tab order without being +// asked. Anything else needs a tabindex, and carrying one is what puts it in the +// list below. +// +// The set is the natively focusable form controls plus the anchor, which is +// focusable only when it carries an href. A list that included the anchor +// unconditionally would expect focus on every in-page target name and find none. +var focusable = map[string]bool{ + "button": true, "a": true, "input": true, "select": true, "textarea": true, +} + +// void are the tags that carry no closing tag, so their label cannot come from +// text between one. +var void = map[string]bool{"input": true} + +// Interactive lists the controls a page declares, in document order. +// +// A duplicate label is kept rather than deduplicated. Two controls reading the +// same are two things a keyboard has to reach, and collapsing them here would +// let one of them fall out of the tab order with nothing to say so. +func Interactive(html string) ([]Control, error) { + var out []Control + for i := 0; i < len(html); i++ { + if html[i] != '<' { + continue + } + name, attrs, end, ok := openTag(html, i) + if !ok { + continue + } + + // A disabled control is out of the tab order by the specification, and + // correctly so, so expecting focus on it would refuse a page that is + // right. It is read before the tabindex, because a disabled control + // carrying a positive one is still not focusable. + if hasWord(attrs, "disabled") { + i = end + continue + } + + tabindex, hasTabindex := attr(attrs, "tabindex") + switch { + case hasTabindex && tabindex == "-1": + // Deliberately out of the tab order. Reachable by script and by a + // pointer, and this check is about the keyboard. A roving tabindex, + // which is the pattern a listbox is built with, is made of exactly + // these. + i = end + continue + case hasTabindex: + case !focusable[name]: + i = end + continue + case name == "a": + if _, has := attr(attrs, "href"); !has { + i = end + continue + } + } + + label, next, err := labelOf(html, name, attrs, end) + if err != nil { + return nil, fmt.Errorf("the element <%s> at byte %d: %w", name, i, err) + } + out = append(out, Control{Tag: name, Label: label}) + i = next + } + return out, nil +} + +// openTag reads the opening tag starting at html[i], returning its lower-case +// name, its attribute text and the index of the closing angle bracket. +func openTag(html string, i int) (name, attrs string, end int, ok bool) { + if i+1 >= len(html) { + return "", "", 0, false + } + if !isNameStart(html[i+1]) { + return "", "", 0, false + } + j := i + 1 + for j < len(html) && isNameByte(html[j]) { + j++ + } + shut := strings.IndexByte(html[j:], '>') + if shut < 0 { + return "", "", 0, false + } + end = j + shut + return strings.ToLower(html[i+1 : j]), html[j:end], end, true +} + +func isNameStart(b byte) bool { + return b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' +} + +func isNameByte(b byte) bool { + return isNameStart(b) || b >= '0' && b <= '9' || b == '-' +} + +// attr reads one attribute out of an opening tag's attribute text. It reads the +// double-quoted form, which is the one .editorconfig and every page in this tree +// use, and treats any other spelling as absent rather than guessing at it. +func attr(attrs, name string) (string, bool) { + want := name + "=\"" + for i := 0; ; { + at := strings.Index(attrs[i:], want) + if at < 0 { + return "", false + } + at += i + // The match has to start a word, so href does not match data-href. + if at > 0 && (isNameByte(attrs[at-1]) || attrs[at-1] == '-') { + i = at + len(want) + continue + } + rest := attrs[at+len(want):] + shut := strings.IndexByte(rest, '"') + if shut < 0 { + return "", false + } + return rest[:shut], true + } +} + +// hasWord reports whether an opening tag's attribute text carries a bare +// attribute of that name, in the boolean form HTML writes one. +// +// It matches the whole word rather than a substring, so aria-disabled is not +// read as disabled. The two mean different things: one takes the control out of +// the tab order and the other only says so to a screen reader, and a control +// carrying the second is still something a keyboard reaches. +func hasWord(attrs, name string) bool { + for _, f := range strings.Fields(attrs) { + if f == name || strings.HasPrefix(f, name+"=") { + return true + } + } + return false +} + +// labelOf reads the text a reader sees on a control, and returns the index to +// carry on scanning from. +func labelOf(html, name, attrs string, end int) (label string, next int, err error) { + if aria, ok := attr(attrs, "aria-label"); ok { + return collapse(aria), end, nil + } + if void[name] { + if v, ok := attr(attrs, "value"); ok { + return collapse(v), end, nil + } + return "", end, nil + } + closing := "': + if depth > 0 { + depth-- + } + default: + if depth == 0 { + b.WriteByte(s[i]) + } + } + } + return b.String() +} + +// collapse turns every run of whitespace into one space and trims the ends, so +// a label that wraps in the source is the label a browser reports. +func collapse(s string) string { return strings.Join(strings.Fields(s), " ") } + +// Missing is what a rendered page failed to put in the tab order: every control +// the markup declares that the browser never gave focus to. +// +// It compares MULTISETS rather than sets, so a page declaring two controls that +// read the same and reaching one of them is a finding rather than a match. +func Missing(declared, reached []Control) []Control { + have := map[Control]int{} + for _, c := range reached { + have[c]++ + } + var out []Control + for _, c := range declared { + if have[c] > 0 { + have[c]-- + continue + } + out = append(out, c) + } + return out +} + +// Extra is the other direction: focus landing somewhere the markup does not +// declare a control. +// +// It is reported rather than ignored because it is how this check goes quietly +// blind. A page that put a div in the tab order with a tabindex the scan above +// does not read would show up here, and treating that as nothing to say would +// leave the comparison passing on a page it no longer describes. +func Extra(declared, reached []Control) []Control { + want := map[Control]int{} + for _, c := range declared { + want[c]++ + } + var out []Control + for _, c := range reached { + if want[c] > 0 { + want[c]-- + continue + } + out = append(out, c) + } + return out +} + +// Format renders a list of controls for a failure message, in a stable order. +func Format(cs []Control) string { + out := make([]string, 0, len(cs)) + for _, c := range cs { + out = append(out, c.String()) + } + sort.Strings(out) + return strings.Join(out, ", ") +} diff --git a/internal/keyboard/keyboard_test.go b/internal/keyboard/keyboard_test.go new file mode 100644 index 0000000..23eb33a --- /dev/null +++ b/internal/keyboard/keyboard_test.go @@ -0,0 +1,175 @@ +package keyboard + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func read(t *testing.T, page string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join("..", "..", filepath.FromSlash(page))) + if err != nil { + t.Fatalf("opening %s: %v", page, err) + } + return string(b) +} + +func TestTheServedPagesDeclareTheControlsAKeyboardHasToReach(t *testing.T) { + // The expectation the browser side is measured against, read here so that a + // scan which quietly stopped finding anything is a failure in the gate + // rather than an empty comparison the harness reports as clean. + want := map[string]int{"docs/index.html": 1, "docs/design-system.html": 14} + for _, page := range Pages { + got, err := Interactive(read(t, page)) + if err != nil { + t.Fatalf("%s: %v", page, err) + } + if len(got) != want[page] { + t.Errorf("%s declares %d control(s), want %d: %s", page, len(got), want[page], Format(got)) + } + for _, c := range got { + if c.Label == "" { + t.Errorf("%s declares a control with no text on it, which the browser side cannot match: %s", page, c) + } + } + } +} + +func TestAControlTakenOutOfTheTabOrderIsNotExpectedInIt(t *testing.T) { + // tabindex="-1" is a deliberate statement that the keyboard does not go + // there. Expecting it anyway would make this check red on a page that is + // right, which is the fastest way to have it switched off. + got, err := Interactive(``) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Label != "Shown" { + t.Fatalf("read %s, want the shown button alone", Format(got)) + } +} + +func TestSomethingPutInTheTabOrderByHandIsExpectedInIt(t *testing.T) { + // The other direction, and it is the one that matters for a design system: + // a div made focusable is a control, and a check reading only the native + // tags would walk past exactly the element most likely to be operable by + // pointer alone. + got, err := Interactive(`
Custom
`) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Tag != "div" || got[0].Label != "Custom" { + t.Fatalf("read %s, want the div", Format(got)) + } +} + +func TestAnAnchorWithNoDestinationIsNotAControl(t *testing.T) { + // An anchor without an href is not in the tab order, so expecting focus on + // every in-page target name would fail on every page that has one. + got, err := Interactive(`Go`) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Label != "Go" { + t.Fatalf("read %s, want the linked anchor alone", Format(got)) + } +} + +func TestAnAttributeNameIsMatchedWholeAndNotAsASuffix(t *testing.T) { + // The one-character mistake in the reader rather than in the page: a + // substring match reads data-href as href, and the anchor above then counts + // as a control that is not in the tab order. + got, err := Interactive(`Not a link`) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("read %s, want nothing", Format(got)) + } +} + +func TestTheLabelIsTheTextAReaderSeesAndNotTheMarkupAroundIt(t *testing.T) { + got, err := Interactive("") + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Label != "The Shaft" { + t.Fatalf("read %s, want the collapsed text", Format(got)) + } +} + +func TestAnExplicitLabelWins(t *testing.T) { + got, err := Interactive(``) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Label != "Close the panel" { + t.Fatalf("read %s, want the explicit label", Format(got)) + } +} + +func TestAControlThatIsNeverClosedIsAnErrorRatherThanASkip(t *testing.T) { + // Unreadable is not a pass. A control whose text this reader could not find + // and silently dropped would be a control the browser side never looks for. + if _, err := Interactive(``) + if err != nil { + t.Fatal(err) + } + if len(declared) != 2 { + t.Fatalf("read %s, want two", Format(declared)) + } + reached := []Control{{Tag: "button", Label: "More"}} + missing := Missing(declared, reached) + if len(missing) != 1 { + t.Fatalf("Missing reported %s, want one of the two", Format(missing)) + } + if extra := Extra(declared, reached); len(extra) != 0 { + t.Fatalf("Extra reported %s over a subset", Format(extra)) + } +} + +func TestFocusLandingSomewhereUndeclaredIsReported(t *testing.T) { + // How this check goes blind. A page that put something in the tab order in a + // shape the reader above does not understand would otherwise pass, because + // nothing declared went unreached. + declared := []Control{{Tag: "button", Label: "Dark"}} + reached := []Control{{Tag: "button", Label: "Dark"}, {Tag: "div", Label: "Surprise"}} + extra := Extra(declared, reached) + if len(extra) != 1 || extra[0].Label != "Surprise" { + t.Fatalf("Extra reported %s, want the undeclared one", Format(extra)) + } + if missing := Missing(declared, reached); len(missing) != 0 { + t.Fatalf("Missing reported %s over a superset", Format(missing)) + } +} + +func TestFormatNamesAControlWithNoTextRatherThanPrintingNothing(t *testing.T) { + got := Format([]Control{{Tag: "input", Label: ""}}) + if !strings.Contains(got, "no text") { + t.Fatalf("a control with no label formatted as %q", got) + } +} + +func TestADisabledControlIsNotExpectedInTheTabOrder(t *testing.T) { + // Disabled is out of the tab order by the specification, so expecting focus + // on it would refuse a page that is right. The near neighbour is the one to + // be careful about: aria-disabled says so to a screen reader and leaves the + // control focusable, so it stays expected. + got, err := Interactive(``) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Label != "Announced" { + t.Fatalf("read %s, want the aria-disabled control alone", Format(got)) + } +}