Skip to content

Commit c7ac8d2

Browse files
committed
[Scout][a11y] adding axe-core validation (elastic#243953)
**Summary** - Integrates `axe-core` accessibility checks into our test-suite for `Scout`. - Adds a shared helper `checkA11y` that runs axe on a given DOM context. - Targets automated detection of common accessibility issues (color contrast, missing ARIA, etc.) so we catch regressions earlier. Configuration is unified with Cypress and FTR **Why** - Improve accessibility coverage by running automated checks in-unit and in functional tests. - Provide a simple, reusable helper for tests so authors can validate accessibility as part of normal test work. **What changed** - Adds `axe-core` dependency and wiring to run it in tests. - Introduces `checkA11y` helper used by existing tests. This method is accessible through the `Page` object. **How to use** > [!NOTE] >We recommend running `checkA11y` with the `include` parameter set to the root element you are testing. This makes the tests more isolated and reduces the time required to analyze the DOM structure. ``` test('an example of using the checkA11y check', async ({ page }) => { .... const { violations } = await page.checkA11y({ include: ['{CSS selector of root element you are testing}'] }); expect(violations).toHaveLength(0); }); ``` ## Screens **Example of report** <img width="963" height="326" alt="image" src="https://github.com/user-attachments/assets/c8450ad8-deea-47c3-a4d7-e7e2e0a73d88" /> **ESLint issue* <img width="963" height="326" alt="Screenshot 2025-11-28 at 15 23 55" src="https://github.com/user-attachments/assets/387400ac-4fb8-45d6-9649-09f7ee3fb8f5" /> --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> (cherry picked from commit 1caf007) # Conflicts: # .buildkite/scripts/steps/security/third_party_packages.txt # package.json # src/platform/packages/shared/kbn-scout/moon.yml # src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/index.ts # src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/single_thread.ts # src/platform/packages/shared/kbn-scout/src/playwright/utils/index.ts # src/platform/packages/shared/kbn-scout/tsconfig.json
1 parent f65acd1 commit c7ac8d2

14 files changed

Lines changed: 372 additions & 8 deletions

File tree

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2490,6 +2490,7 @@ module.exports = {
24902490
],
24912491
rules: {
24922492
'@kbn/eslint/scout_no_describe_configure': 'error',
2493+
'@kbn/eslint/require_include_in_check_a11y': 'warn',
24932494
},
24942495
},
24952496
{

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1378,6 +1378,7 @@
13781378
},
13791379
"devDependencies": {
13801380
"@apidevtools/swagger-parser": "^12.1.0",
1381+
"@axe-core/playwright": "^4.11.0",
13811382
"@babel/cli": "^7.24.7",
13821383
"@babel/core": "^7.24.7",
13831384
"@babel/eslint-parser": "^7.24.7",
@@ -1776,7 +1777,7 @@
17761777
"aggregate-error": "^3.1.0",
17771778
"argsplit": "^1.0.5",
17781779
"autoprefixer": "^10.4.7",
1779-
"axe-core": "^4.10.0",
1780+
"axe-core": "^4.11.0",
17801781
"babel-jest": "^29.7.0",
17811782
"babel-loader": "^9.1.3",
17821783
"babel-plugin-add-module-exports": "^1.0.4",

packages/kbn-eslint-plugin-eslint/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,5 +25,6 @@ module.exports = {
2525
deployment_agnostic_test_context: require('./rules/deployment_agnostic_test_context'),
2626
scout_no_describe_configure: require('./rules/scout_no_describe_configure'),
2727
require_kbn_fs: require('./rules/require_kbn_fs'),
28+
require_include_in_check_a11y: require('./rules/require_include_in_check_a11y'),
2829
},
2930
};
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
const isName = (node, expected) =>
11+
(node?.type === 'Identifier' && node.name === expected) ||
12+
(node?.type === 'Literal' && node.value === expected);
13+
14+
module.exports = {
15+
meta: {
16+
type: 'suggestion',
17+
docs: {
18+
description:
19+
'Warn when page.checkA11y is called without the include option to encourage scoped a11y scans.',
20+
recommended: false,
21+
},
22+
messages: {
23+
requireIncludeInCheckA11y:
24+
'We recommend running checkA11y with the include parameter set to the root element you are testing. This makes the tests more isolated and reduces the time required to analyze the DOM structure.',
25+
},
26+
schema: [],
27+
},
28+
29+
create(context) {
30+
const isCheckA11yCall = (node) => {
31+
const callee = node && node.callee;
32+
if (!callee || callee.type !== 'MemberExpression') return false;
33+
return isName(callee.property, 'checkA11y');
34+
};
35+
36+
const hasIncludeProperty = (objExpr) => {
37+
for (const prop of objExpr.properties) {
38+
if (prop.type === 'Property' && isName(prop.key, 'include')) {
39+
return true;
40+
}
41+
}
42+
return false;
43+
};
44+
45+
return {
46+
CallExpression(node) {
47+
if (!isCheckA11yCall(node)) return;
48+
49+
const args = node.arguments || [];
50+
if (args.length === 0) {
51+
context.report({ node, messageId: 'requireIncludeInCheckA11y' });
52+
return;
53+
}
54+
55+
const firstArg = args[0];
56+
if (!firstArg || firstArg.type !== 'ObjectExpression') {
57+
context.report({ node, messageId: 'requireIncludeInCheckA11y' });
58+
return;
59+
}
60+
61+
if (!hasIncludeProperty(firstArg)) {
62+
context.report({ node, messageId: 'requireIncludeInCheckA11y' });
63+
}
64+
},
65+
};
66+
},
67+
};
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
const { RuleTester } = require('eslint');
11+
const rule = require('./require_include_in_check_a11y');
12+
const dedent = require('dedent');
13+
14+
const ruleTester = new RuleTester({
15+
parser: require.resolve('@typescript-eslint/parser'),
16+
parserOptions: {
17+
sourceType: 'module',
18+
ecmaVersion: 2018,
19+
},
20+
});
21+
22+
const MESSAGE =
23+
'We recommend running checkA11y with the include parameter set to the root element you are testing. This makes the tests more isolated and reduces the time required to analyze the DOM structure.';
24+
25+
ruleTester.run('@kbn/eslint/require_include_in_check_a11y', rule, {
26+
valid: [
27+
{
28+
code: dedent`
29+
page.checkA11y({ include: '#root' });
30+
`,
31+
},
32+
{
33+
code: dedent`
34+
page.checkA11y({ include: rootEl });
35+
`,
36+
},
37+
{
38+
code: dedent`
39+
something.checkA11y({ include: '#app', exclude: ['#legacy'] });
40+
`,
41+
},
42+
{
43+
code: dedent`
44+
page['checkA11y']({ include: '#root' });
45+
`,
46+
},
47+
{
48+
code: dedent`
49+
page.checkA11y({ include: '#root', other: true });
50+
`,
51+
},
52+
{
53+
code: dedent`
54+
page.checkA11y({ ['include']: '#root' });
55+
`,
56+
},
57+
],
58+
59+
invalid: [
60+
{
61+
code: dedent`
62+
page.checkA11y();
63+
`,
64+
errors: [{ line: 1, message: MESSAGE }],
65+
},
66+
{
67+
code: dedent`
68+
page.checkA11y({});
69+
`,
70+
errors: [{ line: 1, message: MESSAGE }],
71+
},
72+
{
73+
code: dedent`
74+
page.checkA11y({ foo: 1 });
75+
`,
76+
errors: [{ line: 1, message: MESSAGE }],
77+
},
78+
{
79+
code: dedent`
80+
page.checkA11y({ 'include ': '#root' });
81+
`,
82+
errors: [{ line: 1, message: MESSAGE }],
83+
},
84+
{
85+
code: dedent`
86+
page.checkA11y(config);
87+
`,
88+
errors: [{ line: 1, message: MESSAGE }],
89+
},
90+
{
91+
code: dedent`
92+
page['checkA11y']();
93+
`,
94+
errors: [{ line: 1, message: MESSAGE }],
95+
},
96+
{
97+
// include only in second arg (rule checks first arg)
98+
code: dedent`
99+
page.checkA11y({}, { include: '#root' });
100+
`,
101+
errors: [{ line: 1, message: MESSAGE }],
102+
},
103+
{
104+
// Non-object first argument
105+
code: dedent`
106+
page.checkA11y('not an object');
107+
`,
108+
errors: [{ line: 1, message: MESSAGE }],
109+
},
110+
],
111+
});

