@d-zero/a11y-check-scenarios 0.2.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2024 D-ZERO Co., Ltd.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # `@d-zero/a11y-check-scenarios`
@@ -0,0 +1,3 @@
1
+ import type { AxeResults } from 'axe-core';
2
+ import type { Page } from 'puppeteer';
3
+ export declare function runAxePuppeteer(page: Page, lang: string, log: (log: string) => void): Promise<AxeResults>;
@@ -0,0 +1,17 @@
1
+ import { AxePuppeteer } from '@axe-core/puppeteer';
2
+ import { delay } from '@d-zero/shared/delay';
3
+ export async function runAxePuppeteer(page, lang, log) {
4
+ const mod = await import(`axe-core/locales/${lang}.json`, {
5
+ with: { type: 'json' },
6
+ });
7
+ const locale = mod.default;
8
+ log(`Analyze%dots%`);
9
+ const axeResults = await new AxePuppeteer(page)
10
+ .configure({
11
+ locale,
12
+ })
13
+ .analyze();
14
+ log(`Found ${axeResults.violations.length} violations, ${axeResults.incomplete.length} incomplete issues`);
15
+ await delay(600);
16
+ return axeResults;
17
+ }
package/dist/axe.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { A11yCheckAxeOptions } from './types.js';
2
+ declare const _default: import("@d-zero/a11y-check-core").A11yCheckScenario<A11yCheckAxeOptions>;
3
+ export default _default;
package/dist/axe.js ADDED
@@ -0,0 +1,25 @@
1
+ import { createScenario } from '@d-zero/a11y-check-core';
2
+ import { Cache } from '@d-zero/shared/cache';
3
+ import { runAxePuppeteer } from './axe-puppeteer.js';
4
+ import { convertResultsFromViolations } from './convert-results-from-violations.js';
5
+ const cache = new Cache('a11y-check/axe', '.cache');
6
+ export default createScenario((options) => {
7
+ return async (page, sizeName, log) => {
8
+ if (options?.cache === false) {
9
+ await cache.clear();
10
+ }
11
+ const axeLog = (message) => log(`🪓 ${message}`);
12
+ const key = page.url() + '#' + sizeName;
13
+ const cached = await cache.load(key, (key, value) => {
14
+ if (key === 'timestamp') {
15
+ return new Date(Date.parse(value));
16
+ }
17
+ return value;
18
+ });
19
+ if (cached) {
20
+ return convertResultsFromViolations(page, cached, sizeName, options?.screenshot ?? false, axeLog);
21
+ }
22
+ const axeResults = await runAxePuppeteer(page, options?.lang ?? 'ja', axeLog);
23
+ return convertResultsFromViolations(page, axeResults, sizeName, options?.screenshot ?? false, axeLog);
24
+ };
25
+ });
@@ -0,0 +1,8 @@
1
+ import type { Style } from '@d-zero/a11y-check-core';
2
+ import type { NodeResult } from 'axe-core';
3
+ import type { Page } from 'puppeteer';
4
+ export declare function convertResultsFromNode(page: Page, node: NodeResult, screenshot: boolean, log: (log: string) => void): Promise<{
5
+ screenshot: string | null;
6
+ style: Style | null;
7
+ landmark: string | null;
8
+ } | null>;
@@ -0,0 +1,89 @@
1
+ import path from 'node:path';
2
+ import { hash } from '@d-zero/shared/hash';
3
+ export async function convertResultsFromNode(page, node, screenshot, log) {
4
+ const target = node.target[0] && typeof node.target[0] === 'string' ? node.target[0] : null;
5
+ if (!target) {
6
+ return null;
7
+ }
8
+ log(`Node: ${target}`);
9
+ let screenshotName = null;
10
+ if (screenshot && target) {
11
+ log(`Screenshot: ${target}`);
12
+ const fileElement = await page.waitForSelector(target);
13
+ if (fileElement) {
14
+ const url = page.url();
15
+ const ssName = hash(url + target) + '.png';
16
+ const el = await fileElement
17
+ .screenshot({
18
+ path: path.resolve(process.cwd(), '.cache', ssName),
19
+ })
20
+ .catch(() => null);
21
+ if (el) {
22
+ screenshotName = ssName;
23
+ }
24
+ }
25
+ }
26
+ log(`Get style: ${target}`);
27
+ const style = await page.evaluate((selector) => {
28
+ const el = document.querySelector(selector);
29
+ if (!el) {
30
+ return null;
31
+ }
32
+ const style = globalThis.getComputedStyle(el);
33
+ const closest = {
34
+ closestBackgroundColor: null,
35
+ closestBackgroundImage: null,
36
+ };
37
+ let current = el.parentElement;
38
+ while (current) {
39
+ const currentStyle = globalThis.getComputedStyle(current);
40
+ if (currentStyle.backgroundColor !== 'rgba(0, 0, 0, 0)') {
41
+ closest.closestBackgroundColor = currentStyle.backgroundColor;
42
+ }
43
+ if (currentStyle.backgroundImage !== 'none') {
44
+ closest.closestBackgroundImage = currentStyle.backgroundImage;
45
+ }
46
+ if (closest.closestBackgroundColor || closest.closestBackgroundImage) {
47
+ break;
48
+ }
49
+ current = current.parentElement;
50
+ }
51
+ return {
52
+ color: style.color,
53
+ backgroundColor: style.backgroundColor,
54
+ backgroundImage: style.backgroundImage,
55
+ ...closest,
56
+ };
57
+ }, target);
58
+ const landmark = await page.evaluate((selector) => {
59
+ const el = document.querySelector(selector);
60
+ if (!el) {
61
+ return null;
62
+ }
63
+ if (el.closest('header, [role="banner"]')) {
64
+ return 'header';
65
+ }
66
+ if (el.closest('footer, [role="contentinfo"]')) {
67
+ return 'footer';
68
+ }
69
+ if (el.closest('nav, [role="navigation"]')) {
70
+ return 'nav';
71
+ }
72
+ if (el.closest('main, [role="main"]')) {
73
+ return 'main';
74
+ }
75
+ if (el.closest('aside, [role="complementary"]')) {
76
+ return 'aside';
77
+ }
78
+ const identicalComponent = el.closest('[id]');
79
+ if (identicalComponent) {
80
+ return `#${identicalComponent.id}`;
81
+ }
82
+ return null;
83
+ }, target);
84
+ return {
85
+ screenshot: screenshotName,
86
+ style,
87
+ landmark,
88
+ };
89
+ }
@@ -0,0 +1,4 @@
1
+ import type { A11yCheckResult } from '@d-zero/a11y-check-core';
2
+ import type { AxeResults } from 'axe-core';
3
+ import type { Page } from 'puppeteer';
4
+ export declare function convertResultsFromViolations(page: Page, axeResults: AxeResults, sizeName: string, screenshot: boolean, log: (log: string) => void): Promise<A11yCheckResult[]>;
@@ -0,0 +1,41 @@
1
+ import { convertResultsFromNode } from './convert-results-from-node.js';
2
+ import { detectLevel } from './detect-level.js';
3
+ import { inferExplanation } from './infer-explanation.js';
4
+ import { p } from './pargraph.js';
5
+ import { tagsToSCs } from './tags-to-scs.js';
6
+ export async function convertResultsFromViolations(page, axeResults, sizeName, screenshot, log) {
7
+ const results = [];
8
+ const violations = [...axeResults.incomplete, ...axeResults.violations];
9
+ for (const violation of violations) {
10
+ const nodeResults = violation.nodes;
11
+ for (const node of nodeResults) {
12
+ const nodeResult = await convertResultsFromNode(page, node, screenshot, log);
13
+ const explanation = inferExplanation(violation.id, node, nodeResult?.style ?? null);
14
+ results.push({
15
+ id: '',
16
+ url: page.url(),
17
+ tool: `${axeResults.testEngine.name} (v${axeResults.testEngine.version})`,
18
+ timestamp: new Date(axeResults.timestamp),
19
+ component: nodeResult?.landmark ?? null,
20
+ environment: sizeName,
21
+ targetNode: {
22
+ value: node.html,
23
+ },
24
+ asIs: {
25
+ value: p(`${violation.help}\n(${violation.helpUrl})`, violation.description, node.failureSummary),
26
+ },
27
+ toBe: {
28
+ value: p(explanation?.main),
29
+ },
30
+ explanation: {
31
+ value: p(explanation?.help),
32
+ },
33
+ wcagVersion: violation.tags.includes('wcag2a') ? 'WCAG2.0' : 'WCAG2.1',
34
+ scNumber: tagsToSCs(violation.tags),
35
+ level: detectLevel(violation.tags),
36
+ screenshot: nodeResult?.screenshot ?? null,
37
+ });
38
+ }
39
+ }
40
+ return results;
41
+ }
@@ -0,0 +1 @@
1
+ export declare function detectLevel(tags: readonly string[]): "A" | "AA" | "AAA" | null;
@@ -0,0 +1,9 @@
1
+ export function detectLevel(tags) {
2
+ return tags.includes('wcag2a')
3
+ ? 'A'
4
+ : tags.includes('wcag2aa')
5
+ ? 'AA'
6
+ : tags.includes('wcag2aaa')
7
+ ? 'AAA'
8
+ : null;
9
+ }
@@ -0,0 +1 @@
1
+ export declare function detectLevel(tags: readonly string[]): "A" | "AA" | "AAA" | null;
@@ -0,0 +1,9 @@
1
+ export function detectLevel(tags) {
2
+ return tags.includes('wcag2')
3
+ ? 'A'
4
+ : tags.includes('wcag21')
5
+ ? 'AA'
6
+ : tags.includes('wcag2aaa')
7
+ ? 'AAA'
8
+ : null;
9
+ }
@@ -0,0 +1,3 @@
1
+ export { default as scenario01 } from './scenario.js';
2
+ export { default as scenario02 } from './scenario2.js';
3
+ export * from './types.js';
package/dist/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { default as scenario01 } from './scenario.js';
2
+ export { default as scenario02 } from './scenario2.js';
3
+ export * from './types.js';
@@ -0,0 +1,4 @@
1
+ import type { AxeRuleId, Explanation } from './types.js';
2
+ import type { NodeResult } from 'axe-core';
3
+ import { type Style } from '@d-zero/a11y-check-core';
4
+ export declare function inferExplanation(id: AxeRuleId, node: NodeResult, style: Style | null): Explanation | null;
@@ -0,0 +1,390 @@
1
+ import { colorContrastCheck, ColorContrastError, } from '@d-zero/a11y-check-core';
2
+ import { br, p } from './pargraph.js';
3
+ export function inferExplanation(id, node, style) {
4
+ switch (id) {
5
+ case 'accesskeys': {
6
+ return null;
7
+ }
8
+ case 'area-alt': {
9
+ return {
10
+ main: 'alt属性に「______」を設定してください。',
11
+ help: '',
12
+ };
13
+ }
14
+ case 'aria-allowed-attr': {
15
+ return {
16
+ main: 'aria属性に「______」を設定してください。',
17
+ help: '',
18
+ };
19
+ }
20
+ case 'aria-allowed-role': {
21
+ return null;
22
+ }
23
+ case 'aria-braille-equivalent': {
24
+ return null;
25
+ }
26
+ case 'aria-command-name': {
27
+ return null;
28
+ }
29
+ case 'aria-conditional-attr': {
30
+ return null;
31
+ }
32
+ case 'aria-deprecated-role': {
33
+ return null;
34
+ }
35
+ case 'aria-dialog-name': {
36
+ return null;
37
+ }
38
+ case 'aria-hidden-body': {
39
+ return null;
40
+ }
41
+ case 'aria-hidden-focus': {
42
+ return null;
43
+ }
44
+ case 'aria-input-field-name': {
45
+ return null;
46
+ }
47
+ case 'aria-meter-name': {
48
+ return null;
49
+ }
50
+ case 'aria-progressbar-name': {
51
+ return null;
52
+ }
53
+ case 'aria-prohibited-attr': {
54
+ return null;
55
+ }
56
+ case 'aria-required-attr': {
57
+ return null;
58
+ }
59
+ case 'aria-required-children': {
60
+ return null;
61
+ }
62
+ case 'aria-required-parent': {
63
+ return null;
64
+ }
65
+ case 'aria-roledescription': {
66
+ return null;
67
+ }
68
+ case 'aria-roles': {
69
+ return null;
70
+ }
71
+ case 'aria-text': {
72
+ return null;
73
+ }
74
+ case 'aria-toggle-field-name': {
75
+ return null;
76
+ }
77
+ case 'aria-tooltip-name': {
78
+ return null;
79
+ }
80
+ case 'aria-treeitem-name': {
81
+ return null;
82
+ }
83
+ case 'aria-valid-attr-value': {
84
+ return null;
85
+ }
86
+ case 'aria-valid-attr': {
87
+ return null;
88
+ }
89
+ case 'audio-caption': {
90
+ return null;
91
+ }
92
+ case 'autocomplete-valid': {
93
+ return null;
94
+ }
95
+ case 'avoid-inline-spacing': {
96
+ return null;
97
+ }
98
+ case 'blink': {
99
+ return null;
100
+ }
101
+ case 'button-name': {
102
+ return null;
103
+ }
104
+ case 'bypass': {
105
+ return null;
106
+ }
107
+ case 'color-contrast-enhanced': {
108
+ return null;
109
+ }
110
+ case 'color-contrast': {
111
+ if (node.any?.[0]) {
112
+ let main = '';
113
+ let contrastResult = style ? colorContrastCheck(style) : null;
114
+ let needCheck = null;
115
+ let message = null;
116
+ switch (contrastResult) {
117
+ case null: {
118
+ needCheck = 'N/A';
119
+ message = '要素のスタイル取得に失敗しました。';
120
+ break;
121
+ }
122
+ case ColorContrastError.DOES_NOT_DETERMINE_FOREGROUND: {
123
+ needCheck = 'N/A';
124
+ message = 'フォントの色が判定できません。';
125
+ break;
126
+ }
127
+ case ColorContrastError.DOES_NOT_DETERMINE_BACKGROUND: {
128
+ needCheck = 'WARNING';
129
+ message = '背景色が判定できないかったので#FFFFFFとして判定しています。';
130
+ contrastResult = style
131
+ ? colorContrastCheck({
132
+ ...style,
133
+ backgroundColor: 'rgb(255, 255, 255)',
134
+ })
135
+ : null;
136
+ break;
137
+ }
138
+ case ColorContrastError.FOREGROUND_COLOR_HAS_ALPHA: {
139
+ needCheck = 'N/A';
140
+ message = 'フォントの色に透明度が設定されています。';
141
+ break;
142
+ }
143
+ case ColorContrastError.BACKGROUND_COLOR_HAS_ALPHA: {
144
+ needCheck = 'N/A';
145
+ message = '背景色に透明度が設定されています。';
146
+ break;
147
+ }
148
+ default: {
149
+ if (node.any[0].message.includes('判定できません')) {
150
+ needCheck = 'WARNING';
151
+ }
152
+ if (contrastResult.AA === false) {
153
+ main = p('AAのコントラスト比を満たしていません。', br(`前景色: ${contrastResult.foreground.hex}`, `背景色: ${contrastResult.background.hex}`, `コントラスト比: ${contrastResult.ratioText}`), 'コントラスト比は4.5:1を満たすようにしてください。');
154
+ }
155
+ }
156
+ }
157
+ return {
158
+ main,
159
+ help: p(needCheck ? `${needCheck} 目視で確認してください。` : null, node.any[0].message, message, typeof contrastResult === 'number'
160
+ ? null
161
+ : br(`前景色: ${contrastResult?.foreground.hex ?? '判定不可'}`, `背景色: ${contrastResult?.background.hex ?? '判定不可'}`, `コントラスト比: ${contrastResult?.ratioText ?? '判定不可'}`, `AA: ${contrastResult?.AA ? '適合' : '不適合'}`, `AAA: ${contrastResult?.AAA ? '適合' : '不適合'}`), br(`font-size: ${node.any[0].data.fontSize}`, `font-weight: ${node.any[0].data.fontWeight}`, `color: ${style?.color ?? null}`, `background-color: ${style?.backgroundColor ?? null}`, `background-image: ${style?.backgroundImage ?? null}`, `先祖要素 background-color: ${style?.closestBackgroundColor ?? null}`, `先祖要素 background-image: ${style?.closestBackgroundImage ?? null}`)),
162
+ };
163
+ }
164
+ return null;
165
+ }
166
+ case 'css-orientation-lock': {
167
+ return null;
168
+ }
169
+ case 'definition-list': {
170
+ return null;
171
+ }
172
+ // cspell:disable-next-line
173
+ case 'dlitem': {
174
+ return null;
175
+ }
176
+ case 'document-title': {
177
+ return null;
178
+ }
179
+ case 'duplicate-id-active': {
180
+ return null;
181
+ }
182
+ case 'duplicate-id-aria': {
183
+ return null;
184
+ }
185
+ case 'duplicate-id': {
186
+ return null;
187
+ }
188
+ case 'empty-heading': {
189
+ return null;
190
+ }
191
+ case 'empty-table-header': {
192
+ // td要素に変更してください。
193
+ return null;
194
+ }
195
+ case 'focus-order-semantics': {
196
+ return null;
197
+ }
198
+ case 'form-field-multiple-labels': {
199
+ // 「性別」のラベルはfieldset/legend要素をつかって複数のラジオボタンをグルーピングするように修正してください。
200
+ return null;
201
+ }
202
+ case 'frame-focusable-content': {
203
+ return null;
204
+ }
205
+ case 'frame-tested': {
206
+ return null;
207
+ }
208
+ case 'frame-title-unique': {
209
+ return null;
210
+ }
211
+ case 'frame-title': {
212
+ return null;
213
+ }
214
+ case 'heading-order': {
215
+ return null;
216
+ }
217
+ case 'hidden-content': {
218
+ return null;
219
+ }
220
+ case 'html-has-lang': {
221
+ return {
222
+ main: 'html要素にlang属性を設定してください。日本語の場合は「ja」、英語の場合は「en」を設定してください。',
223
+ help: '',
224
+ };
225
+ }
226
+ case 'html-lang-valid': {
227
+ return null;
228
+ }
229
+ case 'html-xml-lang-mismatch': {
230
+ return null;
231
+ }
232
+ case 'identical-links-same-purpose': {
233
+ return null;
234
+ }
235
+ case 'image-alt': {
236
+ return null;
237
+ }
238
+ case 'image-redundant-alt': {
239
+ return null;
240
+ }
241
+ case 'input-button-name': {
242
+ return null;
243
+ }
244
+ case 'input-image-alt': {
245
+ return null;
246
+ }
247
+ case 'label-content-name-mismatch': {
248
+ return null;
249
+ }
250
+ case 'label-title-only': {
251
+ return null;
252
+ }
253
+ case 'label': {
254
+ return null;
255
+ }
256
+ case 'landmark-banner-is-top-level': {
257
+ return null;
258
+ }
259
+ case 'landmark-complementary-is-top-level': {
260
+ return null;
261
+ }
262
+ case 'landmark-contentinfo-is-top-level': {
263
+ return null;
264
+ }
265
+ case 'landmark-main-is-top-level': {
266
+ return null;
267
+ }
268
+ case 'landmark-no-duplicate-banner': {
269
+ return null;
270
+ }
271
+ case 'landmark-no-duplicate-contentinfo': {
272
+ return null;
273
+ }
274
+ case 'landmark-no-duplicate-main': {
275
+ return null;
276
+ }
277
+ case 'landmark-one-main': {
278
+ return {
279
+ main: 'ページのメインコンテンツをmain要素で囲んでください。header要素やfooter要素とは別の領域が望ましいです。',
280
+ help: '',
281
+ };
282
+ }
283
+ case 'landmark-unique': {
284
+ return null;
285
+ }
286
+ case 'link-in-text-block': {
287
+ return null;
288
+ }
289
+ case 'link-name': {
290
+ return null;
291
+ }
292
+ case 'list': {
293
+ return null;
294
+ }
295
+ case 'listitem': {
296
+ return null;
297
+ }
298
+ case 'marquee': {
299
+ return null;
300
+ }
301
+ case 'meta-refresh-no-exceptions': {
302
+ return null;
303
+ }
304
+ case 'meta-refresh': {
305
+ return null;
306
+ }
307
+ case 'meta-viewport-large': {
308
+ return null;
309
+ }
310
+ case 'meta-viewport': {
311
+ return null;
312
+ }
313
+ case 'nested-interactive': {
314
+ return null;
315
+ }
316
+ case 'no-autoplay-audio': {
317
+ return null;
318
+ }
319
+ case 'object-alt': {
320
+ return null;
321
+ }
322
+ case 'p-as-heading': {
323
+ return null;
324
+ }
325
+ case 'page-has-heading-one': {
326
+ return null;
327
+ }
328
+ case 'presentation-role-conflict': {
329
+ return null;
330
+ }
331
+ case 'region': {
332
+ return {
333
+ main: 'main要素、header要素、footer要素などのランドマーク要素のいずれかの中にあるようにしてください。',
334
+ help: '',
335
+ };
336
+ }
337
+ case 'role-img-alt': {
338
+ return null;
339
+ }
340
+ case 'scope-attr-valid': {
341
+ return null;
342
+ }
343
+ case 'scrollable-region-focusable': {
344
+ // tabindex="0"を設定してください。
345
+ return null;
346
+ }
347
+ case 'select-name': {
348
+ return null;
349
+ }
350
+ case 'server-side-image-map': {
351
+ return null;
352
+ }
353
+ case 'skip-link': {
354
+ return null;
355
+ }
356
+ case 'summary-name': {
357
+ return null;
358
+ }
359
+ case 'svg-img-alt': {
360
+ return null;
361
+ }
362
+ case 'tabindex': {
363
+ return null;
364
+ }
365
+ case 'table-duplicate-name': {
366
+ return null;
367
+ }
368
+ case 'table-fake-caption': {
369
+ return null;
370
+ }
371
+ case 'target-size': {
372
+ return null;
373
+ }
374
+ case 'td-has-header': {
375
+ return null;
376
+ }
377
+ case 'td-headers-attr': {
378
+ return null;
379
+ }
380
+ case 'th-has-data-cells': {
381
+ return null;
382
+ }
383
+ case 'valid-lang': {
384
+ return null;
385
+ }
386
+ case 'video-caption': {
387
+ return null;
388
+ }
389
+ }
390
+ }
@@ -0,0 +1,2 @@
1
+ export declare function p(...texts: (string | null | undefined)[]): string;
2
+ export declare function br(...texts: (string | null | undefined)[]): string;
@@ -0,0 +1,6 @@
1
+ export function p(...texts) {
2
+ return texts.filter(Boolean).join('\n\n');
3
+ }
4
+ export function br(...texts) {
5
+ return texts.filter(Boolean).join('\n');
6
+ }
package/dist/sc.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare function sc(tags: string[]): string;
package/dist/sc.js ADDED
@@ -0,0 +1,23 @@
1
+ import { wcag } from '@d-zero/db-wcag';
2
+ const scVersions = new Set(
3
+ // @ts-ignore
4
+ Object.keys(wcag.successCriterions['wcag_2.2'].en));
5
+ export function sc(tags) {
6
+ return tags.map(_sc).filter(Boolean).join('\n');
7
+ }
8
+ function _sc(tag) {
9
+ if (!tag.startsWith('wcag') || tag.endsWith('a')) {
10
+ return null;
11
+ }
12
+ const num = /\d+/.exec(tag)?.[0];
13
+ if (!num) {
14
+ return null;
15
+ }
16
+ for (const version of scVersions) {
17
+ const crushedNum = version.replaceAll('.', '');
18
+ if (tag === crushedNum) {
19
+ return version;
20
+ }
21
+ }
22
+ return null;
23
+ }
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@d-zero/a11y-check-core").ScenarioCreator<import("@d-zero/a11y-check-core").CoreOptions>;
2
+ export default _default;
@@ -0,0 +1,260 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createScenario } from '@d-zero/a11y-check-core';
4
+ import { BinaryCache } from '@d-zero/shared/cache';
5
+ import { delay } from '@d-zero/shared/delay';
6
+ import { hash } from '@d-zero/shared/hash';
7
+ const scenarioId = 'a11y-check/scenario01';
8
+ export default createScenario((options) => {
9
+ const cache = new BinaryCache(scenarioId, options?.cacheDir);
10
+ const tests = [
11
+ {
12
+ name: 'SC 2.5.8 Target Size (Minimum)',
13
+ description: 'Check if the target size is at least 44px by 44px',
14
+ async test(page) {
15
+ await page.evaluate(() => {
16
+ const hitEl = [
17
+ ...document.querySelectorAll('a,button,input,select,textarea,label,summary'),
18
+ ].filter((el) => !(el.matches('input,select,textarea') && el.closest('label')));
19
+ const hits = [...hitEl]
20
+ .map((el) => {
21
+ const box = el.getBoundingClientRect();
22
+ if (box.width < 0 && box.height < 0) {
23
+ return null;
24
+ }
25
+ if (box.width < 24 || box.height < 24) {
26
+ return {
27
+ type: 'under',
28
+ x: box.left + box.width / 2,
29
+ y: box.top + box.height / 2,
30
+ rect: box,
31
+ };
32
+ }
33
+ return {
34
+ type: 'over',
35
+ rect: box,
36
+ };
37
+ })
38
+ .filter((hit) => hit !== null);
39
+ const cover = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
40
+ const width = document.documentElement.scrollWidth;
41
+ const height = document.documentElement.scrollHeight;
42
+ cover.id = '__a11y-check-scenario01';
43
+ cover.viewBox.baseVal.x = 0;
44
+ cover.viewBox.baseVal.y = 0;
45
+ cover.viewBox.baseVal.width = width;
46
+ cover.viewBox.baseVal.height = height;
47
+ cover.setAttribute('width', width + 'px');
48
+ cover.setAttribute('height', height + 'px');
49
+ cover.style.position = 'absolute';
50
+ cover.style.top = '0';
51
+ cover.style.left = '0';
52
+ cover.style.zIndex = '100000';
53
+ cover.style.pointerEvents = 'none';
54
+ const g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
55
+ for (const hit of hits) {
56
+ if (hit.type === 'under') {
57
+ const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
58
+ circle.cx.baseVal.value = hit.x;
59
+ circle.cy.baseVal.value = hit.y;
60
+ circle.r.baseVal.value = 12;
61
+ circle.style.fill = 'rgba(255, 0, 0, 0.5)';
62
+ g.append(circle);
63
+ }
64
+ const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
65
+ rect.x.baseVal.value = hit.rect.left;
66
+ rect.y.baseVal.value = hit.rect.top;
67
+ rect.width.baseVal.value = hit.rect.width;
68
+ rect.height.baseVal.value = hit.rect.height;
69
+ rect.style.fill = 'rgba(255, 0, 0, 0.5)';
70
+ g.append(rect);
71
+ }
72
+ cover.append(g);
73
+ document.body.append(cover);
74
+ });
75
+ },
76
+ async cleanUp(page) {
77
+ await page.evaluate(() => {
78
+ const cover = document.getElementById('__a11y-check-scenario01');
79
+ if (cover) {
80
+ cover.remove();
81
+ }
82
+ });
83
+ },
84
+ },
85
+ {
86
+ name: 'SC 1.4.4 Text Resize',
87
+ description: 'Except for captions and images of text, text can be resized without assistive technology up to 200 percent without loss of content or functionality.',
88
+ async test(page) {
89
+ await page.evaluate(() => {
90
+ const style = document.createElement('style');
91
+ style.id = '__a11y-check-scenario01';
92
+ style.textContent = `:root { font-size: 200% !important; }`;
93
+ document.head.append(style);
94
+ });
95
+ },
96
+ async cleanUp(page) {
97
+ await page.evaluate(() => {
98
+ const style = document.getElementById('__a11y-check-scenario01');
99
+ if (style) {
100
+ style.remove();
101
+ }
102
+ });
103
+ },
104
+ },
105
+ {
106
+ name: 'SC 1.4.12 Text Spacing',
107
+ description: 'Line spacing (leading) is at least space-and-a-half within paragraphs, and paragraph spacing is at least 1.5 times larger than the line spacing.',
108
+ async test(page) {
109
+ await page.evaluate(() => {
110
+ const style = document.createElement('style');
111
+ style.id = '__a11y-check-scenario01';
112
+ style.textContent = `* {
113
+ /* Line height (line spacing) to at least 1.5 times the font size; */
114
+ line-height: 1.5 !important;
115
+ /* Spacing following paragraphs to at least 2 times the font size; */
116
+ margin-block-end: 2em !important;
117
+ /* Letter spacing (tracking) to at least 0.12 times the font size; */
118
+ letter-spacing: 0.12em !important;
119
+ /* Word spacing to at least 0.16 times the font size. */
120
+ word-spacing: 0.16em !important;
121
+ }`;
122
+ document.head.append(style);
123
+ });
124
+ },
125
+ async cleanUp(page) {
126
+ await page.evaluate(() => {
127
+ const style = document.getElementById('__a11y-check-scenario01');
128
+ if (style) {
129
+ style.remove();
130
+ }
131
+ });
132
+ },
133
+ },
134
+ ];
135
+ return {
136
+ id: scenarioId,
137
+ async exec(page, sizeName, logger) {
138
+ // Wait Scroll End
139
+ await delay(2000);
140
+ if (options?.cache === false) {
141
+ await cache.clear();
142
+ }
143
+ const needAnalysis = [];
144
+ for (const test of tests) {
145
+ const key = page.url() + '#' + sizeName + '@' + test.name + '.png';
146
+ logger(test.name + ': Check if the key exists in the cache');
147
+ const exists = await cache.exists(key);
148
+ if (exists) {
149
+ logger(test.name + ': The key exists in the cache');
150
+ return {};
151
+ }
152
+ logger(`${test.name}: ${test.description}`);
153
+ await test.test(page);
154
+ logger(`${test.name}: Screenshot`);
155
+ let bin;
156
+ let retry = 0;
157
+ // eslint-disable-next-line no-constant-condition
158
+ while (true) {
159
+ bin = await page.screenshot({
160
+ type: 'png',
161
+ fullPage: true,
162
+ encoding: 'binary',
163
+ });
164
+ if (bin.length > 1) {
165
+ break;
166
+ }
167
+ if (retry > 5) {
168
+ throw new Error([
169
+ //
170
+ 'Failed to take a screenshot (scenario01)',
171
+ `\tURL: ${page.url()}`,
172
+ `\tSize: ${sizeName}`,
173
+ `\tTest: ${test.name}`,
174
+ ].join('\n'));
175
+ }
176
+ const retryTime = retry * 1000;
177
+ logger(`${test.name}: Retry (${retry}times) to take a screenshot%dots% %countdown(${retryTime},${hash(key)}_${retryTime})%ms`);
178
+ await delay(retryTime);
179
+ retry++;
180
+ }
181
+ const cachedFileName = await cache.store(key, bin);
182
+ logger(`${test.name}: Clean up`);
183
+ await test.cleanUp(page);
184
+ needAnalysis.push({
185
+ scenarioId,
186
+ subKey: test.name,
187
+ id: '',
188
+ url: await page.url(),
189
+ tool: `a11y-check-scenario01: ${test.name}`,
190
+ timestamp: new Date(),
191
+ component: null,
192
+ environment: sizeName,
193
+ data: cachedFileName,
194
+ });
195
+ }
196
+ return {
197
+ needAnalysis,
198
+ };
199
+ },
200
+ async analyze(results, logger) {
201
+ for (const test of tests) {
202
+ const element = [];
203
+ for (const data of results) {
204
+ if (!data.data) {
205
+ logger(`No screenshot: ${data.url}`);
206
+ continue;
207
+ }
208
+ if (data.subKey !== test.name) {
209
+ continue;
210
+ }
211
+ const img = await cache.loadDirectly(data.data);
212
+ if (!img) {
213
+ logger(`Failed to load the screenshot: ${data.url}`);
214
+ continue;
215
+ }
216
+ const base64 = Buffer.from(img).toString('base64');
217
+ element.push(`
218
+ <p>${new URL(data.url).pathname} [${data.environment}]: ${data.subKey}</p>
219
+ <img src="data:image/png;base64,${base64}" alt="${data.tool}" width="100%">
220
+ `);
221
+ }
222
+ const html = `<!DOCTYPE html>
223
+ <html lang="ja">
224
+ <head>
225
+ <meta charset="UTF-8">
226
+ <title>Accessibility Check Scenario 01: ${test.name}</title>
227
+ <style>
228
+ html {
229
+ font-size: 100%;
230
+ }
231
+ body {
232
+ background: black;
233
+ }
234
+ p {
235
+ position: sticky;
236
+ top: 0;
237
+ background: #000;
238
+ color: #fff;
239
+ padding: 1em;
240
+ }
241
+ img {
242
+ max-width: 100%;
243
+ height: auto;
244
+ width: 100rem;
245
+ margin: 0 auto;
246
+ display: block;
247
+ }
248
+ </style>
249
+ </head>
250
+ <body>
251
+ ${element.join('\n')}
252
+ </body>
253
+ </html>`;
254
+ const fileName = `a11y-check-scenario01_${encodeURI(test.name)}.html`;
255
+ logger(`Saving the result to ${fileName}`);
256
+ await fs.writeFile(path.resolve(process.cwd(), fileName), html, 'utf8');
257
+ }
258
+ },
259
+ };
260
+ });
@@ -0,0 +1,2 @@
1
+ declare const _default: import("@d-zero/a11y-check-core").ScenarioCreator<import("@d-zero/a11y-check-core").CoreOptions>;
2
+ export default _default;
@@ -0,0 +1,77 @@
1
+ import { createScenario } from '@d-zero/a11y-check-core';
2
+ import { Cache } from '@d-zero/shared/cache';
3
+ import c from 'ansi-colors';
4
+ const scenarioId = 'a11y-check/scenario02';
5
+ export default createScenario((options) => {
6
+ const cache = new Cache(scenarioId, options?.cacheDir);
7
+ return {
8
+ id: scenarioId,
9
+ async exec(page, sizeName, logger) {
10
+ if (options?.cache === false) {
11
+ await cache.clear();
12
+ }
13
+ const key = page.url() + '#' + sizeName + '@' + scenarioId;
14
+ const cached = await cache.load(key, (key, value) => {
15
+ if (key === 'timestamp') {
16
+ return new Date(Date.parse(value));
17
+ }
18
+ return value;
19
+ });
20
+ if (cached) {
21
+ return {
22
+ needAnalysis: cached,
23
+ };
24
+ }
25
+ const needAnalysis = [];
26
+ const navigations = ['header', 'nav', 'footer', "[class*='nav' i]"];
27
+ for (const selector of navigations) {
28
+ const logBase = `Finding "${selector}"`;
29
+ logger(`Finding "${selector}"`);
30
+ page.on('console', (msg) => {
31
+ const msgType = msg.type();
32
+ switch (msgType) {
33
+ case 'error': {
34
+ logger(`${logBase}: ${c.red(msg.text())}`);
35
+ break;
36
+ }
37
+ default: {
38
+ logger(`${logBase}: ${c.gray(msg.text())}`);
39
+ break;
40
+ }
41
+ }
42
+ });
43
+ const outerHTML = await page.evaluate((selector) => {
44
+ return [...document.querySelectorAll(selector)].map((el) => el.outerHTML);
45
+ }, selector);
46
+ // get selectorがいる
47
+ // HTMLの重複をどうにかする
48
+ if (!outerHTML || outerHTML.length === 0) {
49
+ continue;
50
+ }
51
+ logger(`Found "${selector}" ${outerHTML.length} elements`);
52
+ needAnalysis.push({
53
+ scenarioId,
54
+ id: '',
55
+ url: await page.url(),
56
+ tool: 'a11y-check-scenario02',
57
+ timestamp: new Date(),
58
+ component: selector,
59
+ environment: sizeName,
60
+ data: outerHTML.join('\n\n'),
61
+ });
62
+ }
63
+ await cache.store(key, needAnalysis);
64
+ return {
65
+ needAnalysis,
66
+ };
67
+ },
68
+ analyze(results, logger) {
69
+ for (const data of results) {
70
+ if (!data.data) {
71
+ logger(`No navigations: ${data.url}`);
72
+ continue;
73
+ }
74
+ }
75
+ },
76
+ };
77
+ });
@@ -0,0 +1,2 @@
1
+ export declare function tagsToSCs(tags: string[]): string;
2
+ export declare function tagToSC(tag: string): "1.1.1" | "1.2.1" | "1.2.2" | "1.2.3" | "1.2.4" | "1.2.5" | "1.2.6" | "1.2.7" | "1.2.8" | "1.2.9" | "1.3.1" | "1.3.2" | "1.3.3" | "1.3.4" | "1.3.5" | "1.3.6" | "1.4.1" | "1.4.2" | "1.4.3" | "1.4.4" | "1.4.5" | "1.4.6" | "1.4.7" | "1.4.8" | "1.4.9" | "1.4.10" | "1.4.11" | "1.4.12" | "1.4.13" | "2.1.1" | "2.1.2" | "2.1.3" | "2.1.4" | "2.2.1" | "2.2.2" | "2.2.3" | "2.2.4" | "2.2.5" | "2.2.6" | "2.3.1" | "2.3.2" | "2.3.3" | "2.4.1" | "2.4.2" | "2.4.3" | "2.4.4" | "2.4.5" | "2.4.6" | "2.4.7" | "2.4.8" | "2.4.9" | "2.4.10" | "2.4.11" | "2.4.12" | "2.4.13" | "2.5.1" | "2.5.2" | "2.5.3" | "2.5.4" | "2.5.5" | "2.5.6" | "2.5.7" | "2.5.8" | "3.1.1" | "3.1.2" | "3.1.3" | "3.1.4" | "3.1.5" | "3.1.6" | "3.2.1" | "3.2.2" | "3.2.3" | "3.2.4" | "3.2.5" | "3.2.6" | "3.3.1" | "3.3.2" | "3.3.3" | "3.3.4" | "3.3.5" | "3.3.6" | "3.3.7" | "3.3.8" | "3.3.9" | "4.1.2" | "4.1.3" | null;
@@ -0,0 +1,23 @@
1
+ import { wcag } from '@d-zero/db-wcag';
2
+ const scVersions = new Set(
3
+ // @ts-ignore
4
+ Object.keys(wcag.successCriterions['wcag_2.2'].en));
5
+ export function tagsToSCs(tags) {
6
+ return tags.map(tagToSC).filter(Boolean).join('\n');
7
+ }
8
+ export function tagToSC(tag) {
9
+ if (!tag.startsWith('wcag') || tag.endsWith('a')) {
10
+ return null;
11
+ }
12
+ const num = /\d+/.exec(tag)?.[0];
13
+ if (!num) {
14
+ return null;
15
+ }
16
+ for (const version of scVersions) {
17
+ const crushedNum = version.replaceAll('.', '');
18
+ if (num === crushedNum) {
19
+ return version;
20
+ }
21
+ }
22
+ return null;
23
+ }
@@ -0,0 +1,2 @@
1
+ import type { CoreOptions } from '@d-zero/a11y-check-core';
2
+ export type ScenarioOptions = CoreOptions & {};
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@d-zero/a11y-check-scenarios",
3
+ "version": "0.2.0",
4
+ "description": "Accessibility Checker Scenario Collection",
5
+ "author": "D-ZERO",
6
+ "license": "MIT",
7
+ "private": false,
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "type": "module",
12
+ "exports": {
13
+ ".": {
14
+ "import": "./dist/index.js",
15
+ "types": "./dist/index.d.ts"
16
+ }
17
+ },
18
+ "files": [
19
+ "dist"
20
+ ],
21
+ "scripts": {
22
+ "build": "tsc",
23
+ "watch": "tsc --watch",
24
+ "clean": "tsc --build --clean"
25
+ },
26
+ "dependencies": {
27
+ "@d-zero/a11y-check-core": "0.2.0",
28
+ "@d-zero/shared": "0.6.0",
29
+ "ansi-colors": "4.1.3"
30
+ },
31
+ "devDependencies": {
32
+ "@d-zero/puppeteer-page": "0.2.0"
33
+ },
34
+ "gitHead": "1eb1c03400580040119121e11ffb33acd876fe1b"
35
+ }