@a11y-skills/audit 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,50 @@
3
3
  All notable changes to `@a11y-skills/audit` are documented here. This project
4
4
  adheres to [Semantic Versioning](https://semver.org/).
5
5
 
6
+ ## 0.3.0
7
+
8
+ **Breaking** — every check now returns (and saves) a single axe-style envelope
9
+ instead of its own ad-hoc shape. The public API is not yet stable in `0.x`, so
10
+ this lands as a minor release.
11
+
12
+ ### Changed (breaking)
13
+
14
+ - All `runXxx()` functions return `AuditCheckResult<TDetails>`: findings are
15
+ normalized into `violations` / `incomplete` / `passes` / `inapplicable`
16
+ rule arrays (axe-style `id` / `impact` / `tags` / `helpUrl` / `nodes`), with
17
+ rule-level counts in `summary`. The former top-level fields (`issues`,
18
+ `failAA`, `overflowingElements`, `clippedElements`, ...) moved unchanged
19
+ under `details`.
20
+ - Classification is conservative: only findings whose detection has no blind
21
+ spot and where no WCAG exception can apply are `violations`
22
+ (on-focus context change, text-spacing clipping, invalid autocomplete
23
+ tokens). Everything else — reflow/zoom overflow, meta refresh, orientation
24
+ lock, missing focus styles, undersized targets — lands in `incomplete`, the
25
+ manual-review queue.
26
+ - Saved JSON files use the same envelope; the JSON Schemas were rewritten
27
+ accordingly (`$defs`-based shared envelope + per-check `details` schemas).
28
+ Pre-0.3.0 result files no longer validate.
29
+ - `saveAuditResult()` no longer appends a disclaimer (the envelope carries it).
30
+ - `runAxeAudit` builds the buckets from the raw axe results: violations and
31
+ incomplete keep their nodes, passes/inapplicable keep rule metadata only;
32
+ `details` records the execution configuration.
33
+
34
+ ### Added
35
+
36
+ - `rule-registry.ts`: per-rule metadata (`sc`, accurate per-SC `wcag*` tags,
37
+ `impact`, `scope`, classification) — target size split into
38
+ `a11y-skills/target-size-minimum` (2.5.8 AA) and
39
+ `a11y-skills/target-size-enhanced` (2.5.5 AAA).
40
+ - Pure normalization mappers (`normalize*`), `buildAuditResult()`, and the
41
+ opt-in `mergeNormalizedResults()` exported from the package root. The
42
+ buckets are re-derivable from a saved result's `details`.
43
+ - Element-level evidence: detail records now carry `html` (outerHTML, capped
44
+ at `HTML_SNIPPET_MAX_LENGTH`) and `htmlTruncated`; focus issues gained
45
+ `selector`. `TargetSizeIssue.exceptionAssessment`
46
+ (`ruled-out`/`possible`/`not-assessed`) drives the violation promotion.
47
+ - Tests: mapper unit tests, merge invariants, and ajv (draft 2020-12) schema
48
+ validation of the produced envelopes.
49
+
6
50
  ## 0.2.0
7
51
 
8
52
  Phase 2 checks added (additive). Still a `0.x` preview — the function API may
package/README.ja.md CHANGED
@@ -67,7 +67,7 @@ test("axe audit", async ({ page }, testInfo) => {
67
67
  // outputFile: "axe-result.json", // 任意で上書き
68
68
  // tags: ["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"],
69
69
  });
70
- expect(result.violationCount).toBe(0);
70
+ expect(result.summary.violationCount).toBe(0);
71
71
  });
72
72
  ```
73
73
 
@@ -86,7 +86,7 @@ test("focus indicators", async ({ browser }, testInfo) => {
86
86
  screenshot: true, // 既定: false
87
87
  // contextOptions: { locale: "ja-JP" }, // browser.newContext() に転送
88
88
  });
89
- expect(result.elementsWithoutFocusStyle).toBe(0);
89
+ expect(result.details.elementsWithoutFocusStyle).toBe(0);
90
90
  });
91
91
  ```
92
92
 
@@ -135,6 +135,52 @@ TEST_PAGE=https://example.com A11Y_OUTPUT_DIR=./a11y-results npx playwright test
135
135
  > を `testMatch` に指定してもテストは見つかりません。上記の 1 行 re-export spec が
136
136
  > entry を実行する正式な方法です。
137
137
 