renovate.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,7 +242,8 @@
242242
{
243243
"groupName": "axe-core",
244244
"matchDepNames": [
245-
"axe-core"
245+
"axe-core",
246+
"@axe-core/playwright"
246247
],
247248
"reviewers": [
248249
"team:appex-qa"

src/platform/packages/shared/kbn-axe-config/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,8 @@ export const AXE_OPTIONS = {
5050
},
5151
},
5252
};
53+
54+
export const AXE_IMPACT_LEVELS: Array<'minor' | 'moderate' | 'serious' | 'critical'> = [
55+
'critical',
56+
'serious',
57+
];

src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/index.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import { Page } from '@playwright/test';
1111
import { PathOptions } from '../../../../../common/services/kibana_url';
12+
import type { RunA11yScanOptions } from '../../../../utils';
1213

1314
/**
1415
* Extends the Playwright 'Page' interface with methods specific to Kibana.
@@ -31,7 +32,17 @@ export type ScoutPage = Page & {
3132
* @returns A Promise resolving when the indicator is hidden.
3233
*/
3334
waitForLoadingIndicatorHidden: () => ReturnType<Page['waitForSelector']>;
34-
/**
35+
/**
36+
* Performs an accessibility (a11y) scan of the current page using axe-core.
37+
* Use this in tests to collect formatted violation summaries (one string per violation).
38+
*
39+
* @param options - Optional accessibility scan configuration (e.g. selectors to include, exclude, timeout).
40+
* @returns A Promise resolving to an object with a 'violations' array containing
41+
* human-readable formatted strings for each detected violation (empty if none).
42+
*/
43+
checkA11y: (options?: RunA11yScanOptions) => Promise<{
44+
violations: string[];
45+
}>;
3546
* Types text into an input field character by character with a specified delay between each character.
3647
* @param selector - The css selector for the input element.
3748
* @param text - The text to type into the input field.

