Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -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
34 changes: 34 additions & 0 deletions .github/workflows/harness.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/design-system.html
Original file line number Diff line number Diff line change
Expand Up @@ -299,7 +299,7 @@ <h3 style="margin-bottom:10px">Phone &middot; from 35 cm</h3>
<div class="eyebrow">The tile</div>
<h2>Three states, and the third one decides</h2>
</div>
<div class="shelf">
<div class="shelf" tabindex="0" role="group" aria-label="Tile examples">
<div class="tile"><span class="art a1"></span><div class="tname">The Shaft</div><div class="tsub">2019 &middot; 1 hr 34</div></div>
<div class="tile"><span class="art a2"></span><div class="tname">Northern Light</div><div class="tsub">2021 &middot; 2 hr 06</div></div>
<div class="tile"><span class="art hash"></span><div class="tname">Cold Coast</div><div class="tsub">loading &hellip;</div></div>
Expand Down Expand Up @@ -332,7 +332,7 @@ <h2>The only place with colour in it</h2>
<div class="tv">
<p class="hint">Move with <kbd>&larr;</kbd> <kbd>&rarr;</kbd>, or click. Switch the colour
vision preset above and look at the same focus again.</p>
<div class="frow" id="frow" role="listbox" aria-label="Example row">
<div class="frow" id="frow" role="listbox" aria-label="Example row" tabindex="0">
<button class="fcell" role="option" aria-selected="true"><span class="fart a1"></span><span class="fcap">The Shaft</span></button>
<button class="fcell" role="option" aria-selected="false"><span class="fart a2"></span><span class="fcap">Northern Light</span></button>
<button class="fcell" role="option" aria-selected="false"><span class="fart a3"></span><span class="fcap">Cold Coast</span></button>
Expand Down
12 changes: 12 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
21 changes: 21 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
15 changes: 11 additions & 4 deletions internal/format/format.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions internal/format/format_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
223 changes: 223 additions & 0 deletions internal/keyboard/browser_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading
Loading