Engine options
Every runner (runDomRulesInPage, runa11yCoreInPage) takes the same four arguments: (pageUrl, contextSelector, engineOptions, runOnly). This page documents engineOptions and runOnly in full. To check a page against a WCAG version and level, start with runOnly.wcag.
Selecting which rules run
Name what you are testing against, and the engine picks the rules. With no selection, the default rules run: every rule except the opt-in rules of a standard or a pack, which run only once unlocked (see Opt-in rules).
| To run | Pass |
|---|---|
| The default rules: every rule except opt-in ones not unlocked | nothing: runOnly null |
| A WCAG conformance target: the rules for the criteria of a version at a level and below | runOnly: { wcag: { version: '2.2', level: 'AA' } } |
| The same, and the best-practice rules, which test no WCAG criterion | runOnly: { wcag: { version: '2.2', level: 'AA' }, bestPractices: true } |
| The best-practice rules only | runOnly: { bestPractices: true } |
| A WCAG version and level by name | engineOptions: { profile: 'wcag21-aa' } |
| A standard built on WCAG, from a pack (EN 301 549) | engineOptions: { packs: [en301549], profile: 'en301549-v4.1.1' } |
| The same, and the best-practice rules | engineOptions: { packs: [en301549], profile: 'en301549-v4.1.1' }, runOnly: { bestPractices: true } |
// WCAG 2.2 AA, with the best practices:runDomRulesInPage(url, null, {}, {wcag: { version: '2.2', level: 'AA' },bestPractices: true});
Every rule either tests WCAG criteria or is a best practice, so { wcag: { version: '2.2', level: 'AAA' }, bestPractices: true } runs every rule but those for 4.1.1 Parsing, which WCAG 2.2 removed. For anything finer, such as a single criterion, a list of rules, or an exclusion, see "Advanced selection: tags and rule ids"; those fields combine with the ones above. A scan that names rules reports the criteria they check too ("Which criteria a scan reports").
Selecting by WCAG target (runOnly.wcag)
runOnly.wcag names a WCAG version and conformance level, and selects the rules for that target's criteria:
// WCAG 2.2 Level A and AA:runDomRulesInPage(url, null, {}, { wcag: { version: '2.2', level: 'AA' } });
versionis'2.0','2.1'or'2.2';levelis'A','AA'or'AAA'(in any case).- A rule is selected when one of the criteria it names is part of the target: in force in that version (introduced in it or before, and not removed in it or before), at the target's level or below, by the level the criterion has in that version. The levels come from
WCAG_CRITERIAin@surea11y/core/wcag, which has a level per version. - So 4.1.1 Parsing, which 2.2 removed, is part of
{ version: '2.1' }targets and not of{ version: '2.2' }ones:duplicate-idruns for the first and not for the second, where the version tags would run it. - A rule that names no criterion (a best-practice rule) is not part of any target. Add those with
bestPractices: true(next section), or single rules withtagsorincludeRuleIds: besidewcag, all of them select rules as well, the union of the target's rules and what the others select.excludeTags,excludeRuleIdsandexcludeTestIdsapply after, as always. - The target's version is the run's WCAG target (
engine.wcagVersion), unlessengineOptions.wcagVersionis set; its level decides which WCAG rollups are reported, as a level tag does, unlessengineOptions.wcagLevelis set ("Giving the level of a scan that names checks"). - A target includes rules, so it selects them instead of
engineOptions.profile, astagsdo.getProfileWcagTarget(profile)gives the target a profile comes to (see "Conformance profiles"), andgetChecksForRunOnly(runOnly, engineOptions)lists the rules arunOnlywithwcagruns, andgetRulesForRunOnly(runOnly, engineOptions)the rollups it reports. - Anything else throws
INVALID_RUN_ONLY, naming what is wrong:runOnly.wcag.version must be "2.0", "2.1" or "2.2", not "3.0".
// WCAG 2.1 AA, plus the best-practice rules, without one of them:{ wcag: { version: '2.1', level: 'AA' }, tags: ['best-practice'], excludeRuleIds: ['region'] }
Adding the best practices (runOnly.bestPractices)
bestPractices: true selects the best-practice rules: the rules that name no WCAG criterion, such as region, landmark-one-main, heading-order, page-has-heading-one, skip-link, tabindex and image-redundant-alt. They are the rules tagged best-practice, so it is the same as adding that tag. A finding from one of them is good practice, not a WCAG failure: no WCAG rollup counts it. Most report cantTell; one whose finding is itself a barrier reports fail, as empty-heading does for a heading with no name.
- Beside
wcag,tagsor rule ids, it adds those rules to what they select; excludes apply after, soexcludeRuleIds: ['region']still leavesregionout. - On its own it selects the best-practice rules only.
false, or leaving it out, adds nothing. On its own,{ bestPractices: false }is an empty selection, so the default rules run, as with{}.- Any other value throws
INVALID_RUN_ONLY:runOnly.bestPractices must be true or false, not "yes". - Beside
engineOptions.profile, it adds the best-practice rules to the profile's, and the profile still applies: its rules, its rollups, its mappings andengine.profile, as without it.{ bestPractices: true }withprofile: 'en301549-v4.1.1'runs that standard and the best practices. The best-practice rules count in none of the profile's rollups, as they test no WCAG criterion.
// WCAG 2.1 AA and the best practices, without one of them:{ wcag: { version: '2.1', level: 'AA' }, bestPractices: true, excludeRuleIds: ['region'] }
Conformance profiles
engineOptions.profile names a conformance target instead of spelling out its tag set. Each WCAG version has a profile per level, named wcag<version>-<level>:
| Profile | Runs the rules tagged | WCAG target | getProfileWcagTarget | Why |
|---|---|---|---|---|
wcag20-a | wcag2a | 2.0 | { version: '2.0', level: 'A' } | WCAG 2.0 Level A |
wcag20-aa | wcag2a, wcag2aa | 2.0 | { version: '2.0', level: 'AA' } | WCAG 2.0 Level A and AA |
wcag20-aaa | wcag2a, wcag2aa, wcag2aaa | 2.0 | { version: '2.0', level: 'AAA' } | WCAG 2.0 Level A, AA and AAA |
wcag21-a | wcag2a, wcag21a | 2.1 | { version: '2.1', level: 'A' } | WCAG 2.1 Level A |
wcag21-aa | wcag2a, wcag2aa, wcag21a, wcag21aa | 2.1 | { version: '2.1', level: 'AA' } | WCAG 2.1 Level A and AA |
wcag21-aaa | the wcag21-aa tags and wcag2aaa, wcag21aaa | 2.1 | { version: '2.1', level: 'AAA' } | WCAG 2.1 Level A, AA and AAA |
wcag22-a | wcag2a, wcag21a, wcag22a | 2.2 | { version: '2.2', level: 'A' } | WCAG 2.2 Level A |
wcag22-aa | wcag2a, wcag2aa, wcag21a, wcag21aa, wcag22a, wcag22aa | 2.2 | { version: '2.2', level: 'AA' } | WCAG 2.2 Level A and AA |
wcag22-aaa | the wcag22-aa tags and wcag2aaa, wcag21aaa, wcag22aaa | 2.2 | { version: '2.2', level: 'AAA' } | WCAG 2.2 Level A, AA and AAA |
en301549-v4.1.1 | same as wcag22-aa | 2.2 | { version: '2.2', level: 'AA' } | Deprecated since 1.13.0, removed in 2.0.0: use @surea11y/pack-en301549, or wcag22-aa for WCAG alone. EN 301 549 V4.1.1 chapter 9 restates WCAG 2.2 A and AA |
en301549-v3.2.1 | same as wcag21-aa | 2.1 | { version: '2.1', level: 'AA' } | Deprecated since 1.13.0, removed in 2.0.0: use @surea11y/pack-en301549, or wcag21-aa for WCAG alone (which counts 4.1.1 Parsing as satisfied, as WCAG 2.1's errata do). EN 301 549 V3.2.1 chapter 9 restates WCAG 2.1 A and AA, including 4.1.1 Parsing |
section508 | same as wcag20-aa | 2.0 | { version: '2.0', level: 'AA' } | Deprecated since 1.16.0, removed in 2.0.0: use wcag20-aa, which runs the same rules and gives the same results. A scan that names it says so once per process |
getProfileWcagTarget(profile) gives the runOnly.wcag target a profile's tag set comes to, or null for a name that is no profile. Each WCAG level includes the ones below it, as WCAG's levels do: wcag22-aaa runs every rule of wcag22-aa and adds the AAA ones, so a scan names one level, not several. wcag22-a and wcag22-aaa are new in core 1.15.0, the WCAG 2.0 and 2.1 profiles in 1.16.0: each runs what runOnly: { wcag: { version, level } } runs, and unlike runOnly can be scanned beside a pack's profile. A profile still selects by its tags, as in the table: under wcag22-aa (and wcag22-a and wcag22-aaa), duplicate-id (4.1.1) runs and cannot fail, while { wcag: getProfileWcagTarget('wcag22-aa') } leaves it out.
runDomRulesInPage(url, null, { profile: 'wcag21-aa' }, null);
en301549-v4.1.1 and en301549-v3.2.1 move to EN 301 549's pack, @surea11y/pack-en301549, and leave core in 2.0.0. They work as before, with one console.warn per process. Given the pack (packs: [en301549]), a scan takes the same names from it and shows no notice; the pack's profiles report a result per clause in place of the WCAG rollups. section508 leaves core in 2.0.0 too, deprecated since 1.16.0: it was only WCAG 2.0 A and AA under another name, so use wcag20-aa, which runs the same rules. See how core deprecates a standard.
Core's own profiles are WCAG's: once the deprecated ones go in 2.0.0, every other standard's profile comes from a pack, whose standard or checklist brings its own profiles to this list.
Several profiles at once
profile also takes a list, with the packs that bring them in packs: one scan then judges the page by WCAG and by WAI-ARIA, by EN 301 549 and your own policy, or by two versions of one standard while you move from one to the other. Each profile judges the page as a scan of it alone would, and the result reads as one profile with the rules of all: a check's outcome is the worst its profiles give it, and each finding's verdicts say what each profile makes of it (Output schema). Core 1.15.0 and later.
const waiAria = require('@surea11y/pack-wai-aria');const result = runDomRulesInPage(url, null, {packs: [waiAria],profile: ['wcag22-aa', 'wai-aria-1.2']});// <div role="group" aria-checked="true">: an attribute the role doesn't// support, a question for WCAG 4.1.2 and a failure of WAI-ARIA.result.checksResults.find((c) => c.ruleId === 'aria-allowed-attr').verdicts;// { 'wcag22-aa': 'cantTell', 'wai-aria-1.2': 'fail' }
Each profile is scanned in turn, on the same page, so the scan takes about as long as one per profile. A profile that doesn't apply (no such profile, its pack skipped, or a runOnly include choosing the rules instead) is listed in engine.profilesNotRun with the reason, and the others still run; SARIF, JUnit, the CLI and the test matchers treat such a scan as an error, since it judged less than it was asked to. A list of one is its one name, and scans exactly as before. The WAI-ARIA pack is the case it was built for.
The WCAG target follows from the tags the same way it does for a hand-written set (see "Filtering by WCAG version" below), so en301549-v3.2.1 targets WCAG 2.1 and en301549-v4.1.1 WCAG 2.2. A duplicate id can still fail under en301549-v3.2.1 because its profile keeps 4.1.1's text (keepsParsing, see below); under en301549-v4.1.1 it cannot. A pack's profile can name its version instead, with wcagVersion (Packs): a profile that runs only the rules its standard maps has no WCAG tags to imply one. Names are matched case-insensitively. A run that used a profile reports it back as engine.profile.
Precedence: anything that includes rules selects them instead of the profile — a runOnly with wcag, tags, includeRuleIds or includeTestIds, or an include in engineOptions.rules/.tags/.tests. Excludes still apply on top of the profile, whether they come from runOnly (excludeTags, excludeRuleIds, excludeTestIds, so a binding's disableTags() narrows the profile rather than replacing it) or, when there is no runOnly filter, from engineOptions (tags.exclude, rules.exclude, tests.exclude), and an explicit engineOptions.wcagVersion still wins over the version the profile implies or names. A profile that does not take effect — an unknown name, or one overridden as above — is not an error: the run proceeds as if none was given, logs a console.warn saying why, and carries no engine.profile. runOnly.bestPractices: true is not an include of that kind: it adds the best-practice rules to the profile's, and the profile applies ("Adding the best practices").
A standard's profile may also leave rules out, when its standard waives a WCAG criterion or replaces a WCAG check with its own: the rules it names, the rules whose every criterion it waives, and the waived criteria's WCAG rollups do not run, and the rule catalog leaves them out too. A rule that also checks a criterion the profile keeps still runs (link-name-present, which checks 2.4.4 and 4.1.2, under a profile waiving 2.4.4), and engine.profileExcludes names what was left out. No built-in profile excludes anything today.
A profile only chooses which rules run. It says nothing about whether passing them meets the standard it is named after: most Success Criteria need human judgement no automated rule covers (see what this engine cannot tell you).
An EN 301 549 profile also switches on the EN 301 549 clauses of the version it targets, as if mappings: ['en301549:V4.1.1'] (or V3.2.1) had been passed; see "Other standards" below. Any standard's profile switches on its own version the same way. A profile with mappedRules also runs, whatever their tags, the rules its standard is mapped to: the ones with no WCAG mapping, such as heading-order and skip-link, have no WCAG tag to be selected by. A standard with requirements of its own, or an organisation's checklist, comes to the engine as a pack, bringing its own profile names, rules and rollups. The engine ships no such standard itself.
Opt-in rules
Some rules check a standard's own requirements, ones WCAG does not make: a national standard may require a doctype, or forbid presentational attributes such as bgcolor. A fail from such a rule is a failure of that standard, not of WCAG, so these rules are off by default. Each carries its standard's rule tag and runs only when the selection asks for it:
- through the standard's profile, which lists the tag,
- by that tag (
tags: { include: '<tag>' }, alone or with others), - by its id (
rules: { include: '<rule id>' }, orrunOnly.includeRuleIds), - by the id of one of its standard's own rollups that groups it (
rules: { include: '<rollup id>' }runs that rollup's rules, opt-in or not), or - by unlocking it with
engineOptions.optInRules, below.
Nothing else selects one: not a default run, not a WCAG tag set, not a WCAG or EN 301 549 profile, not a WCAG rollup id. Excludes apply to them as to any rule. Selecting the tag alone runs that standard's own rules, not the WCAG rules it is also mapped to, so it answers "what does the standard require beyond WCAG" and is not an audit against it. For an audit, use the standard's profile: under a tag alone, its own rollups report cantTell for any requirement whose rules did not run. A rule added through customRules that carries the tag follows the same gate. None of the engine's built-in standards has opt-in rules today; a pack's namespace tag works this way.
Running every rule (optInRules)
To run every rule the engine has, whatever standard it belongs to, leave out the profile and unlock the opt-in rules:
runDomRulesInPage(url, null, { optInRules: 'all' }, null);
'all' unlocks every standard's opt-in rules; a list of tags unlocks only those. It lifts the gate and nothing else: the rest of the selection still decides which rules run. So with no filter and no profile, as above, the run covers every rule and every standard's rollups; under profile: 'wcag22-aa' it still runs the WCAG rules only, since opt-in rules carry no WCAG tag. Excludes apply as usual. A tag that is not an opt-in tag is ignored with a console.warn. Only this engine option unlocks: runOnly has no optInTags key, so one given there is an unknown key, which throws INVALID_RUN_ONLY on its own and is ignored with a warning beside keys runOnly reads, the message pointing to engineOptions.optInRules.
This is for seeing everything the engine can report, during development or when exploring a site. It is not a conformance target: such a run fails a page for things WCAG allows and only another standard forbids. The result says so: engine.optInRules lists the tags whose rules ran, and the HTML report, SARIF and JUnit show it next to the profile. It adds no other standard's numbers to results; pass mappings for that, for example { optInRules: 'all', mappings: ['<key>'] }.
Other standards (mappings)
Every result's meta.normativeMappings names the WCAG Success Criteria it tests. engineOptions.mappings adds the requirement of another standard that corresponds to each one. By default it adds none: a clause of a standard you do not audit against is noise in every SARIF tag, JUnit property and report.
| Value | Adds |
|---|---|
'en301549' | The EN 301 549 chapter 9 clause for each criterion, in every version that has it (V3.2.1 and V4.1.1) |
'en301549:V3.2.1', 'en301549:V4.1.1' | The same, for that version only |
runDomRulesInPage(url, null, { mappings: ['en301549:V3.2.1'] }, null);
The en301549 mappings move to EN 301 549's pack, @surea11y/pack-en301549, and leave core in 2.0.0. They work as before, with one console.warn per process. Given the pack, mappings: ['en301549'] takes the same name and gives every result the same entries, with no notice.
It takes an array or a comma-separated string, names and versions matched case-insensitively. Under profile: 'en301549-v4.1.1' the V4.1.1 clauses are there without asking, and mappings can add more. A name or version the engine has no table for is ignored with a console.warn. A run that carries any other standard reports which in engine.mappings, canonically spelled (["en301549:V3.2.1"], or ["en301549"] for every version).
It changes only what a result names, never which rules run or their outcomes. The rule catalog follows the same option: getChecksCatalog(engineOptions), getCheckDefById(ruleId, engineOptions), getChecksForRunOnly(runOnly, engineOptions), getRulesCatalog(engineOptions), getRulesForRunOnly(runOnly, engineOptions) and getCompositeRuleById(id, engineOptions) name the standards a scan with those options would, a profile included when it would apply, so a catalog entry and a result always agree. With no options they name WCAG only. A rule added through customRules keeps exactly the mappings it declares. What the clauses are and how the two versions differ is on WCAG mapping.
Advanced selection: tags and rule ids
The fields below select by the tags rules carry and by rule ids. Reach for them for what a WCAG target and bestPractices don't say: one criterion (tags: ['wcag1412']), a list of rules, an exclusion, or a tag set of your own. They combine with wcag and bestPractices: what they include is added to what those select, and what they exclude is taken away after.
There are two independent ways to give them — the 4th argument (runOnly), or engineOptions.rules/.tags/.tests/.includeMode. If runOnly contains any filter, it wins outright; otherwise the engine falls back to engineOptions. Don't mix them expecting both to apply — pick one. engineOptions.rules, .tags and .tests take { include, exclude }; a bare list or string is the include list.
Via runOnly (4th argument)
runDomRulesInPage(url, null, {}, {includeRuleIds: ['img-alt-present', 'button-name-present'],excludeRuleIds: ['region'],tags: ['wcag412'],excludeTags: ['best-practice'],includeMode: 'and' // 'and' (default) | 'or' — see below});
A bare array or string works as shorthand: runOnly: ['img-alt-present', 'button-name-present'] runs those rules (a composite id brings in its rules), and runOnly: ['wcag2a', 'wcag2aa'] is the same as { tags: ['wcag2a', 'wcag2aa'] }. An array can't mix rule ids and tags, and every value has to name a rule (built-in, composite or one of engineOptions.customRules) or a tag: anything else throws, so a typo can't quietly run every rule or none. Use the object form to combine ids and tags or to exclude. Before 1.10.0 a bare array was ignored and every rule ran.
| Field | Type | Meaning |
|---|---|---|
wcag | { version, level } | A WCAG conformance target: the rules for the criteria of that version at that level and below. See Selecting by WCAG target, above. |
bestPractices | boolean | true adds the best-practice rules. See Adding the best practices, above. |
includeRuleIds | string[] | Only run these rule IDs (plus, for a composite ID, its child atomic rules). |
excludeRuleIds | string[] | Never run these, applied after include. |
includeTestIds / excludeTestIds | string[] | Same matching as above — kept as a separate field because rules are internally called "tests" (the atomic executable unit); functionally identical to includeRuleIds/excludeRuleIds today. |
tags | string[] | Only run rules carrying at least one of these tags (e.g. wcag412, wcag2aa, best-practice). |
excludeTags | string[] | Never run rules carrying any of these tags, applied after include. |
includeMode | 'and' | 'or' | When both an ID include and a tag include are given: 'and' (default) requires a rule to satisfy both; 'or' runs a rule if it satisfies either. Irrelevant if you only use one dimension. |
Each of these accepts either an array or a comma-separated string, matching the engineOptions form below — includeRuleIds: 'img-alt-present, button-name-present' and includeRuleIds: ['img-alt-present', 'button-name-present'] are equivalent.
Names are checked, here and in engineOptions.rules/.tags below. An include list (includeRuleIds, includeTestIds, tags, the legacy values, rules.include, tags.include) in which no value names a rule or a tag (one a rule or a rollup carries, so a standard's tag is known though only its rollups carry it) throws, with code: 'INVALID_RUN_ONLY' and a message naming the field and the values, since it would select nothing and a run of no rules reads as a clean pass. A selection whose names all exist but whose parts cancel each other (a rule both included and excluded, rules and tags with no rule in common under includeMode: 'and') also runs no rule: the scan warns, naming the parts, and throws INVALID_RUN_ONLY under strictOptions. includeMode is 'and' or 'or'; another value is read as 'and', with a warning, and throws under strictOptions; in a browser binding, .includeMode('or') sets it. A value that names nothing beside ones that do, or in an exclude list, is ignored with a console.warn. Before 1.10.0 every such value was ignored silently.
The nine WCAG version/level tags, wcag2a to wcag22aaa, are always known, whether or not a rule carries one. Some carry none today (wcag21aaa, wcag22a, wcag22aaa): their criteria have no automated rule, which is not a typo. In an include list beside other values, such a tag is noted with a console.info, not warned about:
[surea11y] runOnly.tags: no rules for WCAG 2.2 Level A ("wcag22a"); its criteria need manual review.
engineOptions.logUntestedWcag: false leaves that line out. When every value of an include list is such a tag, nothing would run, so it still throws INVALID_RUN_ONLY, saying why. Beside a runOnly.wcag target or bestPractices: true, which select rules of their own, it is only noted. In an exclude list it is left alone.
Rule IDs are bare (no engine prefix), e.g. 'img-alt-present'. For backward compatibility, matching also accepts a legacy a11ycore--prefixed form of the same id ('a11ycore-img-alt-present'). Ids are matched in any case, as tags are: 'IMG-ALT-PRESENT' selects img-alt-present, in every list that names rules (runOnly in each form, engineOptions.rules and engineOptions.tests, include and exclude). No two rules have ids that differ only in case, so this can't select the wrong one.
The { type, values } shape is also accepted as the whole runOnly value: { type: 'tag', values: ['wcag2a', 'wcag2aa'] } is { tags: ['wcag2a', 'wcag2aa'] }, and { type: 'rule', values: ['img-alt-present'] } is { includeRuleIds: ['img-alt-present'] } ('tags' and 'rules' work too). values may be a comma-separated string, its names are checked like any rule ids or tags, and the other keys beside it (excludeTags, excludeRuleIds...) still apply. A Set of names is read as the list it holds. A key the object form doesn't read, beside ones it does, is ignored with a warning. Any other type throws INVALID_RUN_ONLY, and so does a runOnly that is a number or a boolean, or an object none of whose keys the engine reads ({ includeRuleId: [...] }): each used to run every rule. An empty array, string or object still means the default selection.
Which criteria a scan reports
A scan reports a criterion's rollup (rulesResults) when the selection selects it: a WCAG target or a version/level tag, a profile, the rollup's own id, or no selection at all. It also reports the rollup of each criterion that a rule the selection named on its own checks, when that rule ran:
- by its id (
includeRuleIds,includeTestIds,rules.include, or a profile's own rule list), - by a tag that is not a WCAG tag (a custom rule's, a pack's or a standard's; WCAG's tags are the version/level tags, the criterion tags such as
wcag244, andwcag22-removed, so a custom tag such aswcagteamis not one), - by
optInRules, or - as a best-practice rule (
bestPractices).
A rule a standard's profile runs only because its standard maps it (mappedRules) is named for that standard, not on its own: it adds the standard's requirements and none of its WCAG criteria. So a profile that runs only what its standard asks for reports that standard alone (Packs); a rule its rules list names adds its WCAG criteria too. Before 1.12.0, a scan that named rules rather than criteria had an empty rulesResults.
// The unnamed link fails link-name-present, and with it 2.4.4 and 4.1.2:runDomRulesInPage(url, null, {}, { includeRuleIds: ['link-name-present'] });// rulesResults: wcag-2.4.4-link-purpose-in-context: fail, wcag-4.1.2-name: fail
- The verdict is the rollup's own. A failing rule fails the criterion. A rule that passes doesn't make it pass when the scan left out the criterion's other rules: it is
cantTell, withreasonCodecomposite.rollup.cantTell.notSelectedChild, and each rule the scan didn't select is listed among the contributors asmissingwithnotSelected: true. With all of them selected and passing, a WCAG criterion is stillcantTell(composite.rollup.cantTell.partialCheck), since no criterion's rules yet check all it asks (see WCAG conformance mapping). The HTML report shows "Partly checked: 1 of 2 checks was not selected for this scan" under it. See the output schema. - A criterion tag names its own criterion.
tags: ['wcag244']reports 2.4.4, and not the other criteria its rules check (4.1.2 forlink-name-present). A rule brought in by a rollup's id, a WCAG target, a version or level tag, or a WCAG profile adds no criterion: the selection names the criteria there. So a scan that selects its criteria reports what it reported before. - The run's WCAG version and level apply. A criterion is added only if it is in force in
engine.wcagVersion(duplicate-idalone adds no 4.1.1 under 2.2, and does underwcagVersion: '2.1'), and at or below the run's level, as a target would:engineOptions.wcagLevelwhen given (next section), else the level the selection gives (runOnly.wcag.level, or the version/level tags beside the rule ids). With neither, every level is reported. - Excludes apply. A rollup the selection excludes (
excludeRuleIds: ['wcag-4.1.2-name'], or a tag) is not added. A rule the caller excluded counts as not selected. A rule a profile excludes narrows the criterion: it ismissingwithoutnotSelected, and a criterion the profile waives is never added. - A standard's own rollups (one per requirement) are added the same way, but only while the standard is in play: the applied profile targets it, or the selection names its tag or
optInRulesunlocks it. A WCAG scan never shows the requirements of a standard nobody asked for. A profile that didn't apply, andmappingsalone, don't put a standard in play. Under its profile, only the profile's version is added; unlocked without a profile, every version is, as a scan selected by its tag reports them. This holds for a standard or checklist from a pack as for one built in: a checklist profile that names its rules (tags: [],rules: [...]) gets their WCAG criteria and its own items. - The list before the scan.
getRulesForRunOnly(runOnly, engineOptions)lists the rollups a scan with that selection and those options reports, the added ones included, asgetRulesCatalogentries. It works them out with the scan's own rollup step, so the two agree;getChecksForRunOnlylists the rules.getRulesCatalog(engineOptions)lists every rollup the options make available, whatever the selection. A tool can record what a scan checks before running it; the browser bindings' builders give the same lists asplannedChecks()andplannedCriteria().
Giving the level of a scan that names checks (wcagLevel)
A scan meant as WCAG 2.2 AA that runs only some checks names them, and gives the version in engineOptions.wcagVersion. That says no level, so the criteria of those checks are reported at every level: contrast-computable checks 1.4.3 (AA) and 1.4.6 (AAA), and both are reported. engineOptions.wcagLevel gives the level:
// Only the contrast checks of a WCAG 2.2 AA scan: 1.4.3 is reported, 1.4.6 is not.runDomRulesInPage(url, null,{ wcagVersion: '2.2', wcagLevel: 'AA' },{ includeRuleIds: ['contrast-computable'] });
- Values.
'A','AA'or'AAA', in any case. Another value is ignored with aconsole.warn(engineOptions.wcagLevel: ignoring "AA+"; use "A", "AA" or "AAA". ...), and throwsINVALID_ENGINE_OPTIONSunderstrictOptions. The run reports a valid level asengine.wcagLevel, written'A','AA'or'AAA'; without the option,engine.wcagLevelis the level the selection implies, and absent when it implies none. - It decides what is reported, never what runs. The selection decides what runs, as without it. A check above the level that the selection names still runs and reports its own result; only its criteria above the level are left out of
rulesResults. - It applies to every WCAG rollup, not only the criteria added for named checks.
wcagLevel: 'AA'on a full scan, a profile or a tag selection leaves the AAA criteria out ofrulesResults, while their checks still run and report inchecksResults. This differs fromwcagVersion, which never removes a rollup the selection selects: a 2.2 scan still reports 4.1.1, with its rules coerced tocantTell. A scan without the option reports what it reported before. - It wins over the level the selection implies (
runOnly.wcag.level, or the highest level its version/level tags name), as an explicitwcagVersionwins over the version they imply. A conflict between the two is not warned about, as one betweenwcagVersionand a target's version isn't;engine.wcagLevelshows the level the criteria were gated by. - A rollup named by id is reported as named.
includeRuleIds: ['wcag-1.4.6-contrast-enhanced']withwcagLevel: 'AA'reports 1.4.6: the option says which criteria to report, not to drop one the caller asked for. A level the selection's own tags imply still gates a named rollup, as it does without the option. - A standard's own rollups have no WCAG level and are not gated by it. A pack's profile can't give a level (its tags imply one); its caller can pass
wcagLevel. getRulesForRunOnly(runOnly, engineOptions)honors it, so it lists what the scan reports, and warns about an invalid one as the scan does, or throws understrictOptions.
Without the option, version/level tags beside the rule ids give a level too, since the default includeMode: 'and' keeps the named rules that carry one of them: { includeRuleIds: ['contrast-computable'], tags: wcagTags('2.2') } runs contrast-computable and reports 1.4.3 alone. But the tags also decide what runs: a named rule with no WCAG tag (a best-practice rule such as region, a custom rule without WCAG tags), or one that checks only AAA criteria, is left out of the run, and a version/level tag no rule carries (wcag22a) is noted with a console.info. wcagLevel gives the level without changing what runs. In a browser binding, the builder's .wcagLevel('AA') sets it.
Filtering by WCAG version (2.0, 2.1, 2.2)
Every rule and composite carries exactly one WCAG-version-origin level tag: wcag2a/wcag2aa/wcag2aaa for a Success Criterion that's WCAG 2.0 baseline, wcag21a/wcag21aa/wcag21aaa for one newly introduced in WCAG 2.1 (e.g. 1.3.5 Identify Input Purpose), wcag22a/wcag22aa/wcag22aaa for one newly introduced in WCAG 2.2 (e.g. 2.5.8 Target Size Minimum). A rule gets only the tag for its SC's actual origin version — a 2.1-introduced SC is never also tagged wcag2aa, since it doesn't exist under a WCAG 2.0 conformance target.
runOnly.wcag selects a conformance target in one field (above), and leaves out 4.1.1 under 2.2. With tags, since versions are cumulative (2.1 = 2.0 + new; 2.2 = 2.0 + 2.1 + new), select a WCAG-version conformance target by combining tag sets — the engine's OR-matching on tags (any one match includes the rule) does the rest:
// WCAG 2.0 AA only (excludes every 2.1/2.2-introduced SC, even at level AA):{ tags: ['wcag2a', 'wcag2aa'] }// WCAG 2.1 AA conformance (2.0 baseline + everything 2.1 added, both at A and AA):{ tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] }// WCAG 2.2 AA conformance (2.0 baseline + 2.1 additions + 2.2 additions):{ tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'] }// Just the SCs 2.2 introduced, nothing else:{ tags: ['wcag22a', 'wcag22aa', 'wcag22aaa'] }
One SC goes the other way. WCAG 2.2 removed SC 4.1.1 Parsing — the only criterion ever dropped rather than added. A rule mapped to it carries its 2.0-origin tag (wcag2a) like any other baseline rule, plus wcag22-removed, and the version tag sets above therefore include it under a 2.2 target, where it does not belong.
You do not have to do anything about that. The engine resolves a target WCAG version for every run and, when that target is 2.2, a wcag22-removed rule cannot report fail: it still runs, still reports every occurrence it found, but its outcome is coerced to cantTell and the result carries a wcagVersionScope field saying why (see the output schema). Nothing is silently dropped, and a 2.2 run is not gated by a criterion 2.2 does not contain.
Under a 2.0 or 2.1 target it is the same: W3C's 2023 errata to WCAG 2.0 and 2.1 count 4.1.1 as always satisfied for content in HTML or XML, so the rule asks rather than fails, and wcagVersionScope.by is "errata". The one exception is a profile whose standard keeps 4.1.1's text, such as en301549-v3.2.1 (keepsParsing in the standard's profile, see Packs): a duplicate id can still fail there. Core 1.14.0 and later; before, a 2.0 or 2.1 target let it fail.
The target version is resolved in this order:
engineOptions.wcagVersion—'2.0','2.1'or'2.2', if you set it.- The
wcagVersionof the pack profile the run applies, if it names one: a standard built on WCAG 2.1 whose profile runs only the rules it maps selects no WCAG tag, so it says the version itself. It wins over the version the profile's own tags imply. A profile that doesn't take effect (overridden by a selection of your own) brings no version. - The version of a
runOnly.wcagtarget. - The version-origin tags in your own filter: a set topping out at
wcag21a/wcag21aareads as a 2.1 target, one containing anywcag22*tag as 2.2, one with onlywcag2*tags as 2.0. Only those nine tags count — an SC tag (wcag411) orbest-practicesays nothing about a version. - Otherwise
'2.2', this engine's default target.
// Nothing to declare: a plain run already targets 2.2, so a duplicate id// comes back cantTell rather than fail.runDomRulesInPage(url, null, {}, null);// Conformance-testing against 2.1, where SC 4.1.1 still exists:runDomRulesInPage(url, null, { wcagVersion: '2.1' }, null);// Same thing, implied by the tag set — no extra option needed:runDomRulesInPage(url, null, {}, { tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'] });
The resolved target is reported back on every result as engine.wcagVersion, so you can confirm which one a run actually used.
If you would rather not see the rule at all under 2.2, exclude it outright — the tag is still there for exactly that:
// WCAG 2.2 AA conformance, with the removed criterion left out entirely:{tags: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'wcag22a', 'wcag22aa'],excludeTags: ['wcag22-removed']}
duplicate-id is the only rule carrying that tag today. Left in, it still reports something real — a duplicate id breaks <label for>, fragment links and getElementById whatever the standard says — it just is not a 2.2 conformance failure.
Via engineOptions (no runOnly)
Same filtering, expressed as comma-separated strings (or arrays) nested in engineOptions:
runDomRulesInPage(url, null, {rules: { include: 'img-alt-present, button-name-present', exclude: 'region' },tags: { include: 'wcag412', exclude: 'best-practice' },includeMode: 'and'}, null);
rules.include/.exclude, tags.include/.exclude, tests.include/.exclude (alias of rules), and top-level includeMode mirror the runOnly fields above exactly. Comma-separated strings are trimmed, de-duplicated, and empty tokens dropped automatically. A bare list or string, such as rules: ['img-alt-present'], is read as the include list.
engineOptions — the rest
const engineOptions = {strictOptions: false, // default false — true throws on an unknown option or a value of the wrong typelocale: 'en', // default 'en'; de-DE falls back to de, then to en per stringwcagVersion: '2.2', // default '2.2' — the conformance targetwcagLevel: 'AA', // optional — the level of the criteria reportedprofile: 'wcag22-aa', // optional — a named conformance target, or a list of themmappings: ['en301549'], // optional — standards besides WCAG to name on each resultoptInRules: 'all', // optional — also run rules other standards add beyond WCAGmessages: { de: { /* key: text */ } }, // optional caller-supplied dictionaries; win over built-in onesincludeHiddenElements: false, // default false — set true to evaluate hidden/collapsed subtrees tooincludeShadowDom: true, // default true — opt OUT with false to skip open shadow rootsfragment: false, // default false — set true when the scan target isn't a real pageembeddedAriaHidden: false, // default false — a frame's document shown under aria-hidden="true"embeddedInert: false, // default false — a frame's document shown under [inert]topLevelDocument: true, // optional — unset, a document shown in a frame is no page of its ownassumeModal: false, // default false — under jsdom, take an open <dialog aria-modal="true"> as modalexcludeSelectors: ['#cookie-banner', '.third-party-widget'], // array or comma-separated stringstyleSheetTexts: [{ href: 'https://cdn.example/site.css', text: '/* its CSS */' }], // sheets the page can't readtimestamp: '2026-07-20T12:00:00Z', // optional — engine has no built-in clockperfStats: false, // default false — internal timing counters, debug-only shapeprofileRules: false, // default false — per-rule timings; needs perfStatscontrast: {mode: 'strictConformance', // 'strictConformance' (default) | 'auditorAssist'rootCanvasFallback: '#ffffff', // a canvas to measure on in place of the page's own; unset, the page's color scheme gives itrootCanvasFallbackSet: true // echoed on each result: whether rootCanvasFallback was given; read back, false means the default},visibilityMode: undefined, // 'styleOnly' | 'styleAndGeometry'; unset: geometry with a layout, else stylespolicyContract: 'a11y', // 'a11y' (default) | 'generic' | inline contract objectpolicy: { // optional overrides on top of policyContractcoerceManualFailToCantTell: true},output: {includeSelector: true, // set false to suppress auto-filled selectorsincludeHtml: true,detail: 'full' // 'findings': pass/notApplicable results keep only rule, outcome, type},rules: {'some-rule-id': {excludeSelectors: ['.some-noisy-widget'] // narrows candidates for THIS rule only}},probes: { /* optional host-supplied evidence */ },customRules: [ /* runtime-registered rules, see below */ ],packs: [ /* rules and standards from outside core, see Packs */ ],// Only read by runa11yCoreAcrossFrames. Ignored by runDomRulesInPage/runa11yCoreInPage.pingWaitTime: 500, // ms to wait for a child frame to answer a pingframeWaitTime: 60000 // ms to wait for a child frame's full scan result};
| Option | Meaning |
|---|---|
strictOptions | Default false. true checks every option before the scan, and throws INVALID_ENGINE_OPTIONS on a key the engine doesn't read or a value of the wrong type or outside its set; see "Catching mistakes in the options" below. |
locale | Any string. A code with a subtag falls back to its base language first, so de-DE uses de; failing that, English. Individual strings then fall back the same way (chosen locale → en → the rule's literal English text), so a partly-translated locale never produces missing text. All of that is silent in the strings themselves, so the result reports what actually happened in engine.locale — check it if you need to know whether you got the language you asked for. See Locales. |
wcagVersion | '2.0', '2.1' or '2.2' — which version of WCAG the run is conformance-testing against. Defaults to the version the run's pack profile names (its wcagVersion), else to whatever your version-origin tags imply, and to '2.2' when they imply nothing. What it changes is which criteria a scan reports and, for SC 4.1.1 Parsing, why a rule tagged wcag22-removed cannot fail: removed in 2.2, and always satisfied in 2.0 and 2.1 by their errata, except under a profile whose standard keeps it, such as en301549-v3.2.1 — see "Filtering by WCAG version" above. Any other value, of any type (the number 2.1, '3.0'), is ignored with a console.warn naming the version the run targets instead. |
wcagLevel | Optional: 'A', 'AA' or 'AAA', in any case — the WCAG level of the criteria a run reports. WCAG rollups above it are left out of rulesResults while their rules still run; it never changes what runs. It wins over the level the selection implies; a rollup named by id is reported as named. Reported back as engine.wcagLevel, which without the option holds the level the selection implies, if any. Any other value is ignored with a console.warn, and throws under strictOptions. See "Giving the level of a scan that names checks" above. |
profile | Optional named conformance target: 'wcag20-a', 'wcag20-aa', 'wcag20-aaa', 'wcag21-a', 'wcag21-aa', 'wcag21-aaa', 'wcag22-a', 'wcag22-aa', 'wcag22-aaa', the deprecated 'en301549-v4.1.1', 'en301549-v3.2.1' and 'section508', or a pack's own; or a list of them, for a scan of several profiles at once. Selects rules by the matching tag set when nothing else includes any, and the WCAG target follows from those tags. Reported back as engine.profile when it took effect (for a list, the first that ran, with every one in engine.profiles); otherwise ignored with a console warning. A name no profile has throws INVALID_ENGINE_OPTIONS under strictOptions, in a list too, with a message naming the profiles there are (also in the error's knownProfiles): without a profile, the scan would run every rule. See "Conformance profiles" above. |
mappings | Optional standards besides WCAG whose requirements meta.normativeMappings names: 'en301549', 'en301549:V3.2.1' or 'en301549:V4.1.1', or a pack's standard's key, as an array or comma-separated string. Default none, so results name WCAG only; an EN 301 549 profile adds its own version. Reported back as engine.mappings when any applies. See "Other standards" above. |
logUntestedWcag | Default true. false leaves out the console.info note for a WCAG version/level tag no rule carries (wcag22a), in an include list beside others; see "Via runOnly" above. |
optInRules | Optional: 'all', or a list of opt-in rule tags, as an array or comma-separated string. Unlocks those rules outside their standard's profile; the rest of the selection still decides what runs. Reported back as engine.optInRules when it ran a rule the selection would not have run otherwise; unknown tags are ignored with a console warning. See "Running every rule" above. |
messages | Optional { [locale]: { key: text } }. Checked before the engine's own tables, so it can override individual strings or supply a language the build does not carry. Keys you omit fall back normally, so a partial override is fine. This is how the standalone browser bundle receives a locale side file, and it is the only way to get a dictionary into a page context, since the in-page runner is serialized and cannot read files. It is not echoed back on each result's engineOptions, unlike the other options; engine.locale reports the dictionary used. See Locales. |
includeHiddenElements | Default false: helper queries exclude elements hidden by structural/CSS mechanisms such as display:none, [hidden], closed <details> (everything but its first <summary>, at any depth), content-visibility:hidden and hidden="until-found" content, content in an [inert] subtree (which rules about how content looks, such as contrast and text spacing, still judge, since it is drawn), and hidden rendering-only host elements (with descendants excluded too). runa11yCoreAcrossFrames does not scan a frame that is hidden this way. Set true to include those hidden/collapsed subtrees in evaluation (legacy/static-markup behavior). It widens what the shared queries return; most rules then judge only what is in the accessibility tree, since a hidden element has no name or role to check, so for them it changes nothing. It matters for rules about markup itself, such as a broken ID reference. |
includeShadowDom | Default true: rules using helpers.queryAllSmart traverse into open shadow roots. Set false to scan only the light DOM. Closed shadow roots are never reachable either way — no DOM API exposes them: an element whose name could come from a custom element with no content the engine can read (a button, link, menu item, option, tab, tree item, summary or tooltip) is asked about (cantTell, name_closedContent), not failed. |
fragment | Default false. A handful of rules check for the presence of a property that exists once per real page — page-title-present, html-lang-attr-present, html-xml-lang-mismatch, aria-hidden-body, css-orientation-lock, meta-refresh-no-exceptions, meta-refresh-timing-absent, meta-viewport-zoom-enabled, meta-viewport-large, page-title-patterns, region, bypass-blocks-present, landmark-one-main, page-has-heading-one — and correctly report notApplicable for these once contextSelector has scoped a run narrower than the whole document (document.documentElement no longer among the resolved roots), since a scoped subtree was never expected to carry its own <title>/<html lang>/etc. Set fragment: true for the case that scoping alone can't detect: a scan target that's the whole given document but was never meant to represent a real page at all (e.g. a raw component snippet parsed on its own) — this forces the same notApplicable gating even when unscoped. A document shown in a frame is no page of its own either: page-title-present, page-title-patterns, html-lang-attr-present, meta-viewport-zoom-enabled, meta-viewport-large, region, bypass-blocks-present, landmark-one-main and page-has-heading-one are notApplicable in it whatever this option says, unless topLevelDocument: true (below) says it is the page. Core 1.15.0 and later. |
embeddedAriaHidden | Default false. true says the document is shown in a frame under aria-hidden="true" (on the <iframe> or an element around it): every element is read as aria-hidden content is, out of the accessibility tree but for what overrides that. Rules about names and roles (img-alt-present, button-name-present, …) then don't apply, and those about what is seen and operated (contrast, focus, keyboard) still do. runa11yCoreAcrossFrames sets it for such a frame, and a binding that scans frames itself sets it in that frame's engineOptions. The focus such a frame takes is the top page's aria-hidden-focus finding on the <iframe>. Core 1.15.0 and later. |
embeddedInert | Default false. true says the document is shown in a frame under [inert]: every element is read as inert content is, out of the accessibility tree and taking no focus, so only the rules about how content looks (contrast, text spacing) judge it. runa11yCoreAcrossFrames sets it for such a frame, and leaves out a frame behind an open modal dialog, as it leaves out a hidden one; a binding that scans frames itself does the same. Core 1.15.0 and later. |
topLevelDocument | Unset by default: whether the document is a page of its own. Unset, it is when it is the top window's: a document shown in a frame is no page of its own, and the nine rules about the page the fragment row names are notApplicable in it. A test runner that shows the page under test in a frame of its own, as Cypress does, sets it to true (@surea11y/cypress does it for you); false reads a top-level document as a frame's. runa11yCoreAcrossFrames doesn't pass it to the frames it scans. Core 1.15.0 and later. |
assumeModal | Default false. Only a <dialog> opened with showModal() (one that matches :modal) is modal: the browser makes the rest of the page inert, and the engine leaves that page out of every rule and counts nothing in it as focusable. A dialog opened with the open attribute or show() is not modal, even with aria-modal="true", so the same markup gets the same verdicts in jsdom and in a browser. jsdom can't open a modal dialog (it has no showModal()), so a test there that stands in for showModal() by setting open and aria-modal="true" on the dialog sets assumeModal: true: such a dialog is then taken as modal, as releases before 1.16.0 took it under jsdom. A DOM that can open a modal dialog (any browser) ignores it. See Known limitations. |
excludeSelectors | Elements matching any of these selectors (and their descendants, including what is in their shadow roots, and the documents of frames among them) are skipped entirely, for every rule — useful for cookie banners, third-party embeds, or known-noisy widgets you don't control. To exclude something from just one specific rule instead, use rules[ruleId].excludeSelectors below. An array of selectors, or a string holding a selector list: '#cookie-banner, img:not(.a, .b)' is the same as ['#cookie-banner', 'img:not(.a, .b)'], since the string is cut only at the commas between selectors. Each selector applies on its own: one that isn't valid CSS excludes nothing, with a console.warn, and the others still apply. |
styleSheetTexts | The text of style sheets the page can't read, [{ href, text }]: a sheet from another origin, by its URL, and its CSS. The rules that read the CSS (css-orientation-lock, css-hidden-focus, css-focus-indicator-suppressed) read it in place of the sheet. Each result's engineOptions echoes it as the sheets' hrefs, [{ href }], without their text. preloadStyleSheets() fetches the ones a server shares from inside the page, and runa11yCoreInPageAsync fetches them before scanning; a tool driving the browser can fetch the others from outside it. See "Style sheets from another origin" in core's integration guide on GitHub. Core 1.14.0 and later. |
timestamp | Passed straight through to the result's top-level timestamp field; the engine does not generate one itself (deterministic-by-design). A value that isn't a string (a Date, a number) is ignored with a console.warn; pass new Date().toISOString(). |
contrast.mode | strictConformance (default): contrast rules stay silent (notApplicable/skip) whenever the true rendered background isn't confidently computable, to protect against false fails. auditorAssist: trades some of that safety margin for more findings, intended for a human auditor who will double-check flagged cases, not for unattended CI gating. A page that paints no opaque background is not such a case: the canvas behind it follows from the page's own color scheme, so its text is measured in either mode without a guess. Where the page's canvas can't be known (in a frame, under forced colors), auditorAssist measures text on contrast.rootCanvasFallback, recorded as ROOT_CANVAS_FALLBACK. |
contrast.rootCanvasFallback | A canvas color for a page that paints no opaque background. Unset, the contrast rules measure such a page's text on the canvas the browser paints behind it, in either mode: the canvas of the color scheme the page is drawn in, which its own color-scheme (on the root, or the <meta> form) decides. A page that declares none is drawn in light, on white, whatever the user prefers; light dark is drawn on white for a user in light and on the dark canvas for one in dark, and its text is measured in both schemes, failing where it fails in either (data.details.colorScheme names the one). Under jsdom, which resolves no system color, the engine uses Chromium's colors, and text whose colors it can't work out there is asked about (COLOR_SCHEME_NOT_RENDERED). Set, white included, the color stands for the page's canvas in every scheme: the text is measured on it once, recorded as an assumption (ROOT_CANVAS_FALLBACK). The page's canvas can't be known in a frame, whose canvas can let the parent page show through, or under forced colors: there contrast-computable asks and the ratio rules leave the text out, unless auditorAssist measures it on this color. Each result's engineOptions.contrast echoes the color used, white when none was given. |
contrast.rootCanvasFallbackSet | Echoed on each result: true when contrast.rootCanvasFallback is the caller's, false for the default white (also when the given value wasn't a usable color). It isn't needed as an input: options passed back from a result's echo are read as they were, so false keeps the echoed white the default. A value that isn't true or false is warned about and read as not given. Core 1.14.0 and later. |
visibilityMode | Controls how strict the three contrast rules (contrast-minimum, contrast-enhanced, contrast-computable) are about deciding a text node is actually eligible to check. Not read by any other rule. Unset (the default), it is 'styleAndGeometry' where the page has a layout, which is any browser scan, and 'styleOnly' where it has none (jsdom): a reader only sees text that is drawn somewhere visible, which only the layout can tell. 'styleOnly': eligibility is CSS-only — display, visibility, opacity, ancestor-hiding, etc. 'styleAndGeometry': adds real layout checks (getClientRects()/getBoundingClientRect()) on top of that — text with no client rects, or zero width/height, is excluded too, and so is text entirely above or left of the page, where no scrolling reaches (the left: -9999px technique), and text an ancestor with overflow: hidden or clip cuts off completely (height: 0; overflow: hidden, a carousel slide out of view). A box that scrolls (auto, scroll) does not exclude its text, since a reader can reach it. The options of a <select> are judged by their <select>'s place on the page. In both modes, text with a font size of 0 or a fully transparent color is excluded, since nothing is drawn; transparent text that lets a background show through its glyphs (background-clip: text, on it or an ancestor) is not: it is measured in that background's color when it is solid, and asked about when it is a gradient or an image. Set 'styleOnly' in a browser to check text from computed styles alone, as earlier releases did; under plain jsdom (runDomRulesInPage) there's no real layout engine, so 'styleAndGeometry' mostly just adds getBoundingClientRect() zero-size checks, not true clipping/overflow detection — see known limitations. |
policyContract | See POLICY.md — controls which outcomes/confidence values are allowed and whether manual rules' would-be fails get coerced to cantTell. |
output.includeSelector / .includeHtml | Suppresses the engine's automatic selector/html fill-in. Since every rule was migrated to report its element rather than build occurrences by hand (1.5.0), that fill-in is the path almost all rules take: setting includeSelector: false / includeHtml: false strips selectors and HTML snippets from nearly every rule. A handful still assemble those fields themselves inside runInPage and are unaffected — among them contrast-minimum/contrast-enhanced (whose findings are text runs, not elements), page-title-present and identical-links-same-purpose. So this narrows output substantially but is still not a guarantee of no selectors or HTML anywhere in the result. |
output.detail | 'full' (default) or 'findings': a smaller result, keeping whole only the check results that report something (fail, cantTell, and any listing occurrences); every other pass and notApplicable keeps its ruleId, outcome and type, and its margin or error if it has one. Rollups and verdicts are unchanged, and the reporters read it as a full result. A rule core's catalog couldn't fill back in stays whole: every rule of a scan with packs, a custom rule, and one the scan's messages reword. See engine.outputDetail; in TypeScript, such a result is a CompactScanResult. |
rules[ruleId] | Passed through to that rule as ctx.config, and — for excludeSelectors specifically — read by the engine itself before the rule ever runs. See "Rule-scoped excludeSelectors" below. Any other key is passthrough only: no shipped rule takes anything else from it. A rule's declared settings, such as contrast-minimum's thresholds, are its standard's, so a caller's value for one is dropped: a result naming WCAG 1.4.3 is always decided at 4.5:1. A standard with other thresholds has its own rule, a variant, which a pack can bring. A key is matched as a rule id is, in any case or with the legacy a11ycore- prefix; one naming no rule of the scan is warned about with the closest id. |
packs | Optional packs from @surea11y/core/pack: rules, variants of core's rules, a standard or a checklist and messages from outside core, which join the scan. A list: the pack objects for runDomRulesInPage and the catalog functions in Node, and for runa11yCoreInPage the name@version of packs a packScript registered in the page. Anything else runs no pack, with a warning, and throws under strictOptions, with code: 'INVALID_ENGINE_OPTIONS'. Reported back as engine.packs. See Packs. |
probes | An optional, JSON-safe evidence object your host application supplies, for what a scan of one page cannot see, such as the site's other pages. The engine caps it before rules read it (ctx.inputs.probes): six levels deep, 200 items per array, 50 keys per object, 2,000 characters per string. crawl.pageTitles ({ pages: [{ url, title }] }) is read by page-title-patterns, to look for generic and templated titles across a site. A pack's rules may read probes of their own, which the pack documents. |
perfStats / profileRules | Debug-only. perfStats: true returns internal counters on the result's perfStats field; profileRules: true additionally adds a per-rule timing breakdown there. profileRules on its own does nothing — perfStats is what creates the object the breakdown lives in. Shape is not part of the stable output contract — don't build on it. Note also that profileRules is the one option that makes output non-deterministic: counters are stable across identical runs, wall-clock timings are not. Leave it off if you diff results between runs. With profileRules, the shared caches (computed styles and accessibility-tree eligibility) are filled before the first rule and timed apart as perfStats.warmUpMs, so their cost isn't charged to whichever rule walks the page first. |
pingWaitTime / frameWaitTime | Only read by runa11yCoreAcrossFrames (see INTEGRATION.md) — how long to wait for a child frame to answer a ping (default 500ms) and a full run request (default 60000ms) before treating it as unreachable. Ignored by runDomRulesInPage/runa11yCoreInPage. |
Catching mistakes in the options (strictOptions)
An option the engine doesn't read is passed through: a custom rule can read settings of its own from ctx.engineOptions, and an option removed in a later release doesn't break a scan. So by default a misspelt option is ignored, and the scan runs as if it weren't there. Two things say so:
- Always, a key one or two letters from a known one (in any case) is warned about, nested keys included:
lcoalegets[surea11y] engineOptions: unknown option "lcoale" (did you mean "locale"?); ignored. - Always, a value of the wrong type is warned about and read as not given, as is an
engineOptionsthat isn't an object. On/off options (includeShadowDom,includeHiddenElements,fragment,perfStats,profileRules,output.includeHtml,output.includeSelector) taketrueorfalse, or'true','false','1','0',1and0as a command line spells them:perfStats: 'false'is off. - With
strictOptions: true(or'true','1'or1), every option is checked before the scan, and any mistake throws an error withcode: 'INVALID_ENGINE_OPTIONS', naming each one, withproblemslisting them as{ path, kind, message, suggestion }: an unknown key (kind: 'unknown'), inengineOptionsor in one of its objects, and a value of the wrong type or outside its set (kind: 'invalid'), such asincludeHiddenElements: 'yes'orcontrast.mode: 'strict'.
runDomRulesInPage(url, null, { strictOptions: true, lcoale: 'de' }, null);// Error: engineOptions: unknown option "lcoale" (did you mean "locale"?). (strictOptions)
The catalog functions that take engineOptions (getChecksCatalog, getRulesCatalog, getCheckDefById, getCompositeRuleById, getChecksForRunOnly, getRulesForRunOnly, getTestsForRunOnly) check them the same way, in the same words: getRulesForRunOnly(runOnly, { wcagLevel: 'AA+' }) warns, reads the level as not given, and lists what a scan with those options reports; with strictOptions: true it throws INVALID_ENGINE_OPTIONS, as the scan does. A selection that cancels itself (a rule both included and excluded, parts with no rule in common) is said so as the scan says it: a warning, and INVALID_RUN_ONLY under strictOptions.
Not checked: a rule's own settings (rules[ruleId]), the keys of messages, and probes, which the engine passes on unread; and the custom rules, which have checks of their own (skippedCustomRules). In TypeScript, StrictEngineOptions is the same set of options without the index signature that lets any key compile, so a misspelt option is an error there too.
Rule-scoped excludeSelectors
The top-level excludeSelectors applies to every rule — there's no way to exclude an element from just one rule while still running every other rule against it. rules[ruleId].excludeSelectors fills that gap: it narrows candidates for that one rule only, on top of (never instead of) the global list.
const engineOptions = {excludeSelectors: ['#cookie-banner'], // applies to every rule, as alwaysrules: {'aria-required-children': {excludeSelectors: ['mat-select', 'mat-stepper', 'mat-horizontal-stepper', 'mat-vertical-stepper']},'aria-allowed-attr': {excludeSelectors: ['mat-progress-spinner']}}};
Why you'd want this: Angular Material's <mat-select> builds its internal ARIA structure in a way that trips a false positive on aria-required-children specifically, even though the component is otherwise fine. With only the global excludeSelectors, the only way to silence that false positive is excludeSelectors: ['mat-select'] — which also hides mat-select from every other rule, including contrast-minimum and aria-allowed-attr, silently dropping real coverage those checks never had a problem with. The example above keeps mat-select fully visible to every rule except the one that misfires on it.
Effective exclusions for a given rule are the union of the global list and that rule's own list — an element matching either is dropped from that rule's candidates. A rule whose only would-be-failing elements are all excluded this way reports outcome: 'pass' or 'notApplicable' (matching that rule's own no-candidates convention), with occurrences: [] — never outcome: 'fail' with an empty occurrences array, since a built-in rule's fail always names what failed. (A rule that throws is a different case: it reports cantTell with error set, see the output schema.)
Accepts the same forms as the global option: an array (['mat-select', 'mat-stepper']) or a comma-separated string ('mat-select, mat-stepper').
If you're using a binding package (@surea11y/binding-base and its Playwright/Puppeteer wrappers), check that binding's own README for whether its .exclude() builder method has a rule-scoped form yet — this is an engineOptions shape documented here at the engine level; not every binding has picked it up.
Recipes — composing options for real scenarios
CI gate: WCAG 2.2 AA only, ignore a third-party widget you don't control
runDomRulesInPage(url, null, {excludeSelectors: ['#cookie-banner', '.intercom-launcher']}, { wcag: { version: '2.2', level: 'AA' } });
Add bestPractices: true beside wcag to also run the best-practice rules, which a WCAG gate usually reports without failing on. Some of them can fail (empty-heading), so a gate that fails on any fail fails on those too.
Human auditor doing a deep contrast pass in a real browser — trade some false-positive protection for more findings. In a real browser the contrast rules already check the layout, not just computed styles, so only the contrast mode needs setting. Shown with Puppeteer's page.evaluate (accepts multiple args); if you're on Playwright, wrap the four positional args into a single object first — see INTEGRATION.md:
const result = await page.evaluate(runa11yCoreInPage, url, null, {contrast: { mode: 'auditorAssist' }}, null);
Scoped re-scan of one region after a UI change, skipping shadow DOM — useful in a component-level test where you only care about the widget you just changed:
runDomRulesInPage(url, '#checkout-form', {includeShadowDom: false}, { includeRuleIds: ['form-control-programmatic-label-present', 'button-name-present'] });
Output for snapshot testing — pin a timestamp so the result's own time stamp doesn't change between runs, and leave perfStats and profileRules off: their timings vary, and their shape is not part of the stable output. A snapshot also needs the same DOM, the same options, and the same rendering conditions (browser, viewport, fonts) on every run; the timestamp alone doesn't make two results identical.
runDomRulesInPage(url, null, {timestamp: '2026-01-01T00:00:00Z'}, null);
A custom, org-specific rule alongside the built-ins, only for this one call:
runDomRulesInPage(url, null, {customRules: [{id: 'org-no-inline-onclick',meta: { title: 'No inline onclick handlers', defaultSeverity: 'moderate' },runInPage(ctx) {// queryAllSmart applies excludeSelectors, the scope's shadow roots and// the hidden-content filter; queryAll is the plain light-DOM query.// ctx.helpers.dom reads the DOM so the page's markup can't redirect it.const dom = ctx.helpers.dom;const els = ctx.helpers.queryAllSmart('[onclick]');const occurrences = els.map((el) => ({selector: ctx.helpers.buildSelector(el),html: dom.outerHTML(el),summary: 'Inline onclick handler found.',hint: 'Move event handling into an external script.'}));return { ruleId: ctx.rule.ruleId, outcome: occurrences.length ? 'fail' : 'pass', severity: 'moderate', occurrences };}}]}, null);
customRules — runtime-registered rules
Every shipped rule is baked into src/core.js at build time. engineOptions.customRules is the runtime escape hatch: an array of rule descriptors registered for that one call only — nothing is added to the static catalog, and nothing persists between calls. The catalog functions take the same engineOptions and list what a scan with them has: getChecksCatalog(engineOptions) lists the valid custom rules, with each override in place of its built-in, getCheckDefById(id, engineOptions) finds them, getChecksForRunOnly(runOnly, engineOptions) selects them as the scan does, and getRulesForRunOnly(runOnly, engineOptions) lists the rollups they add. This is deliberate, not a limitation to work around: surea11y already takes fresh engineOptions per call with no mutable global config, and custom rules follow that same per-call model.
Calling the library directly is one way in; the CLI also exposes this via --custom-rules <path> (a local file, loaded once per scan) — see the CLI docs.
A custom rule, like a pack's, runs after the built-in rules, and the ctx.helpers, ctx.engineOptions and ctx.inputs.probes it is given are read-only: setting or deleting anything on them throws (the rule is reported as cantTell with that error), so it can't change what other rules see. It must not change the page: one that does is named in a console.warn. Results keep the catalog's order. Whatever it throws or returns, the scan returns a result, and the rule's result is made plain data, so the scan's result can be written as JSON and cloned.
A descriptor has the same shape as an internal rule module's own export — if you already know how to write a rule file for this engine, you already know this API:
{id: 'my-org-custom-rule', // requiredmeta: { title, description, tags, defaultSeverity, defaultConfidence, /* same fields as a rule module's meta */ },runInPage(ctx) { /* same ctx shape and same return contract as any built-in rule */ },applicability(ctx) { return true; }, // optional, same contract as a built-in rule's applicabilitydata: { /* optional, JSON-serializable */ }}
runInPage/applicabilitymay be a real function or a function-source string (i.e.fn.toString()). Pass a real function whenengineOptionsnever leaves the current JS realm (plain Node/jsdom use). Pass a string when it does — e.g. a Playwrightpage.evaluate(runa11yCoreInPage, { engineOptions })call, whereengineOptionscrosses a JSON/structured-clone boundary that cannot carry a liveFunctionreference but can carry a string. The engine reconstructs a string vianew Function, the same mechanism the build uses to embed every built-in rule's source into the in-page runner. Any function's source works: an arrow function, afunctiondeclaration, or a method written in shorthand as in the example above (runInPage(ctx) {…},asynctoo), whosetoString()is not an expression on its own. Reconstruction needsevalto be allowed, so a page whose Content Security Policy forbids'unsafe-eval'skips a rule given as a string, and itsskippedCustomRulesreason says the policy is why; pass a real function there.metagets identical defaulting/validation to a build-time rule — omit anything you don't need;defaultSeveritydefaults tomoderate,defaultConfidencetomedium,typetoautomatic, etc. A value outside its set (minor/moderate/serious/critical;high/medium/low;automatic/manual, any case) is a meta that fails validation, and the rule is skipped. Aseveritya rule returns outside the set is replaced by its default, with a note inerror.- A custom rule whose
idcollides with a built-in one overrides it for that scan, rather than running both. Since a same-named custom rule is just as likely to be an accidental collision as a deliberate override, every collision is surfaced two ways: aconsole.warnnaming the id(s), and a top-leveloverriddenBuiltinIdsarray on the result (empty when there's no collision) — see the output schema. Ids are matched in any case: a custom ruleIMG-ALT-PRESENToverridesimg-alt-presentand takes its id, and a second warning names both spellings. - A custom rule that ran counts toward the WCAG rollup (
rulesResults) of each criterion it maps to, throughmeta.wcagScor a WCAG entry inmeta.normativeMappings, as a built-in rule does: a failing one fails the rollup. The rollup marks itcustom: trueamong its contributors and lists it indata.details.customChecksIds. A custom rule the selection names on its own (by its id or one of its tags) that ran reports the rollups of its criteria even when the selection names none of them ("Which criteria a scan reports"). An override counts where its own mapping says: in its built-in's place when it maps to that criterion, and in no rollup when it maps to none, with a warning naming the rollups it left. - A custom rule is selected like a built-in one, by its id and its
meta.tags. ArunOnlythat names a custom rule the scan skipped throwsINVALID_RUN_ONLY, with the reason it was skipped.customRulesis a list: a single rule given on its own is not run, and is listed inskippedCustomRules. Under a profile or a WCAG target, a rule taggedbest-practice, or with no WCAG tag, is not part of the run: it is listed inskippedCustomRules, with aconsole.warn, and runs when you give it the tags of the criteria it checks ('wcag111') or addrunOnly.bestPractices: true. A custom rule the selection excludes (excludeRuleIds: ['org-b'],rules.exclude...) is listed there too, with the reasonexcluded by the selection.andexcluded: true, and not warned about. An override runs wherever its built-in would have. - A custom rule can report a margin as a built-in rule does: declare
meta.margin({ measure, unit, limit }) and returnmarginCandidates(seeRULE_AUTHORING.md,meta.margin, the rule-authoring guide on GitHub). A malformedmeta.marginis ignored, so that rule reports no margin; the scan carries on. - An invalid descriptor (missing/non-string
id, arunInPagethat isn't a function and isn't a reconstructable source string, anapplicabilitythat can't be turned into a function, ametathat fails validation, such asdeprecated: truewithoutdeprecation, an id another custom rule in the list already has, or the id of a composite rule) is skipped with aconsole.warnnaming the rule and the reason, and listed in the result'sskippedCustomRules— the rest of the scan, including every built-in rule, still runs normally. This isn't a validation gap to fix: a custom rule is arbitrary caller-supplied code, so "fail this one entry closed, don't abort the scan" is the safer default, mirroring how a built-in rule that throws is contained to acantTellfor that rule rather than crashing the run. - Rules run synchronously. A
runInPageorapplicabilitythat returns a Promise (anasyncfunction) is reported ascantTellwith anerrorsaying so, as is arunInPagethat returns anything but a result object. Anoutcomeoutsidepass,fail,cantTellandnotApplicable(such as'failed') becomescantTell, anderrornames the value returned. A rule'stypecomes from itsmeta: atypein its return is ignored, with a note inerror. - Results appear in
checksResultsexactly like any other rule's, including automaticselector/html/structuralPathfill-in forfail/cantTelloccurrences that only attach{ __node }(see the output schema). - A
failnames what failed. A custom rule'sfailwith no occurrences (a failure of the whole page) is reported with one occurrence on the document element (selector: 'html', reason codeFAIL_WITHOUT_OCCURRENCE), so JUnit, SARIF, baselines and the HTML report show it as the failure it is.
Packs — rules and standards from outside core
A pack brings rules, variants of core's rules, a standard or a checklist, and the messages they use, from a package of its own, without a build of core. Pass packs to a scan in engineOptions.packs; the scan runs on an engine prepared with them, by the steps the build uses for core's own rules, so a pack's standard, profiles, rollups and opt-in rules behave as a built-in one's would. The engine is kept for the next scan with the same pack objects, so a crawl that passes the same packs to every page prepares them once.
const { runDomRulesInPage, getChecksCatalog } = require('@surea11y/core');const acmePolicy = require('./acme-policy-pack');const result = runDomRulesInPage(url, null, { packs: [acmePolicy], profile: 'acme-web-2026' });result.engine.packs; // ['@acme/a11y-policy@1.0.0']getChecksCatalog({ packs: [acmePolicy] }); // core's rules and the pack's
A pack that does not hold together with core (its core range excludes this version, a rule id without the namespace, a dictionary key core has) is skipped with a console.warn, listed in the result's skippedPacks as { name, reason }, and the scan runs without it; under strictOptions it throws, with code: 'INVALID_ENGINE_OPTIONS'. Packs that clash with each other throw. In a page, a pack is registered first with packScript(packs) and named in engineOptions.packs as name@version. What a pack holds, how to use one from each binding, the test matchers and the CLI, and how to write one, are on Packs.
contextSelector (2nd runner argument, not an engineOptions field)
A CSS selector (or array of selectors) scoping the scan to one or more subtrees, resolved via document.querySelectorAll (all matches, not just the first). Pass null to scan the whole document.
- A selector that matches nothing scans nothing. Every rule reports
notApplicable, the page-level rules included, and the result'scontextMatchsays so:{ elementCount: 0, unmatchedSelectors: ['#mian'] }(see the output schema). The run also logs aconsole.warn. A scope the page doesn't have is not widened to the whole page: a result for content you never asked about, a whole-pagepassamong it, would read as a result for the part you did. In an array, a selector that matches nothing is listed inunmatchedSelectorswhile the others are scanned as usual, so content that is only sometimes on the page, such as a cookie banner, can sit in the list. Before 1.10.0 a scope that matched nothing fell back to the whole document, without a word in the result. - A selector the browser can't parse throws, alone or in an array, before any rule runs:
contextSelector: "#main[" is not a valid CSS selector.The error carriescode: 'INVALID_CONTEXT_SELECTOR'andselector, the selector that failed. Unlike a selector that matches nothing, it is always a mistake in the call.runa11yCoreAcrossFramesrejects with the same error. An error crossingpage.evaluate(Playwright, Puppeteer) keeps only its message, which is why the message names the option and the selector. Before 1.10.0 an invalid selector was skipped, so on its own it scanned the whole document. - Anything but a selector, an array of selectors or
nullthrows the same error, naming what was passed: an element, a NodeList, an{ include, exclude }object, a number, or an array item that isn't a string (contextSelector[1] must be a CSS selector, not the number 5.). Pass a selector that matches the element instead, and put what anexcludewould leave out inengineOptions.excludeSelectors. - An empty selector or list scans the whole page, and says so.
'',' ',[]and a list of blank strings name no part of the page, so the scan covers all of it, as withnull, and logs aconsole.warn. UnderstrictOptionsit throwsINVALID_CONTEXT_SELECTORinstead, since a list built empty by mistake may have been meant as "nothing to scan". Passnullfor the whole page. The result'scontextMatchisnull, as for no selector. Core 1.16.0 and later. - A single string may itself be a comma-separated selector list (ordinary CSS union semantics) —
'#a, #b'scans both#aand#b. - An array of strings scans the union of every selector's matches —
['#a', '.card']behaves the same as'#a, .card'; the array form exists for callers building the list programmatically. - Shadow DOM. With
includeShadowDom(the default), a scope includes the shadow roots inside it, and a scope that is itself a shadow host includes its own shadow root. The selector is matched against the document, so it can't select an element inside a shadow root; scope to its host instead. - Overlapping/nested regions are deduped automatically — an element reachable from more than one matched root is only ever reported once, not once per region.
- What a scoped result means. Most rules judge each element on its own, and judge the elements in scope exactly as in a whole-page scan. Two kinds of rule depend on the page around them. Whole-page rules (
page-title-present,landmark-one-mainand the rest listed underfragmentabove) reportnotApplicable. Rules that compare elements across the page (landmark-unique,landmark-no-duplicate-banner,-contentinfoand-main,landmark-banner-is-top-level,-complementary-,-contentinfo-and-main-is-top-level,accesskeys,heading-order,identical-links-same-purpose,identical-iframes-same-purpose) still report what they find inside the scope, since two landmarks there sharing a name share it on any page, but reportnotApplicableinstead ofpass: the scan saw only part of the page, so it cannot say the page is fine.duplicate-idandduplicate-id-ariacompare each element in scope with the whole document, since its duplicate can sit outside the scope, and report an element in scope whose id appears anywhere else. Theirpassmeans no element in the scope shares its id with another element on the page; a duplicate pair entirely outside the scope is not reported, as with every rule, so it does not say the whole page is free of duplicates.target-size-minimumjudges the targets in scope against neighbours anywhere in the document, since the spacing exception depends on what the page puts next to a target.text-spacing-content-lossjudges the text in scope, and checks whether it comes to overlap text anywhere on the page.