Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
99e1cec
Try adding axe-core.
afercia Aug 31, 2026
7d380a9
Clean up.
afercia Sep 1, 2026
5e3052e
Add registry of admin pages to be scanned and mechanism to ignore fal…
afercia Sep 2, 2026
90627d5
Add best-practice rules group and exceptions.
afercia Sep 2, 2026
f73dcb5
Add one more exception for list table post titles.
afercia Sep 2, 2026
95cf44a
Upload image to the media library and test the Edit attachment modal …
afercia Sep 2, 2026
5a89bb9
Add more pages to be tested,
afercia Sep 2, 2026
a15654b
Improve exclusions and known false positives mechanism.
afercia Sep 3, 2026
acff25e
Normalize the received Target Selector to simplify pattern matching.
afercia Sep 4, 2026
77db409
Fix bug intentionally re-introduced for testing purposes.
afercia Sep 4, 2026
e7f315b
Create a few posts to test the Posts page.
afercia Sep 8, 2026
fb1b488
Rename accessibility-pages file.
afercia Sep 9, 2026
655b02f
Exclude post titles with surrounding text in list tables for link-in-…
afercia Sep 9, 2026
b07ad7a
Add more pages to test and two comments.
afercia Sep 13, 2026
75207d2
Speed up tests by using the wait interval only when necessary.
afercia Sep 13, 2026
80fdb24
Explain waitInterval.
afercia Sep 13, 2026
db27ddd
Use media requestUtils.
afercia Sep 13, 2026
29d5d29
Simplify beforeAll and afterAll.
afercia Sep 13, 2026
1375f14
Add ignore for the media attachments browser.
afercia Sep 14, 2026
fbf6a2a
Add the fonts Upload and Install Fonts tabs to the pages matrix.
afercia Sep 15, 2026
422a224
Pass more params to the setup helper in a destructured object.
afercia Sep 23, 2026
0822572
Add npm script to run the a11y test.
afercia Sep 25, 2026
6914cc5
Use toBeVisible.
afercia Sep 25, 2026
d93ca35
Scan the Site Health Info page with WP section open.
afercia Sep 25, 2026
51f61d2
Avoid deleting all existing attachments.
afercia Oct 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
],
"devDependencies": {
"@343dev/gifsicle": "1.2.0",
"@axe-core/playwright": "4.13.0",
"@lodder/grunt-postcss": "^3.1.1",
"@napi-rs/image": "1.14.0",
"@playwright/test": "1.61.1",
Expand Down Expand Up @@ -154,6 +155,7 @@
"test:php": "node ./tools/local-env/scripts/docker.js run --rm php ./vendor/bin/phpunit",
"test:coverage": "npm run test:php -- --coverage-html ./coverage/html/ --coverage-php ./coverage/php/report.php --coverage-text=./coverage/text/report.txt",
"test:e2e": "wp-scripts test-playwright --config tests/e2e/playwright.config.js",
"test:e2e:a11y": "npm run test:e2e -- tests/e2e/specs/accessibility.spec.js",
"test:visual": "wp-scripts test-playwright --config tests/visual-regression/playwright.config.js",
"typecheck:php": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan --",
"typecheck:php:baselines": "node ./tools/local-env/scripts/docker.js run --rm php composer phpstan:baselines --",
Expand Down
1 change: 1 addition & 0 deletions src/js/media/views/attachments/browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ AttachmentsBrowser = View.extend(/** @lends wp.media.view.AttachmentsBrowser.pro
}).render() );

// DateFilter is a <select>, a label element needs to be rendered before.
// Re-introduce the bug fixed in https://core.trac.wordpress.org/changeset/62851 for axe-core testing purposes.
this.toolbar.set( 'dateFilterLabel', new wp.media.view.Label({
value: l10n.filterByDate,
attributes: {
Expand Down
Binary file added tests/e2e/assets/sample.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
165 changes: 165 additions & 0 deletions tests/e2e/specs/accessibility.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import path from 'path';
import { test, expect } from '@wordpress/e2e-test-utils-playwright';
const AxeScanner = require( '../utils/accessibility-axe-scanner' );
const { pages } = require( '../utils/accessibility-pages-matrix' );
const ignoresAndknownFalsePositives = require( '../utils/accessibility-ignores-and-false-positives' );

/**
* Global accessibility scan rules.
* Applied to all pages defined in utils/accessibility-pages-matrix.
*
* See the Axe Options parameter documentation.
* See: https://github.com/dequelabs/axe-core/blob/master/doc/API.md#options-parameter
*/
const globalRules = {
runOnly: [ 'wcag2a', 'wcag2aa', 'best-practice' ],
absolutePaths: true, // Report the absolute CSS target selector for better exclusions mechanism.
};

/**
* Filters violations to remove violations we want to intentionally ignore and known false positives.
* Only returns violations for nodes that don't match excluded selectors.
*
* See more details in tests/e2e/utils/accessibility-ignores-and-false-positives.js.
*
* @param {Object} results Axe results object.
* @param {Object} exclusions Explicit exclusions and known false positives config (rule ID → selectors[]).
* @return {Object} Filtered results.
*/
function filterIgnoresAndFalsePositives( results, exclusions ) {
results.violations = results.violations.map( ( violation ) => {
const excludedSelectors = exclusions[ violation.id ] || [];

if ( excludedSelectors.length === 0 ) {
return violation;
}

// Filter out nodes matching excluded selectors.
violation.nodes = violation.nodes.filter( ( node ) => {
const targetSelector = node.target[ 0 ];

/*
* Normalize the received Axe-core Target Selector: remove combinators,
* attributes, and collapse spaces. This allows us to match patterns
* against the normalized selector using token-based matching. For example:
* - Raw Target Selector: "html > body > #__wp-uploader > .attachments-browser > .attachments-wrapper > li[aria-label="image-1"]"
* - After normalization: "html body #__wp-uploader .attachments-browser .attachments-wrapper li"
*
* Then it checks if all pattern tokens appear in order in the normalized selector.
* Intermediate selectors can be skipped.
*/
const normalizedTargetSelector = targetSelector
.replace( /[>+~]/g, ' ' ) // Replace combinators with spaces.
.replace( /\[[^\]]*\]/g, ' ' ) // Remove attributes (anything in brackets).
.replace( /\s+/g, ' ' ) // Collapse multiple spaces.
.trim();

// Check if target selector matches any excluded selector pattern.
const isExcluded = excludedSelectors.some( ( excluded ) => {
const selectors = excluded.trim().split( ' ' ).filter( s => s.length > 0 );
return selectors.every( selector => {
// Escape regex special chars (periods, brackets, etc.) to match literals.
const escaped = selector.replace( /[.*+?^${}()|[\]\\]/g, '\\$&' );
// Replace escaped asterisks with .* to enable wildcard matching.
const wildcard = escaped.replace( /\\\*/g, '.*' );
// Regex checks for token at start/end or bounded by spaces.
const pattern = `(?:^|\\s)${ wildcard }(?=\\s|$)`;
return new RegExp( pattern ).test( normalizedTargetSelector );
} );
} );

return ! isExcluded;
} );

return violation;
} ).filter( ( violation ) => violation.nodes.length > 0 );

return results;
}

/**
* Scans a page for accessibility violations and asserts no violations found.
*
* @param {Object} page Playwright page object.
* @param {Object} pageSpec Page specification object.
* @param {Object} [variant] Optional state variant spec.
* @return {Promise<void>}
*/
async function scanAndAssert( page, pageSpec, variant = null ) {
// Merge rules: global < page < variant.
const mergedRules = {
...globalRules,
...( pageSpec.rules || {} ),
...( variant?.rules || {} ),
};

// Scan and assert.
const scanner = new AxeScanner( { page, globalRules: mergedRules } );
let results = await scanner.scan();

// Filter out known ignores and false positives.
results = filterIgnoresAndFalsePositives( results, ignoresAndknownFalsePositives );

scanner.formatResults( results );

// Use expect for cleaner error reporting at test location.
expect( scanner.getViolationsCount( results ) ).toBe( 0 );
}

test.describe( 'Admin Pages Accessibility', () => {
let mediaAttachmentId = null;

test.beforeAll( async ( { requestUtils } ) => {
// Upload sample image to media library for testing.
const imagePath = path.resolve( __dirname, '../assets/sample.png' )
const response = await requestUtils.uploadMedia(
imagePath
);

// Store the sample image ID for later cleanup.
mediaAttachmentId = response.id;
} );

test.afterAll( async ( { requestUtils } ) => {
// Delete the uploaded sample image.
if ( mediaAttachmentId ) {
await requestUtils.deleteMedia( mediaAttachmentId );
}

// Delete all posts of type 'post' and all associated comments.
await requestUtils.deleteAllPosts();
} );

pages.forEach( ( pageSpec ) => {
// Normalize: pages without stateVariants get a default variant.
const variants = pageSpec.stateVariants || [ { name: 'default' } ];

variants.forEach( ( variant ) => {
// Generate test name: omit [variant.name] if default.
const variantName = variant.name === 'default' ? '' : ` [${ variant.name }]`;
const testName = `${ pageSpec.name }${ variantName } should not have violations`;

test( testName, async ( { admin, editor, page, requestUtils } ) => {
// Navigate to page.
await admin.visitAdminPage( pageSpec.path );

// Wait for any async content to render. Some pages load content
// dynamically after the DOM is ready. This is particularly important
// for pages rendered via React components like the Settings > Connectors
// page or the Fonts page. Without this wait, the scan may run before all
// content is fully rendered.
if ( pageSpec.waitInterval ) {
await page.waitForTimeout( 500 );
}

// Run state setup if provided.
if ( variant.setup ) {
await variant.setup( { admin, editor, page, requestUtils, expect } );
}

// Scan and assert.
await scanAndAssert( page, pageSpec, variant );
} );
} );
} );
} );
135 changes: 135 additions & 0 deletions tests/e2e/utils/accessibility-axe-scanner.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
/**
* AxeScanner utility for accessibility scanning with axe-core.
*
* Encapsulates axe-core scanning logic, result formatting, and assertions.
* Can be configured with global rules and logger functions.
*
* @example
* const scanner = new AxeScanner({ page, globalRules: { runOnly: ['wcag2a', 'wcag2aa'] } });
* const results = await scanner.scan();
* scanner.formatResults(results);
* scanner.assertNoViolations(results);
*/

const AxeBuilder = require( '@axe-core/playwright' ).default;

class AxeScanner {
/**
* Create an AxeScanner instance.
*
* @param {Object} options
* @param {Object} options.page Playwright page object.
* @param {Object} [options.globalRules] Rules config passed to axe (e.g., { runOnly: ['wcag2a', 'wcag2aa'], rules: {...} }).
* @param {Function} [options.logger] Custom logger function (defaults to console.log).
*/
constructor( { page, globalRules = {}, logger = console.log } ) {
this.page = page;
this.globalRules = globalRules;
this.logger = logger;
}

/**
* Runs axe-core scan on the current page.
*
* @param {Object} [overrideOptions] Additional options to merge with globalRules (useful for per-page overrides).
* @returns {Promise<Object>} Axe results object (violations, passes, incomplete, inapplicable).
*/
async scan( overrideOptions = {} ) {
const options = this._mergeRulesOptions( this.globalRules, overrideOptions );

const results = await new AxeBuilder( { page: this.page } )
.options( options )
.analyze();

return results;
}

/**
* Formats and logs scan results to console.
* Outputs violation details, element selectors, and HTML snippets.
*
* @param {Object} results Axe results object.
*/
formatResults( results ) {
const violationsAmount = results.violations.length;

if ( violationsAmount === 0 ) {
this.logger( 'No accessibility violations found!' );
return;
}

this.logger( `\nFound ${ violationsAmount } accessibility violation(s):\n` );

/*
* Result Object documentation: https://github.com/dequelabs/axe-core/blob/master/doc/API.md#results-object
* This object has four components:
* - a `passes` array: keeps track of all the passed tests,
* along with detailed information on each one.
* - a `violations` array: keeps track of all the failed tests,
* along with detailed information on each one.
* - an `incomplete` array: indicates which nodes could neither be
* determined to definitively pass or definitively
* fail. They are separated out in order that
* a user interface can display these to the
* user for manual review
* - an `inapplicable` array: lists all the rules for which no matching
* elements were found on the page.
*/
results.violations.forEach( ( violation, index ) => {
this.logger( `--- Violation #${ index + 1 } ---` );
this.logger( `Rule ID: ${ violation.id }` );
this.logger( `Impact: ${ violation.impact.toUpperCase() }` );
this.logger( `Failure: ${ violation.description }` );
this.logger( `Help: ${ violation.help }` );
this.logger( `Help link: ${ violation.helpUrl }` );

// List every specific HTML element failing a failing rule.
this.logger( 'Failing elements:' );
violation.nodes.forEach( ( node ) => {
this.logger( ` - Target Selector: ${ node.target.join( ', ' ) }` );
this.logger( ` - HTML Snippet:\n${ node.html }` );
} );
this.logger( '\n' );
} );
}

/**
* Asserts that there are no accessibility violations.
* Returns the violations amount; let the test use expect() for cleaner error reporting.
*
* @param {Object} results Axe results object.
* @returns {number} Number of violations found.
*/
getViolationsCount( results ) {
return results.violations.length;
}

/**
* Merges rules options, with overrides taking precedence over global rules..
*
* @private
* @param {Object} globalRules Global rules config.
* @param {Object} overrideRules Override rules config.
* @returns {Object} Merged rules object.
*/
_mergeRulesOptions( globalRules, overrideRules ) {
// Deep merge rules object to allow per-rule overrides
if ( globalRules.rules && overrideRules.rules ) {
return {
...globalRules,
...overrideRules,
rules: {
...globalRules.rules,
...overrideRules.rules,
},
};
}

return {
...globalRules,
...overrideRules,
};
}
}

module.exports = AxeScanner;
Loading
Loading