138
+ ## 結果形式
139
+
140
+ すべての検査は同じ axe 風の envelope を返し、同じ形式で JSON に保存します:
141
+
142
+ ```ts
143
+ interface AuditCheckResult<TDetails> {
144
+ source: CheckSource; // 例: "reflow-check"
145
+ url: string;
146
+ timestamp: string;
147
+ violations: NormalizedRuleResult[]; // 確定した違反
148
+ incomplete: NormalizedRuleResult[]; // 要手動確認
149
+ passes: NormalizedRuleResult[]; // 実行して問題が見つからなかったルール
150
+ inapplicable: NormalizedRuleResult[]; // 検査対象がなかったルール
151
+ summary: { violationCount; incompleteCount; passCount; checkedNodes? };
152
+ details: TDetails; // 検査固有の証跡(測定値・スクリーンショット等)
153
+ disclaimer: { ... };
154
+ }
155
+ ```
156
+
157
+ 各ルール結果は axe と同じ形(`id` / `impact` / `description` / `help` /
158
+ `helpUrl` / `tags` / `nodes[]`、`nodes[].target` / `html` / `htmlTruncated` /
159
+ `failureSummary`)です。独自ルールは名前空間付き
160
+ (`a11y-skills/focus-visible`、`a11y-skills/target-size-minimum` など)で、
161
+ SC ごとに正確な WCAG バージョン・レベルタグ(`wcag2aa`、`wcag21aa`、
162
+ `wcag22aa`、`wcag247` 形式の SC タグ)が付きます。
163
+
164
+ **分類は保守的です。** 検出に死角がなく WCAG の例外が適用され得ない場合のみ
165
+ `violations` に入ります(フォーカスによる文脈変化、テキストスペーシングの
166
+ クリップ、autocomplete の不正トークン)。それ以外の検出 — reflow/zoom の
167
+ オーバーフロー、meta refresh、画面方向ロック、フォーカススタイル欠如、
168
+ 小さすぎるターゲット — は `incomplete` に入ります。`incomplete` はノイズでは
169
+ なく「要手動確認キュー」として扱ってください。
170
+
171
+ 同一ページに対する複数検査の結果を 1 つにまとめるには:
172
+
173
+ ```ts
174
+ import { mergeNormalizedResults } from "@a11y-skills/audit";
175
+
176
+ const merged = mergeNormalizedResults([axeResult, reflowResult, targetResult]);
177
+ ```
178
+
179
+ `mergeNormalizedResults` は URL 不一致で例外を投げ、ノードを
180
+ `target` + `failureSummary` で重複排除し、複数バケツに現れるルールは
181
+ `violations > incomplete > passes > inapplicable` の優先順位で統合します。
182
+ frame / shadow root 内の同一セレクタは区別しません。
183
+
138
184
  ## 結果型とスキーマ
139
185
 
140
186
  ```ts
@@ -142,8 +188,10 @@ import type { AxeAuditResult, FocusCheckResult } from "@a11y-skills/audit/schema
142
188
  import { RESULT_SCHEMAS } from "@a11y-skills/audit/schemas";
143
189
  ```
144
190
 
145
- `RESULT_SCHEMAS` は各検査 id に手書きの JSON Schema を対応付けており、`*-result.json`
146
- を実行時に検証できます。
191
+ `RESULT_SCHEMAS` は各検査 id に手書きの JSON Schema(draft 2020-12)を
192
+ 対応付けており、`*-result.json` を実行時に検証できます。正規化マッパー
193
+ (`normalize*`)と `buildAuditResult` はパッケージルートから export されて
194
+ いるため、保存済み結果の `details` から 4 区分をいつでも再導出できます。
147
195
 
148
196
  ## ライセンス
149
197
 
package/README.md CHANGED
@@ -69,7 +69,7 @@ test("axe audit", async ({ page }, testInfo) => {
69
69
  // outputFile: "axe-result.json", // optional override
70
70
  // tags: ["wcag2a", "wcag2aa", "wcag21aa", "wcag22aa"],
71
71
  });
72
- expect(result.violationCount).toBe(0);
72
+ expect(result.summary.violationCount).toBe(0);
73
73
  });
74
74
  ```
75
75
 
@@ -88,7 +88,7 @@ test("focus indicators", async ({ browser }, testInfo) => {
88
88
  screenshot: true, // default: false
89
89
  // contextOptions: { locale: "ja-JP" }, // forwarded to browser.newContext()
90
90
  });
91
- expect(result.elementsWithoutFocusStyle).toBe(0);
91
+ expect(result.details.elementsWithoutFocusStyle).toBe(0);
92
92
  });
93
93
  ```
94
94
 
@@ -138,6 +138,52 @@ TEST_PAGE=https://example.com A11Y_OUTPUT_DIR=./a11y-results npx playwright test
138
138
  > `**/node_modules/@a11y-skills/audit/dist/test-entries/*.js` finds no tests.
139
139
  > The one-line re-export specs above are the supported way to run the entries.
140
140
 
