Skip to content

Commit baca2fa

Browse files
alexwizpkibanamachineCopilot
authored andcommitted
[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>
1 parent d97cd4e commit baca2fa

16 files changed

Lines changed: 376 additions & 8 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
@@ -19,4 +19,5 @@ xml-formatter
1919
@storybook/addon-docs
2020
dompurify
2121
magic-bytes.js
22+
@axe-core/playwright
2223
@opentelemetry/exporter-trace-otlp-grpc

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2692,6 +2692,7 @@ module.exports = {
26922692
],
26932693
rules: {
26942694
'@kbn/eslint/scout_no_describe_configure': 'error',
2695+
'@kbn/eslint/require_include_in_check_a11y': 'warn',
26952696
},
26962697
},
26972698
{

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,7 @@
14711471
"devDependencies": {
14721472
"@apidevtools/swagger-parser": "^12.1.0",
14731473
"@arizeai/phoenix-client": "^4.2.0",
1474+
"@axe-core/playwright": "^4.11.0",
14741475
"@babel/cli": "^7.24.7",
14751476
"@babel/core": "^7.24.7",
14761477
"@babel/eslint-parser": "^7.24.7",
@@ -1889,7 +1890,7 @@
18891890
"ajv-draft-04": "^1.0.0",
18901891
"argsplit": "^1.0.5",
18911892
"autoprefixer": "^10.4.7",
1892-
"axe-core": "^4.10.0",
1893+
"axe-core": "^4.11.0",
18931894
"babel-jest": "^29.7.0",
18941895
"babel-loader": "^9.1.3",
18951896
"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
@@ -244,7 +244,8 @@
244244
{
245245
"groupName": "axe-core",
246246
"matchDepNames": [
247-
"axe-core"
247+
"axe-core",
248+
"@axe-core/playwright"
248249
],
249250
"reviewers": [
250251
"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/moon.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependsOn:
3939
- '@kbn/core-http-common'
4040
- '@kbn/streams-schema'
4141
- '@kbn/streamlang'
42+
- '@kbn/axe-config'
4243
tags:
4344
- test-helper
4445
- package

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

Lines changed: 14 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
/**
@@ -40,6 +41,19 @@ export type ScoutPage = Page & {
4041
* @returns A Promise that resolves once the the element with the css selector is focused, or an error occurs.
4142
*/
4243
keyTo: (selector: string, key: string, maxElementsToTraverse?: number) => Promise<void>;
44+
45+
/**
46+
* Performs an accessibility (a11y) scan of the current page using axe-core.
47+
* Use this in tests to collect formatted violation summaries (one string per violation).
48+
*
49+
* @param options - Optional accessibility scan configuration (e.g. selectors to include, exclude, timeout).
50+
* @returns A Promise resolving to an object with a 'violations' array containing
51+
* human-readable formatted strings for each detected violation (empty if none).
52+
*/
53+
checkA11y: (options?: RunA11yScanOptions) => Promise<{
54+
violations: string[];
55+
}>;
56+
4357
/**
4458
* Types text into an input field character by character with a specified delay between each character.
4559
* @param selector - The css selector for the input element.

0 commit comments

Comments
 (0)