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 は後述するがフィルタリング用の関数名を指定する。

重要なのが anynone プロパティ。それぞれに 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 つの引数を受け取る。nodeElement を、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 同様に nodevirtualNode を受け取り、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 のテスト方法はわかったと思うので、まとめておく。

  1. axe-core は、ルールとその中のチェックをテストし、リザルトを返す
  2. ルール、チェックはスペックを持ち、JSON 形式で書かれる
  3. ルールは、スペックの selector, matches にてテスト対象の要素を絞り込む
  4. ルールは、スペックの all, any, none にある複数のチェックを評価する
  5. チェックは evaluate される関数の返り値 boolean で評価される。そして結果を含めたチェックリザルトを返す

ルールが評価されるまで

Axe が実行されてルールが評価され、リザルトが返るまで、何が起こっているのかをざっと調べる。

予備知識: queue について

axe-core はタスクキューを実装していて、axe.utils.queue でアクセスできる(lib/core/utils/queue.js)。
core の実装では頻繁に出てくるので覚えておく必要がある。

queuedefer(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.jsaxe.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 の対象、除外を決める includeexcludeaxe.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 をエンキューする。
その際に各ルールは nowlater に分けられ、now から先に評価される。これは、事前に cssommedia などのアセットを読み込む(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 が入る。
ここにはチェックの評価時に使われる isAsyncrelatedNodes が格納される。
また、チェック時の記録が 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 された後、runRulesthen に戻ってくる。 各ルールリザルトが finalizeRuleResult を通る。ここでは各ルールのチェックの集計(any のチェック通ってるかとか、none 通ってないかとか)をして、さらにルール全体の集計をしている。
その結果が Axe のテスト結果として axe.run の callback または then に渡される。

おわりに

結構端折った。
この他にも Metadata だったり、別のフレームとのやり取り(Context の fromFrames やチェックの after など)もあるけど、基本的な流れはこんな感じになっていた。

今後は Axe の挙動が怪しい時、「あーこれは evaluate の実装がいまいちだな」「これは all のチェックが足りてないな」などを考えられるようになると思う(そんなケースがあるかは置いといて)。