141
+ ## Result format
142
+
143
+ Every check returns — and saves as JSON — the same axe-style envelope:
144
+
145
+ ```ts
146
+ interface AuditCheckResult<TDetails> {
147
+ source: CheckSource; // e.g. "reflow-check"
148
+ url: string;
149
+ timestamp: string;
150
+ violations: NormalizedRuleResult[]; // confirmed findings
151
+ incomplete: NormalizedRuleResult[]; // needs manual review
152
+ passes: NormalizedRuleResult[]; // rules that ran and found nothing
153
+ inapplicable: NormalizedRuleResult[]; // nothing to examine
154
+ summary: { violationCount; incompleteCount; passCount; checkedNodes? };
155
+ details: TDetails; // check-specific evidence (measurements, screenshots, ...)
156
+ disclaimer: { ... };
157
+ }
158
+ ```
159
+
160
+ Each rule result is axe-shaped (`id` / `impact` / `description` / `help` /
161
+ `helpUrl` / `tags` / `nodes[]`, with `nodes[].target` / `html` /
162
+ `htmlTruncated` / `failureSummary`). Custom rules are namespaced
163
+ (`a11y-skills/focus-visible`, `a11y-skills/target-size-minimum`, ...) and
164
+ tagged with accurate WCAG version/level tags (`wcag2aa`, `wcag21aa`,
165
+ `wcag22aa`, `wcag247`-style SC tags).
166
+
167
+ **Classification is conservative.** A finding lands in `violations` only when
168
+ the detection has no blind spot and no WCAG exception can apply (on-focus
169
+ context change, text-spacing clipping, invalid autocomplete tokens). All other
170
+ detections — reflow/zoom overflow, meta refresh, orientation lock, missing
171
+ focus styles, undersized targets — are `incomplete`: treat that bucket as the
172
+ manual-review queue, not as noise.
173
+
174
+ To combine several checks for the same page into one view:
175
+
176
+ ```ts
177
+ import { mergeNormalizedResults } from "@a11y-skills/audit";
178
+
179
+ const merged = mergeNormalizedResults([axeResult, reflowResult, targetResult]);
180
+ ```
181
+
182
+ `mergeNormalizedResults` throws on URL mismatches, deduplicates nodes by
183
+ `target` + `failureSummary`, and resolves a rule appearing in several buckets
184
+ by priority (`violations > incomplete > passes > inapplicable`). Identical
185
+ selectors inside different frames or shadow roots are not distinguished.
186
+
141
187
  ## Result types & schemas
142
188
 
143
189
  ```ts
@@ -145,8 +191,11 @@ import type { AxeAuditResult, FocusCheckResult } from "@a11y-skills/audit/schema
145
191
  import { RESULT_SCHEMAS } from "@a11y-skills/audit/schemas";
146
192
  ```
147
193
 
148
- `RESULT_SCHEMAS` maps each check id to a hand-written JSON Schema for validating
149
- the `*-result.json` files at runtime.
194
+ `RESULT_SCHEMAS` maps each check id to a hand-written JSON Schema
195
+ (draft 2020-12) for validating the `*-result.json` files at runtime. The
196
+ normalization mappers (`normalize*`) and `buildAuditResult` are exported from
197
+ the package root, so the buckets can be re-derived from a saved result's
198
+ `details` at any time.
150
199
 
151
200
  ## License
152
201
 
@@ -14,6 +14,8 @@ export declare const AUDIT_DISCLAIMER: {
14
14
  };
15
15
  /** Console disclaimer message */
16
16
  export declare const DISCLAIMER_CONSOLE = "\nNote: Automated testing detects only ~30-40% of WCAG issues.\n Manual testing is required for complete accessibility evaluation.\n";
17
+ /** Maximum length of `NormalizedNode.html` / detail `html` snippets. */
18
+ export declare const HTML_SNIPPET_MAX_LENGTH = 300;
17
19
  /** Default axe-core tag set (WCAG 2.0/2.1/2.2 A & AA) */
18
20
  export declare const DEFAULT_AXE_TAGS: readonly ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa", "wcag22aa"];
19
21
  /** CSS properties to check for focus style changes */
package/dist/constants.js CHANGED
@@ -21,6 +21,11 @@ Note: Automated testing detects only ~30-40% of WCAG issues.
21
21
  Manual testing is required for complete accessibility evaluation.