src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/single_thread.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { Page } from '@playwright/test';
1212
import { test as base } from '@playwright/test';
1313
import type { ScoutPage } from '.';
1414
import type { PathOptions } from '../../../../../common/services/kibana_url';
15+
import { keyTo, checkA11y } from '../../../../utils';
1516
import type { KibanaUrl, ScoutLogger } from '../../worker';
1617

1718
/**
@@ -106,6 +107,9 @@ export function extendPlaywrightPage({
106107
extendedPage.testSubj.waitForSelector('globalLoadingIndicator-hidden', {
107108
state: 'attached',
108109
});
110+
111+
extendedPage.checkA11y = (options) => checkA11y(page, options);
112+
109113
// Method to type text with delay character by character
110114
extendedPage.typeWithDelay = (selector: string, text: string, options?: { delay: number }) =>
111115
typeWithDelay(page, selector, text, options);
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
/*
2+
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
3+
* or more contributor license agreements. Licensed under the "Elastic License
4+
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
5+
* Public License v 1"; you may not use this file except in compliance with, at
6+
* your election, the "Elastic License 2.0", the "GNU Affero General Public
7+
* License v3.0 only", or the "Server Side Public License, v 1".
8+
*/
9+
10+
import type { Page } from '@playwright/test';
11+
import type { Result } from 'axe-core';
12+
import AxeBuilder from '@axe-core/playwright';
13+
import { AXE_OPTIONS, AXE_IMPACT_LEVELS } from '@kbn/axe-config';
14+
15+
export interface RunA11yScanOptions {
16+
/** Optional CSS selectors to include in analysis */
17+
include?: string[];
18+
/** Optional CSS selectors to exclude from analysis */
19+
exclude?: string[];
20+
/** Timeout in ms for the scan (defaults 10000) */
21+
timeoutMs?: number;
22+
}
23+
24+
/**
25+
* Runs an Axe accessibility scan
26+
*
27+
* Failure modes:
28+
* - Timeout: Can occur on large or complex DOMs. The scan rejects with a timeout Error; tests should
29+
* be made more isolated by narrowing scope via the `include` option.
30+
* - Axe/Playwright errors: Underlying errors propagate and indicate a failed scan.
31+
*/
32+
const runA11yScan = async (
33+
page: Page,
34+
{ include = [], exclude = [], timeoutMs = 10000 }: RunA11yScanOptions = {}
35+
) => {
36+
const builder = new AxeBuilder({ page });
37+
builder.options(AXE_OPTIONS);
38+
39+
for (const selector of include) {
40+
builder.include(selector);
41+
}
42+
43+
for (const selector of exclude) {
44+
builder.exclude(selector);
45+
}
46+
47+
const analysisPromise = builder.analyze();
48+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
49+
50+
const result = await Promise.race([
51+
analysisPromise,
52+
new Promise<never>((_, reject) => {
53+
timeoutId = setTimeout(
54+
() => reject(new Error(`Axe accessibility scan timed out after ${timeoutMs}ms`)),
55+
timeoutMs
56+
);
57+
}),
58+
]);
59+
60+
if (timeoutId) {
61+
clearTimeout(timeoutId);
62+
}
63+
64+
let violations: Result[] = result.violations;
65+
66+
if (AXE_IMPACT_LEVELS?.length) {
67+
violations = violations.filter((v) => v.impact && AXE_IMPACT_LEVELS.includes(v.impact));
68+
}
69+
70+
return { violations };
71+
};
72+
73+
export const checkA11y = async (page: Page, options?: RunA11yScanOptions) => {
74+
const { violations } = await runA11yScan(page, options);
75+
76+
const formatA11yViolation = (v: Result): string => {
77+
const nodesSection = v.nodes
78+
.map((n, idx) => {
79+
const selectors = `${n.target.join(', ')}(xpath: ${n.xpath})`;
80+
const failure = n.failureSummary?.trim() || 'No failure summary provided';
81+
return ` ${idx + 1}. Selectors: ${selectors}\n Failure: ${failure}`;
82+
})
83+
.join('\n');
84+
85+
return [
86+
`\nAccessibility violation detected!\n`,
87+
` Rule: ${v.id}. Impact: (${v.impact ?? 'impact unknown'})`,
88+
` Description: ${v.description}`,
89+
` Help: ${v.help}. See more: ${v.helpUrl}`,
90+
` Page: ${page.url()}`,
91+
` Nodes:\n${nodesSection}`,
92+
]
93+
.join('\n')
94+
.trim();
95+
};
96+
97+
return {
98+
violations: violations.map((v) => formatA11yViolation(v)),
99+
};
100+
};

0 commit comments

Comments
 (0)