//go:build browser package axe import ( "context" "encoding/json" "errors" "fmt" "sort" "github.com/chromedp/cdproto/runtime" "github.com/chromedp/chromedp" ) // 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 // reached a document. The count of rules that ran to completion is // the proof it did. var ErrNothingScanned = errors.New("axe: the scan reached almost no content") // floor is the least number of axe rules a real scan runs to // completion. Five rather than one because one is not a floor: axe // scoped to a single element still clears it (measured in rastrillo: // one to two rules pass on a lone button), so a context collapsed to // one node would look like a scan. Rastrillo's real scans measured 30 // rules on an index page, 15 on a modal, 13 on a shell and 6 on the // 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 // whole checked DOM in it; stringifying the fields a failure report // needs keeps the payload small enough to be instant. // // 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. 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}, resultTypes: ["violations"], iframes: false, }); return JSON.stringify({ passed: res.passes.length, incomplete: res.incomplete.map(i => i.id), violations: res.violations.map(v => ({ id: v.id, impact: v.impact, help: v.help, nodes: v.nodes.map(n => n.target.map(t => Array.isArray(t) ? t.join(" >> ") : String(t)).join(" >> ")), })), }); })` // 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 } 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{include, tags} { b, err := json.Marshal(v) if err != nil { return Result{}, fmt.Errorf("axe: encoding the run's arguments: %w", err) } args = append(args, string(b)) } 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 Result{}, fmt.Errorf("axe.run: %w", err) } var out struct { Error string `json:"error"` Passed int `json:"passed"` Incomplete []string `json:"incomplete"` Violations []struct { ID string `json:"id"` Impact string `json:"impact"` Help string `json:"help"` Nodes []string `json:"nodes"` } `json:"violations"` } if err := json.Unmarshal([]byte(raw), &out); err != nil { return Result{}, fmt.Errorf("axe: decoding the results: %w (payload begins %.200q)", err, raw) } if out.Error != "" { return Result{}, errors.New("axe: " + out.Error) } if out.Passed < floor { return Result{}, fmt.Errorf("%w: %d rules ran to completion, under the floor of %d", ErrNothingScanned, out.Passed, floor) } res := Result{Violations: make([]Violation, 0, len(out.Violations)), Incomplete: out.Incomplete} for _, v := range out.Violations { 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 } return res.Violations, nil }