diff --git a/.eslintrc.js b/.eslintrc.js index bfde8763c1158..2b06c634c5571 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2490,6 +2490,7 @@ module.exports = { ], rules: { '@kbn/eslint/scout_no_describe_configure': 'error', + '@kbn/eslint/require_include_in_check_a11y': 'warn', }, }, { diff --git a/package.json b/package.json index 583c8ee844fe7..046266ff9c4af 100644 --- a/package.json +++ b/package.json @@ -1378,6 +1378,7 @@ }, "devDependencies": { "@apidevtools/swagger-parser": "^12.1.0", + "@axe-core/playwright": "^4.11.0", "@babel/cli": "^7.24.7", "@babel/core": "^7.24.7", "@babel/eslint-parser": "^7.24.7", @@ -1776,7 +1777,7 @@ "aggregate-error": "^3.1.0", "argsplit": "^1.0.5", "autoprefixer": "^10.4.7", - "axe-core": "^4.10.0", + "axe-core": "^4.11.0", "babel-jest": "^29.7.0", "babel-loader": "^9.1.3", "babel-plugin-add-module-exports": "^1.0.4", diff --git a/packages/kbn-eslint-plugin-eslint/index.js b/packages/kbn-eslint-plugin-eslint/index.js index fca6c2c4d2519..cd5a2124c3604 100644 --- a/packages/kbn-eslint-plugin-eslint/index.js +++ b/packages/kbn-eslint-plugin-eslint/index.js @@ -25,5 +25,6 @@ module.exports = { deployment_agnostic_test_context: require('./rules/deployment_agnostic_test_context'), scout_no_describe_configure: require('./rules/scout_no_describe_configure'), require_kbn_fs: require('./rules/require_kbn_fs'), + require_include_in_check_a11y: require('./rules/require_include_in_check_a11y'), }, }; diff --git a/packages/kbn-eslint-plugin-eslint/rules/require_include_in_check_a11y.js b/packages/kbn-eslint-plugin-eslint/rules/require_include_in_check_a11y.js new file mode 100644 index 0000000000000..1b7e1dac5019f --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/require_include_in_check_a11y.js @@ -0,0 +1,67 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +const isName = (node, expected) => + (node?.type === 'Identifier' && node.name === expected) || + (node?.type === 'Literal' && node.value === expected); + +module.exports = { + meta: { + type: 'suggestion', + docs: { + description: + 'Warn when page.checkA11y is called without the include option to encourage scoped a11y scans.', + recommended: false, + }, + messages: { + requireIncludeInCheckA11y: + '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.', + }, + schema: [], + }, + + create(context) { + const isCheckA11yCall = (node) => { + const callee = node && node.callee; + if (!callee || callee.type !== 'MemberExpression') return false; + return isName(callee.property, 'checkA11y'); + }; + + const hasIncludeProperty = (objExpr) => { + for (const prop of objExpr.properties) { + if (prop.type === 'Property' && isName(prop.key, 'include')) { + return true; + } + } + return false; + }; + + return { + CallExpression(node) { + if (!isCheckA11yCall(node)) return; + + const args = node.arguments || []; + if (args.length === 0) { + context.report({ node, messageId: 'requireIncludeInCheckA11y' }); + return; + } + + const firstArg = args[0]; + if (!firstArg || firstArg.type !== 'ObjectExpression') { + context.report({ node, messageId: 'requireIncludeInCheckA11y' }); + return; + } + + if (!hasIncludeProperty(firstArg)) { + context.report({ node, messageId: 'requireIncludeInCheckA11y' }); + } + }, + }; + }, +}; diff --git a/packages/kbn-eslint-plugin-eslint/rules/require_include_in_check_a11y.test.js b/packages/kbn-eslint-plugin-eslint/rules/require_include_in_check_a11y.test.js new file mode 100644 index 0000000000000..05e8fbec1a8a9 --- /dev/null +++ b/packages/kbn-eslint-plugin-eslint/rules/require_include_in_check_a11y.test.js @@ -0,0 +1,111 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +const { RuleTester } = require('eslint'); +const rule = require('./require_include_in_check_a11y'); +const dedent = require('dedent'); + +const ruleTester = new RuleTester({ + parser: require.resolve('@typescript-eslint/parser'), + parserOptions: { + sourceType: 'module', + ecmaVersion: 2018, + }, +}); + +const MESSAGE = + '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.'; + +ruleTester.run('@kbn/eslint/require_include_in_check_a11y', rule, { + valid: [ + { + code: dedent` + page.checkA11y({ include: '#root' }); + `, + }, + { + code: dedent` + page.checkA11y({ include: rootEl }); + `, + }, + { + code: dedent` + something.checkA11y({ include: '#app', exclude: ['#legacy'] }); + `, + }, + { + code: dedent` + page['checkA11y']({ include: '#root' }); + `, + }, + { + code: dedent` + page.checkA11y({ include: '#root', other: true }); + `, + }, + { + code: dedent` + page.checkA11y({ ['include']: '#root' }); + `, + }, + ], + + invalid: [ + { + code: dedent` + page.checkA11y(); + `, + errors: [{ line: 1, message: MESSAGE }], + }, + { + code: dedent` + page.checkA11y({}); + `, + errors: [{ line: 1, message: MESSAGE }], + }, + { + code: dedent` + page.checkA11y({ foo: 1 }); + `, + errors: [{ line: 1, message: MESSAGE }], + }, + { + code: dedent` + page.checkA11y({ 'include ': '#root' }); + `, + errors: [{ line: 1, message: MESSAGE }], + }, + { + code: dedent` + page.checkA11y(config); + `, + errors: [{ line: 1, message: MESSAGE }], + }, + { + code: dedent` + page['checkA11y'](); + `, + errors: [{ line: 1, message: MESSAGE }], + }, + { + // include only in second arg (rule checks first arg) + code: dedent` + page.checkA11y({}, { include: '#root' }); + `, + errors: [{ line: 1, message: MESSAGE }], + }, + { + // Non-object first argument + code: dedent` + page.checkA11y('not an object'); + `, + errors: [{ line: 1, message: MESSAGE }], + }, + ], +}); diff --git a/renovate.json b/renovate.json index 78b7014436c5d..69fe2c765bd95 100644 --- a/renovate.json +++ b/renovate.json @@ -242,7 +242,8 @@ { "groupName": "axe-core", "matchDepNames": [ - "axe-core" + "axe-core", + "@axe-core/playwright" ], "reviewers": [ "team:appex-qa" diff --git a/src/platform/packages/shared/kbn-axe-config/index.ts b/src/platform/packages/shared/kbn-axe-config/index.ts index aaabd0bc287d6..ef600b56c0102 100644 --- a/src/platform/packages/shared/kbn-axe-config/index.ts +++ b/src/platform/packages/shared/kbn-axe-config/index.ts @@ -50,3 +50,8 @@ export const AXE_OPTIONS = { }, }, }; + +export const AXE_IMPACT_LEVELS: Array<'minor' | 'moderate' | 'serious' | 'critical'> = [ + 'critical', + 'serious', +]; diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/index.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/index.ts index 3fafa23ad3164..a15f0cfddb33f 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/index.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/index.ts @@ -9,6 +9,7 @@ import { Page } from '@playwright/test'; import { PathOptions } from '../../../../../common/services/kibana_url'; +import type { RunA11yScanOptions } from '../../../../utils'; /** * Extends the Playwright 'Page' interface with methods specific to Kibana. @@ -31,6 +32,17 @@ export type ScoutPage = Page & { * @returns A Promise resolving when the indicator is hidden. */ waitForLoadingIndicatorHidden: () => ReturnType; + /** + * Performs an accessibility (a11y) scan of the current page using axe-core. + * Use this in tests to collect formatted violation summaries (one string per violation). + * + * @param options - Optional accessibility scan configuration (e.g. selectors to include, exclude, timeout). + * @returns A Promise resolving to an object with a 'violations' array containing + * human-readable formatted strings for each detected violation (empty if none). + */ + checkA11y: (options?: RunA11yScanOptions) => Promise<{ + violations: string[]; + }>; /** * Types text into an input field character by character with a specified delay between each character. * @param selector - The css selector for the input element. diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/single_thread.ts b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/single_thread.ts index e9fe5a5ef914f..0e49fc3b81d27 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/single_thread.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/fixtures/scope/test/scout_page/single_thread.ts @@ -12,6 +12,7 @@ import type { Page } from '@playwright/test'; import { test as base } from '@playwright/test'; import type { ScoutPage } from '.'; import type { PathOptions } from '../../../../../common/services/kibana_url'; +import { checkA11y } from '../../../../utils'; import type { KibanaUrl, ScoutLogger } from '../../worker'; /** @@ -106,6 +107,9 @@ export function extendPlaywrightPage({ extendedPage.testSubj.waitForSelector('globalLoadingIndicator-hidden', { state: 'attached', }); + + extendedPage.checkA11y = (options) => checkA11y(page, options); + // Method to type text with delay character by character extendedPage.typeWithDelay = (selector: string, text: string, options?: { delay: number }) => typeWithDelay(page, selector, text, options); diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/utils/axe.ts b/src/platform/packages/shared/kbn-scout/src/playwright/utils/axe.ts new file mode 100644 index 0000000000000..4e3f6bcc83621 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/src/playwright/utils/axe.ts @@ -0,0 +1,100 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import type { Page } from '@playwright/test'; +import type { Result } from 'axe-core'; +import AxeBuilder from '@axe-core/playwright'; +import { AXE_OPTIONS, AXE_IMPACT_LEVELS } from '@kbn/axe-config'; + +export interface RunA11yScanOptions { + /** Optional CSS selectors to include in analysis */ + include?: string[]; + /** Optional CSS selectors to exclude from analysis */ + exclude?: string[]; + /** Timeout in ms for the scan (defaults 10000) */ + timeoutMs?: number; +} + +/** + * Runs an Axe accessibility scan + * + * Failure modes: + * - Timeout: Can occur on large or complex DOMs. The scan rejects with a timeout Error; tests should + * be made more isolated by narrowing scope via the `include` option. + * - Axe/Playwright errors: Underlying errors propagate and indicate a failed scan. + */ +const runA11yScan = async ( + page: Page, + { include = [], exclude = [], timeoutMs = 10000 }: RunA11yScanOptions = {} +) => { + const builder = new AxeBuilder({ page }); + builder.options(AXE_OPTIONS); + + for (const selector of include) { + builder.include(selector); + } + + for (const selector of exclude) { + builder.exclude(selector); + } + + const analysisPromise = builder.analyze(); + let timeoutId: ReturnType | undefined; + + const result = await Promise.race([ + analysisPromise, + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`Axe accessibility scan timed out after ${timeoutMs}ms`)), + timeoutMs + ); + }), + ]); + + if (timeoutId) { + clearTimeout(timeoutId); + } + + let violations: Result[] = result.violations; + + if (AXE_IMPACT_LEVELS?.length) { + violations = violations.filter((v) => v.impact && AXE_IMPACT_LEVELS.includes(v.impact)); + } + + return { violations }; +}; + +export const checkA11y = async (page: Page, options?: RunA11yScanOptions) => { + const { violations } = await runA11yScan(page, options); + + const formatA11yViolation = (v: Result): string => { + const nodesSection = v.nodes + .map((n, idx) => { + const selectors = `${n.target.join(', ')}(xpath: ${n.xpath})`; + const failure = n.failureSummary?.trim() || 'No failure summary provided'; + return ` ${idx + 1}. Selectors: ${selectors}\n Failure: ${failure}`; + }) + .join('\n'); + + return [ + `\nAccessibility violation detected!\n`, + ` Rule: ${v.id}. Impact: (${v.impact ?? 'impact unknown'})`, + ` Description: ${v.description}`, + ` Help: ${v.help}. See more: ${v.helpUrl}`, + ` Page: ${page.url()}`, + ` Nodes:\n${nodesSection}`, + ] + .join('\n') + .trim(); + }; + + return { + violations: violations.map((v) => formatA11yViolation(v)), + }; +}; diff --git a/src/platform/packages/shared/kbn-scout/src/playwright/utils/index.ts b/src/platform/packages/shared/kbn-scout/src/playwright/utils/index.ts index bcf1b6fc624ea..44dd49aa35ee6 100644 --- a/src/platform/packages/shared/kbn-scout/src/playwright/utils/index.ts +++ b/src/platform/packages/shared/kbn-scout/src/playwright/utils/index.ts @@ -9,3 +9,4 @@ export { isValidUTCDate, formatTime, getPlaywrightGrepTag, execPromise } from './runner_utils'; export { resolveSelector, type SelectorInput } from './locator_helper'; +export { checkA11y, type RunA11yScanOptions } from './axe'; diff --git a/src/platform/packages/shared/kbn-scout/test/scout/ui/tests/axe.spec.ts b/src/platform/packages/shared/kbn-scout/test/scout/ui/tests/axe.spec.ts new file mode 100644 index 0000000000000..a62236689c1b9 --- /dev/null +++ b/src/platform/packages/shared/kbn-scout/test/scout/ui/tests/axe.spec.ts @@ -0,0 +1,53 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the "Elastic License + * 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side + * Public License v 1"; you may not use this file except in compliance with, at + * your election, the "Elastic License 2.0", the "GNU Affero General Public + * License v3.0 only", or the "Server Side Public License, v 1". + */ + +import { test, expect } from '../../../../src/playwright'; + +test.describe('runA11yScan', { tag: ['@svlSecurity', '@ess'] }, () => { + test('returns violations array (empty for basic accessible markup)', async ({ page }) => { + await page.setContent(` + + + AXE Core check for basic accessible markup + +
+

AXE Core check for basic accessible markup

+ +
+ + +
+
+ + `); + + const { violations } = await page.checkA11y(); + + expect(Array.isArray(violations)).toBe(true); + + // Basic page should have no serious/critical violations + expect(violations).toHaveLength(0); + }); + + test('returns violations related to the CSS "include" selector only', async ({ page }) => { + await page.setContent(` +
+

AXE Core check for basic accessible markup

+
+ + +
+
+ `); + + const { violations } = await page.checkA11y({ include: ['form'] }); + + expect(violations).toHaveLength(1); + }); +}); diff --git a/src/platform/packages/shared/kbn-scout/tsconfig.json b/src/platform/packages/shared/kbn-scout/tsconfig.json index f852de10877e6..6b295bcda22a7 100644 --- a/src/platform/packages/shared/kbn-scout/tsconfig.json +++ b/src/platform/packages/shared/kbn-scout/tsconfig.json @@ -32,6 +32,7 @@ "@kbn/apm-synthtrace-client", "@kbn/projects-solutions-groups", "@kbn/zod", + "@kbn/axe-config", "@kbn/core-http-common", ] } diff --git a/yarn.lock b/yarn.lock index 3ccb7bcd0386e..7fa1000ba16db 100644 --- a/yarn.lock +++ b/yarn.lock @@ -891,6 +891,13 @@ resolved "https://registry.yarnpkg.com/@aws/lambda-invoke-store/-/lambda-invoke-store-0.0.1.tgz#92d792a7dda250dfcb902e13228f37a81be57c8f" integrity sha512-ORHRQ2tmvnBXc8t/X9Z8IcSbBA4xTLKuN873FopzklHMeqBst7YG0d+AX97inkvDX+NChYtSr+qGfcqGFaI8Zw== +"@axe-core/playwright@^4.11.0": + version "4.11.0" + resolved "https://registry.yarnpkg.com/@axe-core/playwright/-/playwright-4.11.0.tgz#64beab80764c1f3f0ec4ac21f9b2c2d7df508958" + integrity sha512-70vBT/Ylqpm65RQz2iCG2o0JJCEG/WCNyefTr2xcOcr1CoSee60gNQYUMZZ7YukoKkFLv26I/jjlsvwwp532oQ== + dependencies: + axe-core "~4.11.0" + "@babel/cli@^7.24.7": version "7.24.7" resolved "https://registry.yarnpkg.com/@babel/cli/-/cli-7.24.7.tgz#eb2868c1fa384b17ea88d60107577d3e6fd05c4e" @@ -15459,16 +15466,16 @@ aws4@^1.13.2, aws4@^1.8.0: resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.13.2.tgz#0aa167216965ac9474ccfa83892cfb6b3e1e52ef" integrity sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw== -axe-core@^4.10.0: - version "4.10.0" - resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.0.tgz#d9e56ab0147278272739a000880196cdfe113b59" - integrity sha512-Mr2ZakwQ7XUAjp7pAwQWRhhK8mQQ6JAaNWSjmjxil0R8BPioMtQsTLOolGYkji1rcL++3dCqZA3zWqpT+9Ew6g== - axe-core@^4.10.3: version "4.10.3" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.10.3.tgz#04145965ac7894faddbac30861e5d8f11bfd14fc" integrity sha512-Xm7bpRXnDSX2YE2YFfBk2FnF0ep6tmG7xPh8iHee8MIcrgq762Nkce856dYtJYLkuIoYZvGfTs/PbZhideTcEg== +axe-core@^4.11.0, axe-core@~4.11.0: + version "4.11.0" + resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.11.0.tgz#16f74d6482e343ff263d4f4503829e9ee91a86b6" + integrity sha512-ilYanEU8vxxBexpJd8cWM4ElSQq4QctCLKih0TSfjIfCQTeyH/6zVrmIJfLPrKTKJRbiG+cfnZbQIjAlJmF1jQ== + axe-core@^4.2.0, axe-core@^4.6.2: version "4.7.2" resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.7.2.tgz#040a7342b20765cb18bb50b628394c21bccc17a0"