22
22
  `;
23
23
  // =============================================================================
24
+ // Normalized result format
25
+ // =============================================================================
26
+ /** Maximum length of `NormalizedNode.html` / detail `html` snippets. */
27
+ export const HTML_SNIPPET_MAX_LENGTH = 300;
28
+ // =============================================================================
24
29
  // Axe Audit defaults (broad WCAG coverage)
25
30
  // =============================================================================
26
31
  /** Default axe-core tag set (WCAG 2.0/2.1/2.2 A & AA) */
package/dist/index.d.ts CHANGED
@@ -10,4 +10,6 @@
10
10
  * - `@a11y-skills/audit/schemas`: result types & JSON Schemas.
11
11
  */
12
12
  export * from './types.js';
13
- export { AUDIT_DISCLAIMER, DEFAULT_AXE_TAGS, REFLOW_VIEWPORT, TARGET_SIZE_AA, TARGET_SIZE_AAA, } from './constants.js';
13
+ export { AUDIT_DISCLAIMER, DEFAULT_AXE_TAGS, HTML_SNIPPET_MAX_LENGTH, REFLOW_VIEWPORT, TARGET_SIZE_AA, TARGET_SIZE_AAA, } from './constants.js';
14
+ export { RULES, getRule, type RuleKey, type RuleMeta } from './utils/rule-registry.js';
15
+ export { buildAuditResult, mergeNormalizedResults, normalizeAxeResults, normalizeAutocompleteAudit, normalizeAutoPlayDetection, normalizeFocusCheck, normalizeOrientationCheck, normalizeReflowCheck, normalizeTargetSizeCheck, normalizeTextSpacingCheck, normalizeTimeLimitDetector, normalizeZoomCheck, type MergedAuditResult, type NormalizedBuckets, type RawAxeResults, type RawAxeRule, } from './utils/axe-format.js';
package/dist/index.js CHANGED
@@ -10,4 +10,6 @@
10
10
  * - `@a11y-skills/audit/schemas`: result types & JSON Schemas.
11
11
  */
12
12
  export * from './types.js';
13
- export { AUDIT_DISCLAIMER, DEFAULT_AXE_TAGS, REFLOW_VIEWPORT, TARGET_SIZE_AA, TARGET_SIZE_AAA, } from './constants.js';
13
+ export { AUDIT_DISCLAIMER, DEFAULT_AXE_TAGS, HTML_SNIPPET_MAX_LENGTH, REFLOW_VIEWPORT, TARGET_SIZE_AA, TARGET_SIZE_AAA, } from './constants.js';
14
+ export { RULES, getRule } from './utils/rule-registry.js';
15
+ export { buildAuditResult, mergeNormalizedResults, normalizeAxeResults, normalizeAutocompleteAudit, normalizeAutoPlayDetection, normalizeFocusCheck, normalizeOrientationCheck, normalizeReflowCheck, normalizeTargetSizeCheck, normalizeTextSpacingCheck, normalizeTimeLimitDetector, normalizeZoomCheck, } from './utils/axe-format.js';
@@ -15,6 +15,7 @@
15
15
  */
16
16
  import * as path from 'node:path';
17
17
  import { SCREENSHOT_INTERVALS, CHANGE_THRESHOLD, DEFAULT_AUTO_PLAY_OUTPUT_DIR, DETECTION_RESULT_FILENAME, } from '../constants.js';
18
+ import { buildAuditResult, normalizeAutoPlayDetection, } from '../utils/axe-format.js';
18
19
  import { generateRecommendation, printSummary } from '../utils/recommendations.js';
19
20
  /** Capture screenshots at configured intervals. */
20
21
  async function captureScreenshots(page, outputDir) {
@@ -114,8 +115,7 @@ export async function runAutoPlayDetection(options) {
114
115
  }
115
116
  pauseVerification = createSkippedVerification(reason);
116
117
  }
117
- const result = {
118
- url: page.url(),
118
+ const details = {
119
119
  screenshotRecords: screenshots,
120
120
  comparisons,
121
121
  hasAutoPlayContent: hasAnyChange,
@@ -129,6 +129,12 @@ export async function runAutoPlayDetection(options) {
129
129
  pauseVerification,
130
130
  }),
131
131
  };
132
+ const result = buildAuditResult({
133
+ source: 'auto-play-detection',
134
+ url: page.url(),
135
+ details,
136
+ buckets: normalizeAutoPlayDetection(details),
137
+ });
132
138
  console.log('\n=== Auto-play Detection Results ===\n');
133
139
  console.log(JSON.stringify(result, null, 2));
134
140
  saveJsonResult(path.join(outputDir, DETECTION_RESULT_FILENAME), result);
@@ -13,13 +13,31 @@
13
13
  * - Cannot confirm actual field purpose; pattern matching is heuristic
14
14
  * - Manual verification needed for edge cases
15
15
  */
16
- import { AUTOCOMPLETE_FIELD_PATTERNS, VALID_AUTOCOMPLETE_TOKENS, DEFAULT_AUTOCOMPLETE_RESULT_FILE, } from '../constants.js';
16
+ import { AUTOCOMPLETE_FIELD_PATTERNS, VALID_AUTOCOMPLETE_TOKENS, DEFAULT_AUTOCOMPLETE_RESULT_FILE, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
17
+ import { buildAuditResult, normalizeAutocompleteAudit, } from '../utils/axe-format.js';
17
18
  import { saveAuditResult, logAuditHeader, logSummary, logIssueList, logOutputPaths, } from '../utils/test-harness.js';
18
19
  /**
19
20
  * Collect basic form field information in browser context.
20
21
  * Accessible names are retrieved separately via ariaSnapshot().
21
22
  */
22
- function collectBasicFieldInfo() {
23
+ function collectBasicFieldInfo(args) {
24
+ const { htmlSnippetMaxLength } = args;
25
+ function getHtmlSnippet(element) {
26
+ let html = '';
27
+ try {
28
+ html = element.outerHTML || '';
29
+ }
30
+ catch {
31
+ html = '';
32
+ }
33
+ if (!html) {
34
+ return { html: `<${element.tagName.toLowerCase()}>`, htmlTruncated: false };
35
+ }
36
+ if (html.length > htmlSnippetMaxLength) {
37
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
38
+ }
39
+ return { html, htmlTruncated: false };
40
+ }
23
41
  function getUniqueSelector(element, elementIndex) {
24
42
  if (element.id) {
25
43
  return `#${element.id}`;
