axe-core の Rules がテストされるまでをざっくり書く
TypeScript, Accessibilityこの記事は、アクセシビリティ Advent Calendar 2022 の 7 日目の記事です。
※お詫び: 当初は "axe-core の utils を使ってプチ axe-core クローンを実装する" という記事でしたが、執筆量が膨大となり収拾がつかなくなったので、この記事では Rule がどのような流れで評価されているかまでにとどめます。
Axe, axe-core とは
HTML 向けのアクセシビリティテストツール。
プラガブルな作りで、Lighthouse のアクセシビリティテストをする時にも使われている。
Axe のコアとなる機能は axe-core として OSS 化されている。
axe-core で楽々アクセシビリティチェック #GAADjp
今回は axe-core の Rule がどのように評価され、結果を返しているかを確認する。 記事を書いた時点の axe-core のバージョンは 4.5.2 で、バージョンによって差異があることに注意。
基本設計を知る
まず、axe-core の基本設計を知る。同リポジトリのデベロッパーガイド に色々と書いてある。
簡単に言えば axe-core は、複数のルール(Rule)と呼ばれるオブジェクトを使ってノードをテストし、リザルト(Result)と呼ばれるオブジェクトを返すライブラリである。
ルールは、テスト対象となる要素の CSS セレクタと、複数のチェック(Check)で構成される。各チェックはそれぞれ関数を持つ。関数は Node や options を引数で受け取り boolean を返す。各チェックの結果によって、ルールの結果がわかる。
チェック、またはルールのテストが終了した後は、それぞれにリザルトというオブジェクトが返る。
ディレクトリを知る
今回の記事で知る必要があるディレクトリは、以下の通り。
lib/rules
はルールのメタデータが入っているlib/checks
はチェックのメタデータと、チェック用の実装が入っているlib/core
は axe-core で使われる API の実装などが入っているlib/core/utils
は axe-core の開発で使う utils が入っている(型情報はないが axe-core をインポートするとアクセス可能)
ルール、チェック、リザルトの確認
まずはルールとチェックとリザルトについて、それぞれ確認していく。
ルールについて
ルールのメタ情報は axe-core の lib/rules
フォルダに JSON 形式で入っている。内部の実装ではスペック(Spec)と呼ばれている。今回はそのうちの image-alt ルールから、Rule の実行に関する一部のプロパティを取り出してみた。
lib/rules/image-alt.json
{
"id": "image-alt",
"selector": "img",
"matches": "no-explicit-name-required-matches",
"all": [],
"any": [
"has-alt",
"aria-label",
"aria-labelledby",
"non-empty-title",
"presentational-role"
],
"none": ["alt-space-value"]
}
id
はルールを示す一意の値。selector
は対象となる要素の CSS セレクタを指定するので、このルールでは img
要素を対象としているのがわかる。
matches
は後述するがフィルタリング用の関数名を指定する。
重要なのが any
と none
プロパティ。それぞれに has-alt
などの文字列が、配列形式で入っている。文字列はチェックの ID を指している。
any
は、配列内にあるいずれかのチェックをパスする必要があることを示す。none
は、配列内のチェックを一つでもパスした場合、ルールに違反していることを示している。
つまり image-alt
ルールは、has-alt
など any
内のチェックをひとつでもパスする必要があり、alt-space-value
チェックをパスしてはいけない。
ちなみに all
は、配列内の全てのチェックをパスする必要があるプロパティ。
ルールの見方がある程度わかったところで、チェックの JSON も確認する。
チェックと evaluate について
image-alt ルールに含まれていた has-alt チェックの JSON を確認してみる。
チェックの場所は lib/checks/
にある。
こちらもルール同様、metadata
などの解説に不必要なプロパティを省いている。
lib/checks/shared/has-alt.json
{
"id": "has-alt",
"evaluate": "has-alt-evaluate",
}
チェックには、ルールから参照される id
が含まれる。また、evaluate
プロパティがある。このプロパティに含まれる文字列は、evaluate される関数の id を指す。
この関数は、各ノードごとに評価され、ノードなどの引数を受け取って boolean
を返す。
実際の has-alt-evaluate の実装についても確認してみる。
チェックの実装は、チェックの JSON と同階層にある。
lib/checks/shared/has-alt-evaluate.js
function hasAltEvaluate(node, options, virtualNode) {
const { nodeName } = virtualNode.props;
if (!['img', 'input', 'area'].includes(nodeName)) {
return false;
}
return virtualNode.hasAttr('alt');
}
export default hasAltEvaluate;
evaluate の関数は 3 つの引数を受け取る。node
は Element
を、options
はチェックの options
プロパティで定義したオブジェクトを受け取る。
has-alt-evaluate
は、virtualNode
を取り出し hasAttr
を用いて、ノードが alt
属性の値を持つかを確認していることがわかる。
virtualNode
は、axe-core の flattenTree から取り出している。
axe-core は flat-tree のアルゴリズムを実装して、テスト範囲から light tree と shadow tree をまとめた flattenTree を生成している。その際に生成している仮想 Node となっている(lib/core/base/virtual-node/virtual-node.js)。
これは Open Shadow DOM をサポートするためで、3 系以降から徐々にサポートされている様子。Add support for shadowDOM #87
元の Node は VirtualNode.actualNode
に入っていて、第一引数の node
もここから取られているようだった。
types.ts
type VirtualNode = {
actualNode: Element
children: readonly VirtualNode[]
parent: VirtualNode
shadowId: string
attr: (attributeName: string) => string
hasAttr: (attributeName: string) => boolean
props: VirtualNodeProps
}
type VirtualNodeProps = {
nodeName: string
nodeType: number
id: string
nodeValue: string
}
axe-core では axe.utils.getFlattenedTree(Element)
から flattenTree にアクセスできる。開発向けの utils だからか、型情報はない。
index.ts
import { utils } from 'axe-core'
const context = document.documentelement
// @ts-ignore
console.log(utils.getFlattenedTree(context))
ルールの matches について
ルールには matches
というプロパティを持つものがある。
これはフィルタリング関数で、CSS セレクタのみでは表現できない条件を絞り込むために使う。
値は関数の id を指定する。
lib/rules/image-alt.json
{
"matches": "no-explicit-name-required-matches",
}
matches
も、チェックの evaluate
同様に node
と virtualNode
を受け取り、boolean を返すように実装される。
例えば lib/rules/no-explicit-name-required-matches.js では以下のようになっている。
lib/rules/no-explicit-name-required-matches.js
import { isFocusable } from '../commons/dom';
import { getExplicitRole } from '../commons/aria';
import ariaRoles from '../standards/aria-roles';
/**
* Filter out elements with an explicit role that does not require an accessible name and is not focusable
*/
function noExplicitNameRequired(node, virtualNode) {
const role = getExplicitRole(virtualNode);
if (!role || ['none', 'presentation'].includes(role)) {
return true;
}
const { accessibleNameRequired } = ariaRoles[role] || {};
if (accessibleNameRequired || isFocusable(virtualNode)) {
return true;
}
return false;
}
export default noExplicitNameRequired;
リザルトについて
チェックの結果とルールの結果は、それぞれリザルトというオブジェクトが持っている。
例えば、ルールリザルトは、結果の文字列やそのルールの重大度などを持っている。
types.ts
type RuleResult = {
id: string
result: 'PASS' | 'FAIL' | 'NA'
pageLevel: boolean
impact: 'minor' | 'moderate' | 'serious' | 'critical'
nodes: readonly Node[]
}
チェックリザルトは、evaluate された関数の結果、チェック内容に関連する RelatedNodes などを持っている。
types.ts
type CheckResult = {
id: string
data: Record<string, unknown>
relatedNodes: Readonly<{ target: Node[]; html: string }>[]
result: boolean
}
まとめ
ここまでで axe-core のテスト方法はわかったと思うので、まとめておく。
- axe-core は、ルールとその中のチェックをテストし、リザルトを返す
- ルール、チェックはスペックを持ち、JSON 形式で書かれる
- ルールは、スペックの
selector
,matches
にてテスト対象の要素を絞り込む - ルールは、スペックの
all
,any
,none
にある複数のチェックを評価する - チェックは evaluate される関数の返り値 boolean で評価される。そして結果を含めたチェックリザルトを返す
ルールが評価されるまで
Axe が実行されてルールが評価され、リザルトが返るまで、何が起こっているのかをざっと調べる。
予備知識: queue について
axe-core はタスクキューを実装していて、axe.utils.queue
でアクセスできる(lib/core/utils/queue.js)。
core の実装では頻繁に出てくるので覚えておく必要がある。
queue
は defer(func)
メソッドを持つ。func は resolve
, reject
を持ち、それらを呼ぶことでエンキューされる。
then(callback)
メソッドを使うことで、その時点までにエンキューされた func が全て評価された時、callback が発火する。
callback には、func で返った値を配列形式にした data
が渡される。
then を破棄する abort
もある。
index.ts
import { utils } from 'axe-core'
// @ts-ignore
const q = utils.queue()
q.defer((resolve) => resolve('Hello Axe'))
q.defer((_resolve) => setTimeout(() => _resolve('Hey Axe'), 3000))
q.defer((_resolve) => new Promise(() => _resolve('Bye Axe')))
q.then((data) => console.log(data)) // ['Hello Axe', 'Hey Axe', 'Bye Axe']
queue を持っていることを知ったので、まずはビルド時の挙動を追ってみる。
1. axe.js のビルド(ルール、チェックをパースする)
まず Axe 本体を Grunt でビルドする。その際にルールとチェックのパース+出力が行われる。その処理は build/configure.js にある。
チェックで指定された evalute
やルールで指定された matches
の実装は metadata-function-map.js
にマッピングされる。ここにセットされた関数は Rule
や Check
のコンストラクタ(正確には prototype.configure
)で、対応する id が設定されたプロパティに紐づけられる。
ルールとチェックの情報、マップされた関数は axe.js
と axe.min.js
に組み込まれる。
2. run を実行
1 でビルドされた Axe を、ユーザーがページ上で読み込み、axe.run()
を実行する。
run
は第一引数に context
を受け取る。この context はページ内のテスト対象となるルートノードを指し、デフォルトではdocument
が入る。
第二引数は options
で、説明は省略する。第三引数に handleRunRules
コールバックがあり、テストが終了した後はここからルールリザルトを得られる。
run の中では runRules を実行している。
3. runRules から audit を実行
runRules では主に 2 つの仕事をしている。
まず context から Context を生成している。Context は Node の対象、除外を決める include
や exclude
、 axe.utils.getFlattenedTree
で作成した fletTree を含んでいる。
Context については、v4.5.2 じゃなくてずるいけど Axe Testing Context というドキュメントが最近マージされたので、そちらを読んで欲しい。
lib/core/public/run-rules.js
context = new Context(context);
その後、audit.run
をエンキューする。
lib/core/public/run-rules.js
q.defer((res, rej) => {
audit.run(context, options, res, rej);
});
4. Audit から Rule, Check の生成
Audit は、ルールとチェックの評価を担当する。
ここでは初期化時に、スペックからオブジェクトに変換された rules や checks を取得し、Rule と Check インスタンスのリストを作成する。
5. Audit.run から Rule.run のキューを生成
次に、Audit.run
では、リストのルールを getDefferedRule で配列に積み、Rule.run
をエンキューする。
その際に各ルールは now
と later
に分けられ、now から先に評価される。これは、事前に cssom
や media
などのアセットを読み込む(preload
)ルールに対応するためだと思われる。
6. Rule.run から Check.run のキューを生成
次は Rule.run が評価されていく。
ルールリザルトが生成され、gatherAndMatchNodes で CSS セレクタ、matches
の関数で、ルール対象の NodeList を作成する。
その後、ルールの any, all, none
を元に runChecks
を、runChecks
の中で Check.run
がエンキューされていく。
7. Check.run から evaluate で指定された関数の評価、チェックリザルトの生成
Check.run では、チェックリザルト生成し、evaluate
で指定された関数を呼び出す。
lib/core/base/check.js
const helper = checkHelper(checkResult, options, resolve, reject);
let result;
result = this.evaluate.call(
helper,
node.actualNode,
checkOptions,
node,
context
);
ただ evaluate されるだけでなく call
で呼び出していて、第一引数には checkHelper という util が入る。
ここにはチェックの評価時に使われる isAsync
や relatedNodes
が格納される。
また、チェック時の記録が data
に入ることがある。例えば、svg-non-empty-title-evaluate.js では、<title>
に値がないか、<title>
自体がないかを区別するため this.data.messageKey
にラベルを指定している。
その後、evaluate された関数の boolean がチェックリザルトの result
に渡され、resolve される。
8. ルールリザルトの生成
runChecks
が全て評価されたので、then が実行される。
ここではルールリザルトの nodes
に、テスト対象となった Node が追加されていく。その Node は axe.utils.DqElement(HTMLElement)
で生成されたものである。
DqElement は対象の祖先や対象のソースコードなどを含んでいたり、toJSON
のメソッドを持っていたりして、テスト対象の様々な情報を取得できる。
core/base/rule.js
import { utils } from 'axe'
// <img id="imageElement" src="test.png" alt="この画像の代替テキスト">
const context = document.getElementById('imageElement')
console.log(new utils.DqElement(context).source) // <img id="imageElement" src="test.png" alt="この画像の代替テキスト">
そして、Rule, Audit で resolve, then された後、runRules
の then に戻ってくる。
各ルールリザルトが finalizeRuleResult を通る。ここでは各ルールのチェックの集計(any
のチェック通ってるかとか、none
通ってないかとか)をして、さらにルール全体の集計をしている。
その結果が Axe のテスト結果として axe.run
の callback または then に渡される。
おわりに
結構端折った。
この他にも Metadata だったり、別のフレームとのやり取り(Context の fromFrames
やチェックの after
など)もあるけど、基本的な流れはこんな感じになっていた。
今後は Axe の挙動が怪しい時、「あーこれは evaluate の実装がいまいちだな」「これは all
のチェックが足りてないな」などを考えられるようになると思う(そんなケースがあるかは置いといて)。