Skip to content

Commit 45c828e

Browse files
authored
[9.2] [Scout][a11y] adding axe-core validation (#243953) (#244797)
# Backport This will backport the following commits from `main` to `9.2`: - [[Scout][a11y] adding `axe-core` validation (#243953)](#243953) <!--- Backport version: 10.2.0 --> ### Questions ? Please refer to the [Backport tool documentation](https://github.com/sorenlouv/backport) <!--BACKPORT [{"author":{"name":"Alexey Antonov","email":"alexwizp@gmail.com"},"sourceCommit":{"committedDate":"2025-12-01T13:38:23Z","message":"[Scout][a11y] adding `axe-core` validation (#243953)\n\n**Summary**\n- Integrates `axe-core` accessibility checks into our test-suite for\n`Scout`.\n- Adds a shared helper `checkA11y` that runs axe on a given DOM context.\n- Targets automated detection of common accessibility issues (color\ncontrast, missing ARIA, etc.) so we catch regressions earlier.\nConfiguration is unified with Cypress and FTR\n\n**Why**\n- Improve accessibility coverage by running automated checks in-unit and\nin functional tests.\n- Provide a simple, reusable helper for tests so authors can validate\naccessibility as part of normal test work.\n\n**What changed**\n- Adds `axe-core` dependency and wiring to run it in tests.\n- Introduces `checkA11y` helper used by existing tests. This method is\naccessible through the `Page` object.\n\n**How to use**\n> [!NOTE]\n>We recommend running `checkA11y` with the `include` parameter set to\nthe root element you are testing. This makes the tests more isolated and\nreduces the time required to analyze the DOM structure.\n\n```\ntest('an example of using the checkA11y check', async ({ page }) => {\n ....\n \n const { violations } = await page.checkA11y({ include: ['{CSS selector of root element you are testing}'] });\n expect(violations).toHaveLength(0);\n});\n\n```\n\n\n## Screens\n\n**Example of report**\n\n<img width=\"963\" height=\"326\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/c8450ad8-deea-47c3-a4d7-e7e2e0a73d88\"\n/>\n\n\n**ESLint issue*\n\n<img width=\"963\" height=\"326\" alt=\"Screenshot 2025-11-28 at 15 23 55\"\nsrc=\"https://github.com/user-attachments/assets/387400ac-4fb8-45d6-9649-09f7ee3fb8f5\"\n/>\n\n---------\n\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>","sha":"1caf007ff13a21654ba74a596bf3614348adc424","branchLabelMapping":{"^v9.3.0$":"main","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","backport:version","test:scout","v9.3.0"],"title":"[Scout][a11y] adding `axe-core` validation","number":243953,"url":"https://github.com/elastic/kibana/pull/243953","mergeCommit":{"message":"[Scout][a11y] adding `axe-core` validation (#243953)\n\n**Summary**\n- Integrates `axe-core` accessibility checks into our test-suite for\n`Scout`.\n- Adds a shared helper `checkA11y` that runs axe on a given DOM context.\n- Targets automated detection of common accessibility issues (color\ncontrast, missing ARIA, etc.) so we catch regressions earlier.\nConfiguration is unified with Cypress and FTR\n\n**Why**\n- Improve accessibility coverage by running automated checks in-unit and\nin functional tests.\n- Provide a simple, reusable helper for tests so authors can validate\naccessibility as part of normal test work.\n\n**What changed**\n- Adds `axe-core` dependency and wiring to run it in tests.\n- Introduces `checkA11y` helper used by existing tests. This method is\naccessible through the `Page` object.\n\n**How to use**\n> [!NOTE]\n>We recommend running `checkA11y` with the `include` parameter set to\nthe root element you are testing. This makes the tests more isolated and\nreduces the time required to analyze the DOM structure.\n\n```\ntest('an example of using the checkA11y check', async ({ page }) => {\n ....\n \n const { violations } = await page.checkA11y({ include: ['{CSS selector of root element you are testing}'] });\n expect(violations).toHaveLength(0);\n});\n\n```\n\n\n## Screens\n\n**Example of report**\n\n<img width=\"963\" height=\"326\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/c8450ad8-deea-47c3-a4d7-e7e2e0a73d88\"\n/>\n\n\n**ESLint issue*\n\n<img width=\"963\" height=\"326\" alt=\"Screenshot 2025-11-28 at 15 23 55\"\nsrc=\"https://github.com/user-attachments/assets/387400ac-4fb8-45d6-9649-09f7ee3fb8f5\"\n/>\n\n---------\n\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>","sha":"1caf007ff13a21654ba74a596bf3614348adc424"}},"sourceBranch":"main","suggestedTargetBranches":[],"targetPullRequestStates":[{"branch":"main","label":"v9.3.0","branchLabelMappingKey":"^v9.3.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/243953","number":243953,"mergeCommit":{"message":"[Scout][a11y] adding `axe-core` validation (#243953)\n\n**Summary**\n- Integrates `axe-core` accessibility checks into our test-suite for\n`Scout`.\n- Adds a shared helper `checkA11y` that runs axe on a given DOM context.\n- Targets automated detection of common accessibility issues (color\ncontrast, missing ARIA, etc.) so we catch regressions earlier.\nConfiguration is unified with Cypress and FTR\n\n**Why**\n- Improve accessibility coverage by running automated checks in-unit and\nin functional tests.\n- Provide a simple, reusable helper for tests so authors can validate\naccessibility as part of normal test work.\n\n**What changed**\n- Adds `axe-core` dependency and wiring to run it in tests.\n- Introduces `checkA11y` helper used by existing tests. This method is\naccessible through the `Page` object.\n\n**How to use**\n> [!NOTE]\n>We recommend running `checkA11y` with the `include` parameter set to\nthe root element you are testing. This makes the tests more isolated and\nreduces the time required to analyze the DOM structure.\n\n```\ntest('an example of using the checkA11y check', async ({ page }) => {\n ....\n \n const { violations } = await page.checkA11y({ include: ['{CSS selector of root element you are testing}'] });\n expect(violations).toHaveLength(0);\n});\n\n```\n\n\n## Screens\n\n**Example of report**\n\n<img width=\"963\" height=\"326\" alt=\"image\"\nsrc=\"https://github.com/user-attachments/assets/c8450ad8-deea-47c3-a4d7-e7e2e0a73d88\"\n/>\n\n\n**ESLint issue*\n\n<img width=\"963\" height=\"326\" alt=\"Screenshot 2025-11-28 at 15 23 55\"\nsrc=\"https://github.com/user-attachments/assets/387400ac-4fb8-45d6-9649-09f7ee3fb8f5\"\n/>\n\n---------\n\nCo-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>\nCo-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>","sha":"1caf007ff13a21654ba74a596bf3614348adc424"}},{"url":"https://github.com/elastic/kibana/pull/244796","number":244796,"branch":"8.19","state":"OPEN"}]}] BACKPORT-->
1 parent 51c7c0f commit 45c828e

15 files changed

Lines changed: 373 additions & 7 deletions

File tree

.buildkite/scripts/steps/security/third_party_packages.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,5 @@ canvas-confetti
1212
undici
1313
ajv-formats
1414
dompurify
15+
@axe-core/playwright
1516
magic-bytes.js

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2469,6 +2469,7 @@ module.exports = {
24692469
],
24702470
rules: {
24712471
'@kbn/eslint/scout_no_describe_configure': 'error',
2472+
'@kbn/eslint/require_include_in_check_a11y': 'warn',
24722473
},
24732474
},
24742475
{

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1437,6 +1437,7 @@
14371437
"devDependencies": {
14381438
"@apidevtools/swagger-parser": "^12.1.0",
14391439
"@arizeai/phoenix-client": "^2.3.4",
1440+
"@axe-core/playwright": "^4.11.0",
14401441
"@babel/cli": "^7.24.7",
14411442
"@babel/core": "^7.24.7",
14421443
"@babel/eslint-parser": "^7.24.7",
@@ -1856,7 +1857,7 @@
18561857
"aggregate-error": "^3.1.0",
18571858
"argsplit": "^1.0.5",
18581859
"autoprefixer": "^10.4.7",
1859-
"axe-core": "^4.10.0",
1860+
"axe-core": "^4.11.0",
18601861
"babel-jest": "^29.7.0",
18611862
"babel-loader": "^9.1.3",
18621863
"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
@@ -246,7 +246,8 @@
246246
{
247247
"groupName": "axe-core",
248248
"matchDepNames": [
249-
"axe-core"
249+
"axe-core",
250+
"@axe-core/playwright"
250251
],
251252
"reviewers": [
252253
"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 & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
*/
99

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

1314
/**
@@ -31,6 +32,17 @@ export type ScoutPage = Page & {
3132
* @returns A Promise resolving when the indicator is hidden.
3233
*/
3334
waitForLoadingIndicatorHidden: () => ReturnType<Page['waitForSelector']>;
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+
}>;
3446
/**
3547
* Types text into an input field character by character with a specified delay between each character.
3648
* @param selector - The css selector for the input element.

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 { 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);

0 commit comments

Comments
 (0)