rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

1//go:build browser
2
3package axe
4
5import (
6 "context"
7 "encoding/json"
8 "errors"
9 "fmt"
10 "sort"
11
12 "github.com/chromedp/cdproto/runtime"
13 "github.com/chromedp/chromedp"
14)
15
16// ErrNothingScanned is Scan's answer to a scan that examined almost
17// nothing. A scan that reached no content reports no violations,
18// which is the one way an accessibility gate goes green while
19// switched off: a selector matching no element, an engine that never
20// reached a document. The count of rules that ran to completion is
21// the proof it did.
22var ErrNothingScanned = errors.New("axe: the scan reached almost no content")
23
24// floor is the least number of axe rules a real scan runs to
25// completion. Five rather than one because one is not a floor: axe
26// scoped to a single element still clears it (measured in rastrillo:
27// one to two rules pass on a lone button), so a context collapsed to
28// one node would look like a scan. Rastrillo's real scans measured 30
29// rules on an index page, 15 on a modal, 13 on a shell and 6 on the
30// smallest preview document, a span of text and nothing else.
31const floor = 5
32
33// Result is what one scan found. Violations are the failures, sorted
34// by rule id. Incomplete is one rule id per rule axe could not decide,
35// a contrast it could not compute, usually: never a failure, and never
36// to be silent either, since it is the seam between the scanned half
37// of the standard and the half a person reviews. Log it.
38type Result struct {
39 Violations []Violation
40 Incomplete []string
41}
42
43// loadedJS is the cheap question asked before the engine is shipped:
44// half a megabyte of source goes across only when the page has none.
45const loadedJS = `typeof window.axe !== "undefined"`
46
47// runJS is the expression that runs the engine and hands back a JSON
48// string. A string rather than an object graph because chromedp has to
49// serialise whatever comes back over CDP, and axe's result carries the
50// whole checked DOM in it; stringifying the fields a failure report
51// needs keeps the payload small enough to be instant.
52//
53// resultTypes: ["violations"] tells axe to stop collecting node detail
54// for passes and incompletes, which is most of the cost of a run; the
55// rule ids still come back, and the count of them is what proves the
56// engine looked at something. An include that matches nothing is
57// reported as zero rules passed rather than left to axe, which throws
58// for it: either way it is a scan of nothing, and ErrNothingScanned
59// is the one name for that.
60const runJS = `(async (include, tags) => {
61 if (typeof window.axe === "undefined") return JSON.stringify({error: "axe did not load"});
62 if (include && document.querySelectorAll(include).length === 0) return JSON.stringify({passed: 0, incomplete: [], violations: []});
63 const target = include ? {include: [include]} : document;
64 const res = await window.axe.run(target, {
65 runOnly: {type: "tag", values: tags},
66 resultTypes: ["violations"],
67 iframes: false,
68 });
69 return JSON.stringify({
70 passed: res.passes.length,
71 incomplete: res.incomplete.map(i => i.id),
72 violations: res.violations.map(v => ({
73 id: v.id,
74 impact: v.impact,
75 help: v.help,
76 nodes: v.nodes.map(n => n.target.map(t => Array.isArray(t) ? t.join(" >> ") : String(t)).join(" >> ")),
77 })),
78 });
79})`
80
81// Scan scans the page ctx is on and returns what it found. include is
82// a CSS selector for the part of the document to scan, or "" for the
83// whole document; tags is the ruleset, or nil for DefaultTags. An
84// engine already on the page is reused; otherwise Script is evaluated
85// there first, once. Fewer than five rules passing, which is what a
86// selector matching nothing or a lone element comes to, is
87// ErrNothingScanned.
88func Scan(ctx context.Context, include string, tags []string) (Result, error) {
89 if tags == nil {
90 tags = defaultTags
91 }
92 var loaded bool
93 if err := chromedp.Run(ctx, chromedp.Evaluate(loadedJS, &loaded)); err != nil {
94 return Result{}, fmt.Errorf("axe: asking the page for its engine: %w", err)
95 }
96 if !loaded {
97 if err := chromedp.Run(ctx, chromedp.Evaluate(script, nil)); err != nil {
98 return Result{}, fmt.Errorf("axe: loading the engine: %w", err)
99 }
100 }
101 var args []string
102 for _, v := range []any{include, tags} {
103 b, err := json.Marshal(v)
104 if err != nil {
105 return Result{}, fmt.Errorf("axe: encoding the run's arguments: %w", err)
106 }
107 args = append(args, string(b))
108 }
109 expr := fmt.Sprintf("%s(%s, %s)", runJS, args[0], args[1])
110 var raw string
111 err := chromedp.Run(ctx, chromedp.Evaluate(expr, &raw,
112 func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }))
113 if err != nil {
114 return Result{}, fmt.Errorf("axe.run: %w", err)
115 }
116 var out struct {
117 Error string `json:"error"`
118 Passed int `json:"passed"`
119 Incomplete []string `json:"incomplete"`
120 Violations []struct {
121 ID string `json:"id"`
122 Impact string `json:"impact"`
123 Help string `json:"help"`
124 Nodes []string `json:"nodes"`
125 } `json:"violations"`
126 }
127 if err := json.Unmarshal([]byte(raw), &out); err != nil {
128 return Result{}, fmt.Errorf("axe: decoding the results: %w (payload begins %.200q)", err, raw)
129 }
130 if out.Error != "" {
131 return Result{}, errors.New("axe: " + out.Error)
132 }
133 if out.Passed < floor {
134 return Result{}, fmt.Errorf("%w: %d rules ran to completion, under the floor of %d", ErrNothingScanned, out.Passed, floor)
135 }
136 res := Result{Violations: make([]Violation, 0, len(out.Violations)), Incomplete: out.Incomplete}
137 for _, v := range out.Violations {
138 res.Violations = append(res.Violations, Violation{ID: v.ID, Impact: v.Impact, Help: v.Help, Nodes: v.Nodes})
139 }
140 sort.Slice(res.Violations, func(i, j int) bool { return res.Violations[i].ID < res.Violations[j].ID })
141 sort.Strings(res.Incomplete)
142 return res, nil
143}
144
145// Run is Scan's violations alone, for a caller that only fails on
146// them. Prefer Scan where the incomplete rules can be logged.
147func Run(ctx context.Context, include string, tags []string) ([]Violation, error) {
148 res, err := Scan(ctx, include, tags)
149 if err != nil {
150 return nil, err
151 }
152 return res.Violations, nil
153}
154