Scan returns axe's incomplete rules, loads the engine once, and has a test
Review of Task 3, Minor 1 to 4.
Scan(ctx, include, tags) returns a Result: the violations sorted by
rule id, and Incomplete, one rule id per rule axe could not decide.
SEED's scan logged those on purpose, "never a failure, and never
silent either", and Run decoded them into a field it never read. Run
keeps its signature for Task 13 and is Scan's violations alone. An
include that matches no element is reported from the page as zero
rules passed, so it is ErrNothingScanned like a lone element is,
rather than the exception axe throws for it.
The engine goes across only when a cheap typeof window.axe says the
page has none: half a megabyte once per page instead of once per
scan.
defaultTags is unexported; DefaultTags() returns a copy, so a caller
appending to or reslicing what it gets changes nothing a later scan
reads.
TestAxeScanFindsFloorsAndSorts lives in check/ so no BROWSER_PKGS or
@N change is needed. It plants three failures on one page, each a
rule of its own (html-has-lang, image-alt, label), and holds Scan to
finding them in id order, Run to Scan's list, a selector matching
nothing and a lone button to ErrNothingScanned, and DefaultTags() to
its copy. Green first run. Seen red under three mutations of run.go,
each reverted:
violations: [] in runJS -> "the planted html-has-lang violation
was not reported; got []" (and the
other two, and "0 violation(s)")
const floor = 0 -> "a selector matching nothing gave
<nil>, want ErrNothingScanned" and
"a lone button gave <nil>"
the comparator reversed -> "violations are not sorted by id:
[label image-alt html-has-lang]"
Human
Prompt
Task 3 fix round 1: the review at task-3-review.md, items Important 1 and Minor 1 to 8, as the coordinator ruled them.
From this commit's own message. The prompt ledger has no entries for it — which is what a squashed, rebased or amended commit looks like, and what every commit looks like in a repo without a ledger.
Summary
Adds axe.Scan returning a Result with Violations and Incomplete, makes Run a wrapper over it, ships the engine only when the page has none, maps an include matching nothing to ErrNothingScanned, unexports the default tags behind DefaultTags() returning a copy, and adds TestAxeScanFindsFloorsAndSorts under check/.
Agent notes
DefaultTags is now a function, not a var: Task 13 calls axe.DefaultTags(). Callers should log Result.Incomplete; it is never a failure.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
3 files changed,
+169
−33
check/axe_browser_test.go+101 −0internal/axe/axe.go+7 −2internal/axe/run.go+61 −31
Intent
diff --git a/check/axe_browser_test.go b/check/axe_browser_test.go| new file mode 100644 |
| index 0000000..f1e7253 |
| --- /dev/null |
| +++ b/check/axe_browser_test.go |
| @@ -0,0 +1,101 @@ |
| +//go:build browser |
| + |
| +package check |
| + |
| +import ( |
| + "context" |
| + "errors" |
| + "net/http" |
| + "sort" |
| + "testing" |
| + "time" |
| + |
| + "github.com/chromedp/chromedp" |
| + |
| + "amadan.net/rastrillo/fiesta/internal/axe" |
| + "amadan.net/rastrillo/fiesta/internal/harness" |
| +) |
| + |
| +// axePage is a page with the stylesheets and three planted WCAG |
| +// failures, each a rule of its own: no lang on <html> |
| +// (html-has-lang), an image with no alt (image-alt) and a text input |
| +// with no label (label). Three rather than one so that "sorted by id" |
| +// is a claim about an order and not about a list of one. |
| +const axePage = `<!doctype html><html><head><meta charset="utf-8"><title>axe</title>` + |
| + `<link rel="stylesheet" href="/tokens.css"><link rel="stylesheet" href="/theme.css"></head>` + |
| + `<body><main id="app"><h1>Hello</h1><p>Some text with a <a href="#x">link</a>.</p>` + |
| + `<button rst-btn id="b">Save</button><input type="text" id="unlabelled">` + |
| + `<img id="bad" src="/images/a.svg" width="10" height="10"></main></body></html>` |
| + |
| +// TestAxeScanFindsFloorsAndSorts is the gate's own control, ahead of |
| +// the drives that scan with it: the three planted failures are found |
| +// and come back in id order, Run is Scan's violations, and a scan that |
| +// reaches almost nothing (a selector matching no element, or a lone |
| +// button under the five-rule floor) is ErrNothingScanned rather than |
| +// a clean result. |
| +func TestAxeScanFindsFloorsAndSorts(t *testing.T) { |
| + pages := map[string]string{"/": axePage} |
| + rig := harness.New(t, func(string) http.Handler { return sizingMux(t, pages) }) |
| + ctx, cancel := context.WithTimeout(rig.Context(), 60*time.Second) |
| + defer cancel() |
| + mustRun(t, ctx, chromedp.Navigate(rig.Origin+"/"), chromedp.WaitVisible("#app", chromedp.ByQuery)) |
| + |
| + res, err := axe.Scan(ctx, "", nil) |
| + if err != nil { |
| + t.Fatalf("Scan(whole document): %v", err) |
| + } |
| + if len(res.Incomplete) > 0 { |
| + t.Logf("needs review, axe could not decide: %v", res.Incomplete) |
| + } |
| + ids := make([]string, 0, len(res.Violations)) |
| + for _, v := range res.Violations { |
| + ids = append(ids, v.ID) |
| + if len(v.Nodes) == 0 { |
| + t.Errorf("%s names no node; a failure with no selector is not a report", v.ID) |
| + } |
| + } |
| + for _, want := range []string{"html-has-lang", "image-alt", "label"} { |
| + found := false |
| + for _, id := range ids { |
| + found = found || id == want |
| + } |
| + if !found { |
| + t.Errorf("the planted %s violation was not reported; got %v", want, ids) |
| + } |
| + } |
| + if len(ids) < 2 { |
| + t.Fatalf("%d violation(s) reported; an order of fewer than two proves nothing", len(ids)) |
| + } |
| + if !sort.StringsAreSorted(ids) { |
| + t.Errorf("violations are not sorted by id: %v", ids) |
| + } |
| + |
| + vs, err := axe.Run(ctx, "", nil) |
| + if err != nil { |
| + t.Fatalf("Run(whole document): %v", err) |
| + } |
| + if len(vs) != len(res.Violations) { |
| + t.Errorf("Run reports %d violations, Scan %d", len(vs), len(res.Violations)) |
| + } |
| + for i := range vs { |
| + if i < len(res.Violations) && vs[i].ID != res.Violations[i].ID { |
| + t.Errorf("Run's violation %d is %s, Scan's is %s", i, vs[i].ID, res.Violations[i].ID) |
| + } |
| + } |
| + |
| + // A scan of nothing is ErrNothingScanned, by either route to it. |
| + if _, err := axe.Scan(ctx, "#no-such-element", nil); !errors.Is(err, axe.ErrNothingScanned) { |
| + t.Errorf("a selector matching nothing gave %v, want ErrNothingScanned", err) |
| + } |
| + if _, err := axe.Scan(ctx, "#b", nil); !errors.Is(err, axe.ErrNothingScanned) { |
| + t.Errorf("a lone button gave %v, want ErrNothingScanned (under the five-rule floor)", err) |
| + } |
| + |
| + // The caller's copy of the default ruleset is its own. |
| + tags := axe.DefaultTags() |
| + tags[0] = "best-practice" |
| + if got := axe.DefaultTags(); got[0] != "wcag2a" { |
| + t.Errorf("DefaultTags() hands out its own backing array; a caller changed it to %v", got) |
| + } |
| + rig.Screen("#app", "the axe page") |
| +} |
diff --git a/internal/axe/axe.go b/internal/axe/axe.go| index 5603faa..a6d8bff 100644 |
| --- a/internal/axe/axe.go |
| +++ b/internal/axe/axe.go |
| @@ -25,7 +25,7 @@ var script string |
| // page Run scans. |
| func Script() string { return script } |
| -// DefaultTags is the ruleset Run uses when handed no tags, and it is |
| +// defaultTags is the ruleset Scan uses when handed no tags, and it is |
| // not negotiable downwards. WCAG 2.2 AA is cumulative: 2.2 AA means |
| // every A and AA criterion from 2.0, 2.1 and 2.2, so all five tags |
| // have to be named or the older half of the standard silently stops |
| @@ -35,7 +35,12 @@ func Script() string { return script } |
| // are useful advice and they are not the standard the question asked |
| // about; mixing them in would make a failure mean two different |
| // things. |
| -var DefaultTags = []string{"wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"} |
| +var defaultTags = []string{"wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"} |
| + |
| +// DefaultTags returns the ruleset Scan uses when handed no tags, as a |
| +// copy: a caller that appends to or reslices what it gets changes |
| +// nothing a later scan reads. |
| +func DefaultTags() []string { return append([]string(nil), defaultTags...) } |
| // Violation is one rule that found something: its id, axe's impact |
| // word, the one-line help text, and a selector for every node it |
diff --git a/internal/axe/run.go b/internal/axe/run.go| index 5779e04..ccf2bcb 100644 |
| --- a/internal/axe/run.go |
| +++ b/internal/axe/run.go |
| @@ -13,7 +13,7 @@ import ( |
| "github.com/chromedp/chromedp" |
| ) |
| -// ErrNothingScanned is Run's answer to a scan that examined almost |
| +// ErrNothingScanned is Scan's answer to a scan that examined almost |
| // nothing. A scan that reached no content reports no violations, |
| // which is the one way an accessibility gate goes green while |
| // switched off: a selector matching no element, an engine that never |
| @@ -30,6 +30,20 @@ var ErrNothingScanned = errors.New("axe: the scan reached almost no content") |
| // smallest preview document, a span of text and nothing else. |
| const floor = 5 |
| +// Result is what one scan found. Violations are the failures, sorted |
| +// by rule id. Incomplete is one rule id per rule axe could not decide, |
| +// a contrast it could not compute, usually: never a failure, and never |
| +// to be silent either, since it is the seam between the scanned half |
| +// of the standard and the half a person reviews. Log it. |
| +type Result struct { |
| + Violations []Violation |
| + Incomplete []string |
| +} |
| + |
| +// loadedJS is the cheap question asked before the engine is shipped: |
| +// half a megabyte of source goes across only when the page has none. |
| +const loadedJS = `typeof window.axe !== "undefined"` |
| + |
| // runJS is the expression that runs the engine and hands back a JSON |
| // string. A string rather than an object graph because chromedp has to |
| // serialise whatever comes back over CDP, and axe's result carries the |
| @@ -39,14 +53,13 @@ const floor = 5 |
| // resultTypes: ["violations"] tells axe to stop collecting node detail |
| // for passes and incompletes, which is most of the cost of a run; the |
| // rule ids still come back, and the count of them is what proves the |
| -// engine looked at something. |
| -// |
| -// The engine is injected first if the page has none: the source goes |
| -// across as a JSON string and is evaluated in the page, so nothing in |
| -// half a megabyte of engine needs escaping by hand. |
| -const runJS = `(async (source, include, tags) => { |
| - if (typeof window.axe === "undefined") (0, eval)(source); |
| +// engine looked at something. An include that matches nothing is |
| +// reported as zero rules passed rather than left to axe, which throws |
| +// for it: either way it is a scan of nothing, and ErrNothingScanned |
| +// is the one name for that. |
| +const runJS = `(async (include, tags) => { |
| if (typeof window.axe === "undefined") return JSON.stringify({error: "axe did not load"}); |
| + if (include && document.querySelectorAll(include).length === 0) return JSON.stringify({passed: 0, incomplete: [], violations: []}); |
| const target = include ? {include: [include]} : document; |
| const res = await window.axe.run(target, { |
| runOnly: {type: "tag", values: tags}, |
| @@ -65,34 +78,40 @@ const runJS = `(async (source, include, tags) => { |
| }); |
| })` |
| -// Run scans the page ctx is on and returns what it found, sorted by |
| -// rule id. include is a CSS selector for the part of the document to |
| -// scan, or "" for the whole document; tags is the ruleset, or nil for |
| -// DefaultTags. An engine already on the page is reused, otherwise |
| -// Script is evaluated there first. |
| -// |
| -// Incomplete results (axe saying a human has to look, a contrast it |
| -// could not compute) are never a failure and are not returned; a |
| -// drive that wants them logged reads them with its own expression. |
| -// Fewer than five rules passing is ErrNothingScanned. |
| -func Run(ctx context.Context, include string, tags []string) ([]Violation, error) { |
| +// Scan scans the page ctx is on and returns what it found. include is |
| +// a CSS selector for the part of the document to scan, or "" for the |
| +// whole document; tags is the ruleset, or nil for DefaultTags. An |
| +// engine already on the page is reused; otherwise Script is evaluated |
| +// there first, once. Fewer than five rules passing, which is what a |
| +// selector matching nothing or a lone element comes to, is |
| +// ErrNothingScanned. |
| +func Scan(ctx context.Context, include string, tags []string) (Result, error) { |
| if tags == nil { |
| - tags = DefaultTags |
| + tags = defaultTags |
| + } |
| + var loaded bool |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(loadedJS, &loaded)); err != nil { |
| + return Result{}, fmt.Errorf("axe: asking the page for its engine: %w", err) |
| + } |
| + if !loaded { |
| + if err := chromedp.Run(ctx, chromedp.Evaluate(script, nil)); err != nil { |
| + return Result{}, fmt.Errorf("axe: loading the engine: %w", err) |
| + } |
| } |
| var args []string |
| - for _, v := range []any{script, include, tags} { |
| + for _, v := range []any{include, tags} { |
| b, err := json.Marshal(v) |
| if err != nil { |
| - return nil, fmt.Errorf("axe: encoding the run's arguments: %w", err) |
| + return Result{}, fmt.Errorf("axe: encoding the run's arguments: %w", err) |
| } |
| args = append(args, string(b)) |
| } |
| - expr := fmt.Sprintf("%s(%s, %s, %s)", runJS, args[0], args[1], args[2]) |
| + expr := fmt.Sprintf("%s(%s, %s)", runJS, args[0], args[1]) |
| var raw string |
| err := chromedp.Run(ctx, chromedp.Evaluate(expr, &raw, |
| func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) })) |
| if err != nil { |
| - return nil, fmt.Errorf("axe.run: %w", err) |
| + return Result{}, fmt.Errorf("axe.run: %w", err) |
| } |
| var out struct { |
| Error string `json:"error"` |
| @@ -106,18 +125,29 @@ func Run(ctx context.Context, include string, tags []string) ([]Violation, error |
| } `json:"violations"` |
| } |
| if err := json.Unmarshal([]byte(raw), &out); err != nil { |
| - return nil, fmt.Errorf("axe: decoding the results: %w (payload begins %.200q)", err, raw) |
| + return Result{}, fmt.Errorf("axe: decoding the results: %w (payload begins %.200q)", err, raw) |
| } |
| if out.Error != "" { |
| - return nil, errors.New("axe: " + out.Error) |
| + return Result{}, errors.New("axe: " + out.Error) |
| } |
| if out.Passed < floor { |
| - return nil, fmt.Errorf("%w: %d rules ran to completion, under the floor of %d", ErrNothingScanned, out.Passed, floor) |
| + return Result{}, fmt.Errorf("%w: %d rules ran to completion, under the floor of %d", ErrNothingScanned, out.Passed, floor) |
| } |
| - vs := make([]Violation, 0, len(out.Violations)) |
| + res := Result{Violations: make([]Violation, 0, len(out.Violations)), Incomplete: out.Incomplete} |
| for _, v := range out.Violations { |
| - vs = append(vs, Violation{ID: v.ID, Impact: v.Impact, Help: v.Help, Nodes: v.Nodes}) |
| + res.Violations = append(res.Violations, Violation{ID: v.ID, Impact: v.Impact, Help: v.Help, Nodes: v.Nodes}) |
| + } |
| + sort.Slice(res.Violations, func(i, j int) bool { return res.Violations[i].ID < res.Violations[j].ID }) |
| + sort.Strings(res.Incomplete) |
| + return res, nil |
| +} |
| + |
| +// Run is Scan's violations alone, for a caller that only fails on |
| +// them. Prefer Scan where the incomplete rules can be logged. |
| +func Run(ctx context.Context, include string, tags []string) ([]Violation, error) { |
| + res, err := Scan(ctx, include, tags) |
| + if err != nil { |
| + return nil, err |
| } |
| - sort.Slice(vs, func(i, j int) bool { return vs[i].ID < vs[j].ID }) |
| - return vs, nil |
| + return res.Violations, nil |
| } |