@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 +44 -0
- package/README.ja.md +52 -4
- package/README.md +53 -4
- package/dist/constants.d.ts +2 -0
- package/dist/constants.js +5 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/playwright/runAutoPlayDetection.js +8 -2
- package/dist/playwright/runAutocompleteAudit.js +40 -10
- package/dist/playwright/runAxeAudit.d.ts +4 -0
- package/dist/playwright/runAxeAudit.js +26 -30
- package/dist/playwright/runFocusIndicatorCheck.js +55 -12
- package/dist/playwright/runOrientationCheck.js +13 -7
- package/dist/playwright/runReflowCheck.js +18 -11
- package/dist/playwright/runTargetSizeCheck.js +42 -10
- package/dist/playwright/runTextSpacingCheck.js +52 -8
- package/dist/playwright/runTimeLimitDetector.js +36 -11
- package/dist/playwright/runZoomCheck.js +35 -11
- package/dist/schemas/index.d.ts +8 -1
- package/dist/schemas/index.js +388 -292
- package/dist/types.d.ts +137 -53
- package/dist/types.js +9 -0
- package/dist/utils/axe-format.d.ts +88 -0
- package/dist/utils/axe-format.js +361 -0
- package/dist/utils/layout.d.ts +2 -0
- package/dist/utils/layout.js +20 -1
- package/dist/utils/rule-registry.d.ts +216 -0
- package/dist/utils/rule-registry.js +220 -0
- package/dist/utils/test-harness.d.ts +2 -2
- package/dist/utils/test-harness.js +5 -6
- package/package.json +2 -1
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
|
|
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
|
|
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
|
|
package/dist/constants.d.ts
CHANGED
|
@@ -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
|
|
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
|
|
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':
|
|
177
|
-
'Fields missing autocomplete':
|
|
178
|
-
'Fields with invalid autocomplete':
|
|
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',
|
|
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',
|
|
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 {
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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.
|
|
53
|
-
'Incomplete (needs review)': result.
|
|
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
|
|
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.
|
|
270
|
-
console.log(`Total focusable elements: ${
|
|
271
|
-
console.log(`Elements with focus style: ${
|
|
272
|
-
console.log(`Elements WITHOUT focus style: ${
|
|
273
|
-
console.log(`Elements with OBSCURED focus: ${
|
|
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);
|