| 1 | //go:build browser |
| 2 | |
| 3 | package axe |
| 4 | |
| 5 | import ( |
| 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. |
| 22 | var 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. |
| 31 | const 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. |
| 38 | type 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. |
| 45 | const 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. |
| 60 | const 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. |
| 88 | func 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. |
| 147 | func 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 | |