@@ -51,6 +69,7 @@ function collectBasicFieldInfo() {
51
69
  fields.push({
52
70
  selector: getUniqueSelector(element, index),
53
71
  tagName: el.tagName.toLowerCase(),
72
+ ...getHtmlSnippet(element),
54
73
  inputType,
55
74
  name: el.name || null,
56
75
  id: el.id || null,
@@ -106,6 +125,8 @@ function analyzeFields(fields, patterns, validTokens) {
106
125
  missing.push({
107
126
  selector: field.selector,
108
127
  tagName: field.tagName,
128
+ html: field.html,
129
+ htmlTruncated: field.htmlTruncated,
109
130
  inputType: field.inputType,
110
131
  name: field.name,
111
132
  id: field.id,
@@ -124,6 +145,8 @@ function analyzeFields(fields, patterns, validTokens) {
124
145
  invalid.push({
125
146
  selector: field.selector,
126
147
  tagName: field.tagName,
148
+ html: field.html,
149
+ htmlTruncated: field.htmlTruncated,
127
150
  inputType: field.inputType,
128
151
  name: field.name,
129
152
  id: field.id,
@@ -144,7 +167,9 @@ function analyzeFields(fields, patterns, validTokens) {
144
167
  export async function runAutocompleteAudit(options) {
145
168
  const { page, ...location } = options;
146
169
  // Collect basic field info from DOM
147
- const basicFields = await page.evaluate(collectBasicFieldInfo);
170
+ const basicFields = await page.evaluate(collectBasicFieldInfo, {
171
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
172
+ });
148
173
  // Enhance with accessible names via ariaSnapshot()
149
174
  const fields = [];
150
175
  for (const basicField of basicFields) {
@@ -164,26 +189,31 @@ export async function runAutocompleteAudit(options) {
164
189
  }
165
190
  const patterns = Object.entries(AUTOCOMPLETE_FIELD_PATTERNS);
166
191
  const { missing, invalid } = analyzeFields(fields, patterns, VALID_AUTOCOMPLETE_TOKENS);
167
- const result = {
168
- url: page.url(),
192
+ const details = {
169
193
  totalFieldsChecked: fields.length,
170
194
  missingAutocomplete: missing,
171
195
  invalidAutocomplete: invalid,
172
196
  };
197
+ const result = buildAuditResult({
198
+ source: 'autocomplete-audit',
199
+ url: page.url(),
200
+ details,
201
+ buckets: normalizeAutocompleteAudit(details),
202
+ });
173
203
  // Output results
174
204
  logAuditHeader('Autocomplete Audit Results', 'WCAG 1.3.5', result.url);
175
205
  logSummary({
176
- 'Total form fields': result.totalFieldsChecked,
177
- 'Fields missing autocomplete': result.missingAutocomplete.length,
178
- 'Fields with invalid autocomplete': result.invalidAutocomplete.length,
206
+ 'Total form fields': details.totalFieldsChecked,
207
+ 'Fields missing autocomplete': details.missingAutocomplete.length,
208
+ 'Fields with invalid autocomplete': details.invalidAutocomplete.length,
179
209
  });
180
- logIssueList('Missing Autocomplete', result.missingAutocomplete, (el, i) => [
210
+ logIssueList('Missing Autocomplete', details.missingAutocomplete, (el, i) => [
181
211
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
182
212
  ` name: ${el.name || 'none'}, id: ${el.id || 'none'}`,
183
213
  ` label: "${el.labelText || 'none'}"`,
184
214
  ` Expected: autocomplete="${el.expectedToken}" (matched by ${el.matchedBy})`,
185
215
  ]);
186
- logIssueList('Invalid Autocomplete', result.invalidAutocomplete, (el, i) => [
216
+ logIssueList('Invalid Autocomplete', details.invalidAutocomplete, (el, i) => [
187
217
  `${i + 1}. <${el.tagName}> "${el.selector}"`,
188
218
  ` Current: autocomplete="${el.currentAutocomplete}"`,
189
219
  ` Expected: autocomplete="${el.expectedToken}"`,
@@ -5,6 +5,10 @@
5
5
  * caller is responsible for navigating the page (e.g. `await page.goto(url)`)
6
6
  * before calling this function.
7
7
  *
8
+ * The normalized buckets are built from the RAW axe results (violations and
9
+ * incomplete keep their nodes; passes/inapplicable keep rule metadata only),
10
+ * and `details` records the execution configuration.
11
+ *
8
12
  * Axe-core cannot detect all accessibility issues — manual testing and the
9
13
  * other checks in this package are still needed for complete coverage.
10
14
  */
@@ -5,11 +5,16 @@
5
5
  * caller is responsible for navigating the page (e.g. `await page.goto(url)`)
6
6
  * before calling this function.
7
7
  *
8
+ * The normalized buckets are built from the RAW axe results (violations and
9
+ * incomplete keep their nodes; passes/inapplicable keep rule metadata only),
10
+ * and `details` records the execution configuration.
11
+ *
8
12
  * Axe-core cannot detect all accessibility issues — manual testing and the
9
13
  * other checks in this package are still needed for complete coverage.
10
14
  */
11
15
  import { AxeBuilder } from '@axe-core/playwright';
12
- import { AUDIT_DISCLAIMER, DEFAULT_AXE_TAGS, DEFAULT_AXE_RESULT_FILE, } from '../constants.js';
16
+ import { DEFAULT_AXE_TAGS, DEFAULT_AXE_RESULT_FILE } from '../constants.js';
17
+ import { buildAuditResult, normalizeAxeResults } from '../utils/axe-format.js';
13
18
  import { saveAuditResult, logAuditHeader, logSummary, logOutputPaths, } from '../utils/test-harness.js';
14
19
  /**
15
20
  * Run an axe-core audit against the current page, write the result JSON, and
@@ -22,36 +27,29 @@ export async function runAxeAudit(options) {
22
27
  builder = builder.options({ rules });
23
28
  }
24
29
  const axeResults = await builder.analyze();
25
- const result = {
26
- url: page.url(),
27
- timestamp: new Date().toISOString(),
28
- violations: axeResults.violations.map((v) => ({
29
- id: v.id,
30
- impact: v.impact ?? null,
31
- description: v.description,
32
- help: v.help,
33
- helpUrl: v.helpUrl,
34
- tags: v.tags,
35
- nodes: v.nodes.map((n) => ({
36
- html: n.html,
37
- target: n.target,
38
- failureSummary: n.failureSummary,
39
- })),
40
- })),
41
- passes: axeResults.passes.length,
42
- incomplete: axeResults.incomplete.length,
43
- inapplicable: axeResults.inapplicable.length,
44
- violationCount: axeResults.violations.length,
45
- disclaimer: AUDIT_DISCLAIMER,
30
+ const buckets = normalizeAxeResults(axeResults);
31
+ const details = {
32
+ tagsRun: [...tags],
33
+ rulesOverride: rules ?? null,
34
+ violationRuleCount: axeResults.violations.length,
35
+ passRuleCount: axeResults.passes.length,
36
+ incompleteRuleCount: axeResults.incomplete.length,
37
+ inapplicableRuleCount: axeResults.inapplicable.length,
46
38
  };
39
+ const result = buildAuditResult({
40
+ source: 'axe-audit',
41
+ url: page.url(),
42
+ details,
43
+ buckets,
44
+ });
47
45
  // Output results
48
46
  logAuditHeader('Axe-core Accessibility Audit Results', 'axe-core', result.url);
49
47
  logSummary({
50
48
  Timestamp: result.timestamp,
51
- Violations: result.violationCount,
52
- Passes: result.passes,
53
- 'Incomplete (needs review)': result.incomplete,
54
- Inapplicable: result.inapplicable,
49
+ Violations: result.summary.violationCount,
50
+ Passes: result.summary.passCount,
51
+ 'Incomplete (needs review)': result.summary.incompleteCount,
52
+ Inapplicable: result.inapplicable.length,
55
53
  });
56
54
  if (result.violations.length > 0) {
57
55
  console.log('\n--- Violations ---');
@@ -71,18 +69,16 @@ export async function runAxeAudit(options) {
71
69
  });
72
70
  }
73
71
  console.log(`\n--- Summary ---`);
74
- if (result.violationCount === 0) {
72
+ if (result.summary.violationCount === 0) {
75
73
  console.log('No violations detected by axe-core');
76
74
  }
77
75
  else {
78
76
  const totalElements = result.violations.reduce((sum, v) => sum + v.nodes.length, 0);
79
- console.log(`Found ${result.violationCount} violation type(s) affecting ${totalElements} element(s)`);
77
+ console.log(`Found ${result.summary.violationCount} violation type(s) affecting ${totalElements} element(s)`);
80
78
  }
81
- // axe results already carry the disclaimer field; don't append it again.
82
79
  const resolvedPath = saveAuditResult(result, {
83
80
  ...location,
84
81
  defaultFile: DEFAULT_AXE_RESULT_FILE,
85
- includeDisclaimer: false,
86
82
  });
87
83
  logOutputPaths(resolvedPath);
88
84
  return result;
@@ -10,7 +10,8 @@
10
10
  *
11
11
  * The context this function creates is closed before it returns.
12
12
  */
13
- import { FOCUSABLE_SELECTOR, FOCUS_STYLE_PROPERTIES, EXTRA_TAB_ITERATIONS, DEFAULT_FOCUS_RESULT_FILE, DEFAULT_FOCUS_SCREENSHOT_FILE, FOCUS_OBSCURED_MIN_OVERLAP_RATIO, FOCUS_OBSCURED_MIN_OVERLAP_PX, FOCUS_OBSCURED_EXCLUDE_SELECTORS, } from '../constants.js';
13
+ import { FOCUSABLE_SELECTOR, FOCUS_STYLE_PROPERTIES, EXTRA_TAB_ITERATIONS, DEFAULT_FOCUS_RESULT_FILE, DEFAULT_FOCUS_SCREENSHOT_FILE, FOCUS_OBSCURED_MIN_OVERLAP_RATIO, FOCUS_OBSCURED_MIN_OVERLAP_PX, FOCUS_OBSCURED_EXCLUDE_SELECTORS, HTML_SNIPPET_MAX_LENGTH, } from '../constants.js';
14
+ import { buildAuditResult, normalizeFocusCheck } from '../utils/axe-format.js';
14
15
  import { resolveOutputPath, resolveScreenshotPath, saveAuditResult, takeAuditScreenshot, requireTargetUrl, logAuditHeader, logOutputPaths, } from '../utils/test-harness.js';
15
16
  // =============================================================================
16
17
  // Browser-injected styles for marking elements
@@ -106,6 +107,8 @@ export async function runFocusIndicatorCheck(options) {
106
107
  name: data.name,
107
108
  selector: data.selector ||
108
109
  `${data.tag.toLowerCase()}:nth-of-type(${data.id + 1})`,
110
+ html: data.html,
111
+ htmlTruncated: data.htmlTruncated,
109
112
  };
110
113
  });
111
114
  // Expose function to receive focus obscured reports (WCAG 2.4.12)
@@ -118,6 +121,7 @@ export async function runFocusIndicatorCheck(options) {
118
121
  styleProperties: [...FOCUS_STYLE_PROPERTIES],
119
122
  warningStyles: WARNING_STYLES,
120
123
  skipSelectors: [...skipSelectors],
124
+ htmlSnippetMaxLength: HTML_SNIPPET_MAX_LENGTH,
121
125
  obscuredConfig: {
122
126
  minOverlapRatio: FOCUS_OBSCURED_MIN_OVERLAP_RATIO,
123
127
  minOverlapPx: FOCUS_OBSCURED_MIN_OVERLAP_PX,
@@ -163,7 +167,7 @@ export async function runFocusIndicatorCheck(options) {
163
167
  // Get currently focused element IMMEDIATELY after Tab
164
168
  let currentFocusedElement = null;
165
169
  try {
166
- currentFocusedElement = await page.evaluate(() => {
170
+ currentFocusedElement = await page.evaluate((htmlSnippetMaxLength) => {
167
171
  const el = document.activeElement;
168
172
  if (!el || el === document.body)
169
173
  return null;
@@ -180,6 +184,17 @@ export async function runFocusIndicatorCheck(options) {
180
184
  const index = siblings.indexOf(element) + 1;
181
185
  return `${getSelector(parent)} > ${tag}:nth-of-type(${index})`;
182
186
  };
187
+ let html = '';
188
+ try {
189
+ html = el.outerHTML || '';
190
+ }
191
+ catch {
192
+ html = '';
193
+ }
194
+ if (!html) {
195
+ html = `<${el.tagName.toLowerCase()}>`;
196
+ }
197
+ const htmlTruncated = html.length > htmlSnippetMaxLength;
183
198
  return {
184
199
  tag: el.tagName,
185
200
  role: el.getAttribute('role'),
@@ -187,8 +202,10 @@ export async function runFocusIndicatorCheck(options) {
187
202
  el.textContent?.slice(0, 30) ||
188
203
  '',
189
204
  selector: getSelector(el),
205
+ html: htmlTruncated ? html.slice(0, htmlSnippetMaxLength) : html,
206
+ htmlTruncated,
190
207
  };
191
- });
208
+ }, HTML_SNIPPET_MAX_LENGTH);
192
209
  }
193
210
  catch {
194
211
  // Page might have navigated, use lastFocusedElement as fallback
@@ -248,8 +265,7 @@ export async function runFocusIndicatorCheck(options) {
248
265
  console.warn('Elements without visible focus indicator:', elementsWithoutFocusStyle);
249
266
  }
250
267
  // Build result
251
- const result = {
252
- url: finalPage.url(),
268
+ const details = {
253
269
  totalFocusableElements: finalFocusHistory.length,
254
270
  elementsWithFocusStyle: finalFocusHistory.length - elementsWithoutFocusStyle.length,
255
271
  elementsWithoutFocusStyle: elementsWithoutFocusStyle.length,
@@ -257,6 +273,9 @@ export async function runFocusIndicatorCheck(options) {
257
273
  tag: el.tag,
258
274
  role: el.role,
259
275
  name: el.name,
276
+ selector: el.selector,
277
+ html: el.html,
278
+ htmlTruncated: el.htmlTruncated,
260
279
  })),
261
280
  onFocusViolations,
262
281
  focusObscuredIssues,
@@ -265,12 +284,18 @@ export async function runFocusIndicatorCheck(options) {
265
284
  interrupted: false,
266
285
  screenshotPath: screenshot ? resolvedScreenshotPath : '',
267
286
  };
287
+ const result = buildAuditResult({
288
+ source: 'focus-indicator-check',
289
+ url: finalPage.url(),
290
+ details,
291
+ buckets: normalizeFocusCheck(details),
292
+ });
268
293
  // Output results
269
- logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.12 / 3.2.1', result.url);
270
- console.log(`Total focusable elements: ${result.totalFocusableElements}`);
271
- console.log(`Elements with focus style: ${result.elementsWithFocusStyle}`);
272
- console.log(`Elements WITHOUT focus style: ${result.elementsWithoutFocusStyle}`);
273
- console.log(`Elements with OBSCURED focus: ${result.elementsWithObscuredFocus}`);
294
+ logAuditHeader('Focus Indicator Check Results', 'WCAG 2.4.7 / 2.4.11 / 3.2.1', result.url);
295
+ console.log(`Total focusable elements: ${details.totalFocusableElements}`);
296
+ console.log(`Elements with focus style: ${details.elementsWithFocusStyle}`);
297
+ console.log(`Elements WITHOUT focus style: ${details.elementsWithoutFocusStyle}`);
298
+ console.log(`Elements with OBSCURED focus: ${details.elementsWithObscuredFocus}`);
274
299
  if (retryCount > 0) {
275
300
  console.log(`\nTest restarted ${retryCount} time(s) due to navigation violations`);
276
301
  }
@@ -328,7 +353,23 @@ export async function runFocusIndicatorCheck(options) {
328
353
  // Browser-injected script factory
329
354
  // =============================================================================
330
355
  function createFocusTrackerScript(args) {
331
- const { focusableSelector, styleProperties, warningStyles, skipSelectors, obscuredConfig } = args;
356
+ const { focusableSelector, styleProperties, warningStyles, skipSelectors, htmlSnippetMaxLength, obscuredConfig, } = args;
357
+ const getHtmlSnippet = (el) => {
358
+ let html = '';
359
+ try {
360
+ html = el.outerHTML || '';
361
+ }
362
+ catch {
363
+ html = '';
364
+ }
365
+ if (!html) {
366
+ return { html: `<${el.tagName.toLowerCase()}>`, htmlTruncated: false };
367
+ }
368
+ if (html.length > htmlSnippetMaxLength) {
369
+ return { html: html.slice(0, htmlSnippetMaxLength), htmlTruncated: true };
370
+ }
371
+ return { html, htmlTruncated: false };
372
+ };
332
373
  // Add warning styles
333
374
  const styleSheet = new CSSStyleSheet();
334
375
  document.adoptedStyleSheets = [...document.adoptedStyleSheets, styleSheet];
@@ -658,6 +699,7 @@ function createFocusTrackerScript(args) {
658
699
  focusedEl.textContent?.slice(0, 30) ||
659
700
  '',
660
701
  selector: getSelector(focusedEl),
702
+ ...getHtmlSnippet(focusedEl),
661
703
  },
662
704
  elementRect: {
663
705
  left: focusedRect.left,
@@ -729,10 +771,11 @@ function createFocusTrackerScript(args) {
729
771
  id,
730
772
  tag: el.tagName,
731
773
  role: el.getAttribute('role'),
732
- name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30),
774
+ name: el.getAttribute('aria-label') || el.textContent?.slice(0, 30) || '',
733
775
  hasFocusStyle,
734
776
  diff,
735
777
  selector: elementSelectors.get(el) || getSelector(el),
778
+ ...getHtmlSnippet(el),
736
779
  });
737
780
  // Check for WCAG 2.4.12 - focus obscured by fixed/sticky elements
738
781
  checkFocusObscured(el);