From a3d0cff5cad51317da3f4edcd21a196a91db1737 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Thu, 28 May 2026 19:59:40 -0400 Subject: [PATCH 01/77] Fix: normalize FK grade values in (0,1) to 1 instead of collapsing to 0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Raw floor() on a Flesch-Kincaid grade between 0.01 and 0.99 collapsed to 0, causing valid simple-grade content to be treated as "not enough content" and flagged as failing. Introduces edac_normalize_fk_grade() which returns 0 only for a true zero grade and otherwise returns max(1, floor($fk_grade)). Applied to all four call sites across class-ajax.php (×2), class-summary-generator.php, and class-rest-api.php. Adds unit tests covering the boundary cases. Fixes #1497 Co-Authored-By: Claude Sonnet 4.6 --- admin/class-ajax.php | 4 +- includes/classes/class-rest-api.php | 2 +- includes/classes/class-summary-generator.php | 4 +- includes/helper-functions.php | 20 ++++++++ .../helper-functions/NormalizeFkGradeTest.php | 49 +++++++++++++++++++ 5 files changed, 73 insertions(+), 6 deletions(-) create mode 100644 tests/phpunit/helper-functions/NormalizeFkGradeTest.php diff --git a/admin/class-ajax.php b/admin/class-ajax.php index 736ebfc22..763f8cda1 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -80,7 +80,7 @@ public function summary() { $simplified_summary_grade = 0; if ( class_exists( 'DaveChild\TextStatistics\TextStatistics' ) ) { $text_statistics = new \DaveChild\TextStatistics\TextStatistics(); - $simplified_summary_grade = (int) floor( $text_statistics->fleschKincaidGradeLevel( $simplified_summary ) ); + $simplified_summary_grade = edac_normalize_fk_grade( $text_statistics->fleschKincaidGradeLevel( $simplified_summary ) ); } $simplified_summary_grade_failed = ( $simplified_summary_grade > 9 ) ? true : false; @@ -671,7 +671,7 @@ public function readability() { $simplified_summary_grade = 0; if ( class_exists( 'DaveChild\TextStatistics\TextStatistics' ) ) { $text_statistics = new \DaveChild\TextStatistics\TextStatistics(); - $simplified_summary_grade = (int) floor( $text_statistics->fleschKincaidGradeLevel( $simplified_summary ) ); + $simplified_summary_grade = edac_normalize_fk_grade( $text_statistics->fleschKincaidGradeLevel( $simplified_summary ) ); } $simplified_summary_grade_failed = ( $simplified_summary_grade > 9 ) ? true : false; diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index 3af861d19..5f2e1f695 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -1077,7 +1077,7 @@ private function get_readability_data( $post_id ) { $simplified_summary_grade = 0; if ( class_exists( 'DaveChild\TextStatistics\TextStatistics' ) ) { $text_statistics = new \DaveChild\TextStatistics\TextStatistics(); - $simplified_summary_grade = (int) floor( $text_statistics->fleschKincaidGradeLevel( $simplified_summary ) ); + $simplified_summary_grade = edac_normalize_fk_grade( $text_statistics->fleschKincaidGradeLevel( $simplified_summary ) ); } $simplified_summary_grade_failed = $simplified_summary_grade >= 9; diff --git a/includes/classes/class-summary-generator.php b/includes/classes/class-summary-generator.php index ac64a4d7e..584f10734 100644 --- a/includes/classes/class-summary-generator.php +++ b/includes/classes/class-summary-generator.php @@ -290,9 +290,7 @@ private function calculate_content_grade() { $content_grade = 0; if ( class_exists( 'DaveChild\TextStatistics\TextStatistics' ) ) { - $content_grade = floor( - ( new \DaveChild\TextStatistics\TextStatistics() )->fleschKincaidGradeLevel( $content ) - ); + $content_grade = edac_normalize_fk_grade( ( new \DaveChild\TextStatistics\TextStatistics() )->fleschKincaidGradeLevel( $content ) ); } return (int) round( $content_grade ); diff --git a/includes/helper-functions.php b/includes/helper-functions.php index 370113a68..2f692c859 100644 --- a/includes/helper-functions.php +++ b/includes/helper-functions.php @@ -940,6 +940,26 @@ function edac_format_datetime_from_utc( string $utc_datetime ): string { return wp_date( $format, $timestamp ); } +/** + * Normalize a raw Flesch-Kincaid grade level float to a whole-number grade. + * + * `floor()` alone collapses any FK value in (0, 1) to 0, which misrepresents + * very simple content as "not calculable." Values above 0 but below 1 are + * normalized to 1 so that compliance checks treat them correctly. + * + * @since 1.44.0 + * + * @param float $fk_grade Raw Flesch-Kincaid grade level returned by the library. + * @return int Normalized grade: 0 when the library returned 0 (not enough content), + * otherwise max(1, floor($fk_grade)). + */ +function edac_normalize_fk_grade( float $fk_grade ): int { + if ( $fk_grade <= 0 ) { + return 0; + } + return max( 1, (int) floor( $fk_grade ) ); +} + /** * Determine the icon name to display for the readability panel. * diff --git a/tests/phpunit/helper-functions/NormalizeFkGradeTest.php b/tests/phpunit/helper-functions/NormalizeFkGradeTest.php new file mode 100644 index 000000000..c8a87292c --- /dev/null +++ b/tests/phpunit/helper-functions/NormalizeFkGradeTest.php @@ -0,0 +1,49 @@ +assertSame( $expected, edac_normalize_fk_grade( $input ) ); + } + + /** + * Data provider for test_normalize_fk_grade. + * + * @return array + */ + public static function data_normalize_fk_grade(): array { + return [ + 'zero stays zero' => [ 0.0, 0 ], + 'negative stays zero' => [ -1.5, 0 ], + 'fractional above zero is 1' => [ 0.01, 1 ], + 'mid-fraction is 1' => [ 0.5, 1 ], + 'just below 1.0 is 1' => [ 0.99, 1 ], + 'exactly 1.0 is 1' => [ 1.0, 1 ], + '1.9 floors to 1' => [ 1.9, 1 ], + '9.0 is 9' => [ 9.0, 9 ], + '9.9 floors to 9' => [ 9.9, 9 ], + '10.0 is 10' => [ 10.0, 10 ], + 'whole grade passes through' => [ 5.0, 5 ], + ]; + } +} From dfa89d2a54c3110b88660cbbbbbb0486d831ebe0 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 14:36:45 +0100 Subject: [PATCH 02/77] Fix: handle false/null from TextStatistics in edac_normalize_fk_grade The fleschKincaidGradeLevel() library method returns false when content has no words or sentences. Remove the strict float type hint and cast to float internally so empty-content posts don't throw a TypeError. Add false and null test cases to cover this path. Co-Authored-By: Claude Sonnet 4.6 --- includes/helper-functions.php | 7 ++++--- .../phpunit/helper-functions/NormalizeFkGradeTest.php | 10 ++++++---- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/includes/helper-functions.php b/includes/helper-functions.php index 2f692c859..16eadae87 100644 --- a/includes/helper-functions.php +++ b/includes/helper-functions.php @@ -949,11 +949,12 @@ function edac_format_datetime_from_utc( string $utc_datetime ): string { * * @since 1.44.0 * - * @param float $fk_grade Raw Flesch-Kincaid grade level returned by the library. - * @return int Normalized grade: 0 when the library returned 0 (not enough content), + * @param float|bool|null $fk_grade Raw Flesch-Kincaid grade level returned by the library. + * @return int Normalized grade: 0 when the library returned 0, false, or null (not enough content), * otherwise max(1, floor($fk_grade)). */ -function edac_normalize_fk_grade( float $fk_grade ): int { +function edac_normalize_fk_grade( $fk_grade ): int { + $fk_grade = (float) $fk_grade; if ( $fk_grade <= 0 ) { return 0; } diff --git a/tests/phpunit/helper-functions/NormalizeFkGradeTest.php b/tests/phpunit/helper-functions/NormalizeFkGradeTest.php index c8a87292c..ad31f8025 100644 --- a/tests/phpunit/helper-functions/NormalizeFkGradeTest.php +++ b/tests/phpunit/helper-functions/NormalizeFkGradeTest.php @@ -19,17 +19,17 @@ class NormalizeFkGradeTest extends WP_UnitTestCase { * * @dataProvider data_normalize_fk_grade * - * @param float $input Raw FK grade float. - * @param int $expected Expected normalized integer grade. + * @param float|bool|null $input Raw FK grade value (float, or false/null from the library on empty content). + * @param int $expected Expected normalized integer grade. */ - public function test_normalize_fk_grade( float $input, int $expected ) { + public function test_normalize_fk_grade( $input, int $expected ) { $this->assertSame( $expected, edac_normalize_fk_grade( $input ) ); } /** * Data provider for test_normalize_fk_grade. * - * @return array + * @return array */ public static function data_normalize_fk_grade(): array { return [ @@ -44,6 +44,8 @@ public static function data_normalize_fk_grade(): array { '9.9 floors to 9' => [ 9.9, 9 ], '10.0 is 10' => [ 10.0, 10 ], 'whole grade passes through' => [ 5.0, 5 ], + 'false returns zero' => [ false, 0 ], + 'null returns zero' => [ null, 0 ], ]; } } From e616edecffbbe28070168868b469468f36b5c994 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 14:57:32 +0100 Subject: [PATCH 03/77] chore: use x.x.x placeholder for @since tags on new function Release tooling will replace x.x.x with the actual version on release. Co-Authored-By: Claude Sonnet 4.6 --- includes/helper-functions.php | 2 +- tests/phpunit/helper-functions/NormalizeFkGradeTest.php | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/includes/helper-functions.php b/includes/helper-functions.php index 16eadae87..dd497fc7a 100644 --- a/includes/helper-functions.php +++ b/includes/helper-functions.php @@ -947,7 +947,7 @@ function edac_format_datetime_from_utc( string $utc_datetime ): string { * very simple content as "not calculable." Values above 0 but below 1 are * normalized to 1 so that compliance checks treat them correctly. * - * @since 1.44.0 + * @since x.x.x * * @param float|bool|null $fk_grade Raw Flesch-Kincaid grade level returned by the library. * @return int Normalized grade: 0 when the library returned 0, false, or null (not enough content), diff --git a/tests/phpunit/helper-functions/NormalizeFkGradeTest.php b/tests/phpunit/helper-functions/NormalizeFkGradeTest.php index ad31f8025..49abda043 100644 --- a/tests/phpunit/helper-functions/NormalizeFkGradeTest.php +++ b/tests/phpunit/helper-functions/NormalizeFkGradeTest.php @@ -3,14 +3,14 @@ * Tests for the edac_normalize_fk_grade helper. * * @package Accessibility_Checker - * @since 1.44.0 + * @since x.x.x */ /** * Tests for edac_normalize_fk_grade. * * @covers ::edac_normalize_fk_grade - * @since 1.44.0 + * @since x.x.x */ class NormalizeFkGradeTest extends WP_UnitTestCase { From a877530cd1e1769d17f610450d21cbf642e2b764 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 15:24:21 +0100 Subject: [PATCH 04/77] Add @since placeholder update tool --- tools/update-since-tags.php | 221 ++++++++++++++++++++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 tools/update-since-tags.php diff --git a/tools/update-since-tags.php b/tools/update-since-tags.php new file mode 100644 index 000000000..663421cd9 --- /dev/null +++ b/tools/update-since-tags.php @@ -0,0 +1,221 @@ +#!/usr/bin/env php + [options]\n\n"; + echo "Options:\n"; + echo " --root= Root directory to scan (default: current working directory)\n"; + echo " --placeholder= Placeholder token after @since (default: x.x.x)\n"; + echo " --changed-since-tag= Only scan tracked PHP files changed since this Git tag/ref\n"; + echo " --changed-since-last-tag Only scan tracked PHP files changed since latest Git tag\n"; + echo " --dry-run Show what would be changed without writing files\n"; + echo " --help Show this help\n"; + exit( EDAC_SINCE_TOOL_EXIT_OK ); +} + +// Backward compatibility for the earlier positional-argument usage. +$version = $opts['version'] ?? ( $argv[1] ?? '' ); +if ( ! is_string( $version ) || ! preg_match( '/^\d+\.\d+\.\d+$/', $version ) ) { + fwrite( STDERR, "Error: --version is required and must be in x.y.z format.\n" ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); +} + +$root = $opts['root'] ?? dirname( __DIR__ ); +if ( ! is_string( $root ) || ! is_dir( $root ) ) { + fwrite( STDERR, 'Error: root directory does not exist: ' . (string) $root . "\n" ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); +} +$root = rtrim( (string) $root, DIRECTORY_SEPARATOR ); + +$placeholder = $opts['placeholder'] ?? 'x.x.x'; +if ( ! is_string( $placeholder ) || '' === trim( $placeholder ) ) { + fwrite( STDERR, "Error: --placeholder must be a non-empty string.\n" ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); +} + +$changed_since_tag = $opts['changed-since-tag'] ?? null; +$changed_since_last_tag = isset( $opts['changed-since-last-tag'] ); +$dry_run = isset( $opts['dry-run'] ); + +if ( null !== $changed_since_tag && $changed_since_last_tag ) { + fwrite( STDERR, "Error: use either --changed-since-tag or --changed-since-last-tag, not both.\n" ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); +} + +if ( $changed_since_last_tag ) { + $changed_since_tag = trim( edac_run_git( $root, 'describe --tags --abbrev=0' ) ); + if ( '' === $changed_since_tag ) { + fwrite( STDERR, "Error: no tags found to use with --changed-since-last-tag.\n" ); + exit( EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR ); + } +} + +$excluded_dirs = [ '.git', 'vendor', 'node_modules', 'build', 'dist' ]; +$php_files = ( null !== $changed_since_tag ) + ? edac_get_changed_php_files_since_ref( $root, (string) $changed_since_tag ) + : edac_get_all_php_files( $root, $excluded_dirs ); + +$placeholder_regex = preg_quote( $placeholder, '/' ); +$pattern = '/@since(\s+)' . $placeholder_regex . '/i'; +$replacement = '@since${1}' . $version; + +$updated_files = 0; +$updated_tags = 0; +$scanned_files = 0; + +foreach ( $php_files as $file_path ) { + ++$scanned_files; + + if ( ! is_file( $file_path ) || ! is_readable( $file_path ) ) { + continue; + } + + $contents = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown + if ( false === $contents ) { + fwrite( STDERR, "Warning: unable to read file: {$file_path}\n" ); + continue; + } + + $count = preg_match_all( $pattern, $contents ); + if ( 0 === $count ) { + continue; + } + + $new_contents = preg_replace( $pattern, $replacement, $contents ); + if ( ! is_string( $new_contents ) || $new_contents === $contents ) { + continue; + } + + $display_path = ltrim( str_replace( $root, '', $file_path ), DIRECTORY_SEPARATOR ); + + if ( $dry_run ) { + echo "Would update {$count} tag(s) in {$display_path}\n"; + } else { + $write_result = file_put_contents( $file_path, $new_contents ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents + if ( false === $write_result ) { + fwrite( STDERR, "Warning: unable to write file: {$file_path}\n" ); + continue; + } + echo "Updated {$count} tag(s) in {$display_path}\n"; + } + + ++$updated_files; + $updated_tags += (int) $count; +} + +$mode = $dry_run ? 'DRY RUN' : 'DONE'; +echo "{$mode}: replaced {$updated_tags} placeholder @since tag(s) across {$updated_files} file(s); scanned {$scanned_files} PHP file(s).\n"; + +exit( EDAC_SINCE_TOOL_EXIT_OK ); + +/** + * Get all PHP files in the repository, excluding known dependency/build dirs. + * + * @param string $root Root directory. + * @param string[] $excluded_dirs Directory names to skip. + * @return string[] + */ +function edac_get_all_php_files( string $root, array $excluded_dirs ): array { + $files = []; + $iterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ) + ); + + foreach ( $iterator as $item ) { + if ( ! $item instanceof SplFileInfo || $item->isDir() ) { + continue; + } + + $path = $item->getPathname(); + $relative_path = ltrim( str_replace( $root, '', $path ), DIRECTORY_SEPARATOR ); + + foreach ( $excluded_dirs as $excluded ) { + $needle = $excluded . DIRECTORY_SEPARATOR; + if ( 0 === strpos( $relative_path, $needle ) || false !== strpos( $relative_path, DIRECTORY_SEPARATOR . $needle ) ) { + continue 2; + } + } + + if ( 'php' === strtolower( (string) $item->getExtension() ) ) { + $files[] = $path; + } + } + + sort( $files ); + + return $files; +} + +/** + * Get tracked PHP files changed between a given ref and HEAD. + * + * @param string $root Root directory. + * @param string $ref Git ref/tag. + * @return string[] + */ +function edac_get_changed_php_files_since_ref( string $root, string $ref ): array { + $cmd = 'diff --name-only ' . escapeshellarg( $ref . '..HEAD' ) . ' -- ' . escapeshellarg( '*.php' ); + $output = edac_run_git( $root, $cmd ); + + $files = []; + foreach ( preg_split( '/\r?\n/', trim( $output ) ) as $line ) { + if ( '' === $line ) { + continue; + } + + $path = $root . DIRECTORY_SEPARATOR . $line; + if ( is_file( $path ) ) { + $files[] = $path; + } + } + + sort( $files ); + + return $files; +} + +/** + * Run a Git command in the target root and return stdout. + * + * @param string $root Root directory. + * @param string $args Git args. + * @return string + */ +function edac_run_git( string $root, string $args ): string { + $cmd = 'git -C ' . escapeshellarg( $root ) . ' ' . $args . ' 2>/dev/null'; + $output = shell_exec( $cmd ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec + + return is_string( $output ) ? $output : ''; +} + + From 1fb78599f153db82f65edd04aa6bd27b0deb8de6 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 15:24:21 +0100 Subject: [PATCH 05/77] Update release prep flow for @since sync and tag checks --- scripts/prep_release.sh | 105 +++++++++++++++++++++++++++++++++------- 1 file changed, 88 insertions(+), 17 deletions(-) diff --git a/scripts/prep_release.sh b/scripts/prep_release.sh index e996fafd2..70880797d 100755 --- a/scripts/prep_release.sh +++ b/scripts/prep_release.sh @@ -33,10 +33,39 @@ MAIN_BRANCH=main DEVELOP_BRANCH=develop RELEASE_BRANCH_NAME=release/${BUMPED_VERSION} +# Ensure local tags are fully in sync with remote tags before release prep. +verify_local_tags_match_remote() { + local local_tags_file + local remote_tags_file + + local_tags_file=$(mktemp) + remote_tags_file=$(mktemp) + + # Always clean up temporary files before returning. + trap 'rm -f "${local_tags_file}" "${remote_tags_file}"' RETURN + + # --refs avoids peeled annotated-tag entries (^{}) for clean one-line refs. + git show-ref --tags | awk '{print $1" "$2}' | sort > "${local_tags_file}" + git ls-remote --tags --refs origin | awk '{print $1" "$2}' | sort > "${remote_tags_file}" + + if ! diff -u "${remote_tags_file}" "${local_tags_file}" >/dev/null; then + echo + echo "Error: local tags do not match tags on origin." + echo "Please sync tags before running release prep." + echo + diff -u "${remote_tags_file}" "${local_tags_file}" || true + return 1 + fi + + echo "Tag sync check passed: local tags match origin." + return 0 +} + echo echo "Creating release branch" echo -git fetch +git fetch origin --tags --prune-tags +verify_local_tags_match_remote git checkout ${DEVELOP_BRANCH} git pull git checkout -b ${RELEASE_BRANCH_NAME} @@ -56,20 +85,20 @@ echo # Function to fetch WordPress versions and calculate required version update_wp_requires_version() { echo "Fetching WordPress version list..." - + # Get current "Tested up to" version from readme.txt local tested_up_to=$(grep "Tested up to:" "${README_PATH}" | sed -E 's/Tested up to: ([0-9]+\.[0-9]+).*/\1/') - + if [[ -z "${tested_up_to}" ]]; then echo "Error: Could not extract 'Tested up to' version from readme.txt" >&2 return 1 fi - + echo "Current 'Tested up to': ${tested_up_to}" - + # Try to fetch WordPress versions from the API first local wp_versions_json=$(curl -s --max-time 10 "https://api.wordpress.org/core/version-check/1.7/" 2>/dev/null || echo "") - + if [[ -n "${wp_versions_json}" ]]; then echo "Using WordPress API for version data..." # Parse JSON response to get version list in reverse chronological order @@ -83,7 +112,7 @@ update_wp_requires_version() { echo "API unavailable, trying to parse releases page..." # Fallback: try to parse the releases page local releases_html=$(curl -s --max-time 15 "https://wordpress.org/download/releases/" 2>/dev/null || echo "") - + if [[ -n "${releases_html}" ]]; then # Extract version numbers from the releases page # Look for patterns like "WordPress 6.8" or "wordpress-6.7.zip" @@ -97,20 +126,20 @@ update_wp_requires_version() { return 1 fi fi - + if [[ -z "${versions_list}" ]]; then echo "Error: No WordPress versions found in response" >&2 return 1 fi - + echo "Found WordPress versions (latest first):" echo "${versions_list}" | head -10 - + # Find the current tested version in the list and go back 2 versions (to support last 3 versions) local found_current=false local version_count=0 local target_version="" - + while IFS= read -r version; do if [[ "${found_current}" == true ]]; then version_count=$((version_count + 1)) @@ -123,13 +152,13 @@ update_wp_requires_version() { echo "Found current tested version ${tested_up_to} in version list" fi done <<< "${versions_list}" - + if [[ "${found_current}" == false ]]; then echo "Warning: Current 'Tested up to' version ${tested_up_to} not found in WordPress version list" >&2 echo "Available versions: $(echo "${versions_list}" | tr '\n' ' ')" >&2 return 1 fi - + if [[ -z "${target_version}" ]]; then echo "Warning: Could not find version 2 releases back from ${tested_up_to}" >&2 # Try to use the oldest version we found if we don't have enough history @@ -140,19 +169,19 @@ update_wp_requires_version() { return 1 fi fi - + # Ensure we don't go below WordPress 5.0 (reasonable minimum) local min_major=$(echo "${target_version}" | cut -d. -f1) if [[ ${min_major} -lt 5 ]]; then target_version="5.0" echo "Adjusted to minimum supported version: ${target_version}" fi - + echo "Setting 'Requires at least' to: ${target_version} (2 versions back from ${tested_up_to} to support last 3 versions)" - + # Update the readme.txt file sed -i.bak -E "s/(Requires at least: )[0-9]+\.[0-9]+/\1${target_version}/" "${README_PATH}" - + return 0 } @@ -170,6 +199,48 @@ echo "Committing version bump" echo git add ${MAIN_FILE_PATH} ${PACKAGE_JSON_PATH} ${README_PATH} git commit -m "Bump version ${VERSION} -> ${BUMPED_VERSION}" + +echo +echo "Updating @since placeholder tags to ${BUMPED_VERSION}" +echo + +# Stash any existing uncommitted work (tracked modifications + untracked files) +# so that the subsequent git diff picks up only what the tool changes. +STASH_MESSAGE="prep_release: pre-since-tag stash" +git stash push --include-untracked -m "${STASH_MESSAGE}" +STASH_CREATED=$? + +# Ensure the stash is always restored, even if the script exits early. +restore_stash() { + if [[ ${STASH_CREATED} -eq 0 ]]; then + echo + echo "Restoring stashed changes" + echo + git stash pop + fi +} +trap restore_stash EXIT + +php tools/update-since-tags.php --version="${BUMPED_VERSION}" --changed-since-last-tag + +# Stage only the tracked PHP files that were modified by the tool. +# git diff --name-only only lists tracked files with unstaged changes, +# so untracked files are never included. +SINCE_CHANGES=$(git diff --name-only -- '*.php') +if [[ -n "${SINCE_CHANGES}" ]]; then + echo + echo "Committing @since tag updates" + echo + echo "${SINCE_CHANGES}" | xargs git add -- + git commit -m "Update @since placeholders to ${BUMPED_VERSION}" +else + echo "No @since placeholder tags found — skipping commit." +fi + +# Restore stashed work now (trap will also fire on EXIT, guard against double-pop). +restore_stash +trap - EXIT + git push -u origin ${RELEASE_BRANCH_NAME} echo From 4c1dfebf4d68fc2e264a267dfe02f011a21dcb77 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 15:32:33 +0100 Subject: [PATCH 06/77] Fix PHPCS issues in since-tag update tool --- tools/update-since-tags.php | 88 +++++++++++++++++++++---------------- 1 file changed, 49 insertions(+), 39 deletions(-) diff --git a/tools/update-since-tags.php b/tools/update-since-tags.php index 663421cd9..36130b526 100644 --- a/tools/update-since-tags.php +++ b/tools/update-since-tags.php @@ -14,8 +14,8 @@ declare( strict_types=1 ); -const EDAC_SINCE_TOOL_EXIT_OK = 0; -const EDAC_SINCE_TOOL_EXIT_BAD_ARGS = 1; +const EDAC_SINCE_TOOL_EXIT_OK = 0; +const EDAC_SINCE_TOOL_EXIT_BAD_ARGS = 1; const EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR = 2; $opts = getopt( @@ -32,65 +32,66 @@ ); if ( isset( $opts['help'] ) ) { - echo "Usage: php tools/update-since-tags.php --version= [options]\n\n"; - echo "Options:\n"; - echo " --root= Root directory to scan (default: current working directory)\n"; - echo " --placeholder= Placeholder token after @since (default: x.x.x)\n"; - echo " --changed-since-tag= Only scan tracked PHP files changed since this Git tag/ref\n"; - echo " --changed-since-last-tag Only scan tracked PHP files changed since latest Git tag\n"; - echo " --dry-run Show what would be changed without writing files\n"; - echo " --help Show this help\n"; - exit( EDAC_SINCE_TOOL_EXIT_OK ); + edac_write_line( 'Usage: php tools/update-since-tags.php --version= [options]' ); + edac_write_line( '' ); + edac_write_line( 'Options:' ); + edac_write_line( ' --root= Root directory to scan (default: current working directory)' ); + edac_write_line( ' --placeholder= Placeholder token after @since (default: x.x.x)' ); + edac_write_line( ' --changed-since-tag= Only scan tracked PHP files changed since this Git tag/ref' ); + edac_write_line( ' --changed-since-last-tag Only scan tracked PHP files changed since latest Git tag' ); + edac_write_line( ' --dry-run Show what would be changed without writing files' ); + edac_write_line( ' --help Show this help' ); + exit( EDAC_SINCE_TOOL_EXIT_OK ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } // Backward compatibility for the earlier positional-argument usage. $version = $opts['version'] ?? ( $argv[1] ?? '' ); if ( ! is_string( $version ) || ! preg_match( '/^\d+\.\d+\.\d+$/', $version ) ) { - fwrite( STDERR, "Error: --version is required and must be in x.y.z format.\n" ); - exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); + edac_write_line( 'Error: --version is required and must be in x.y.z format.', STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } $root = $opts['root'] ?? dirname( __DIR__ ); if ( ! is_string( $root ) || ! is_dir( $root ) ) { - fwrite( STDERR, 'Error: root directory does not exist: ' . (string) $root . "\n" ); - exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); + edac_write_line( 'Error: root directory does not exist: ' . (string) $root, STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } $root = rtrim( (string) $root, DIRECTORY_SEPARATOR ); $placeholder = $opts['placeholder'] ?? 'x.x.x'; if ( ! is_string( $placeholder ) || '' === trim( $placeholder ) ) { - fwrite( STDERR, "Error: --placeholder must be a non-empty string.\n" ); - exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); + edac_write_line( 'Error: --placeholder must be a non-empty string.', STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } -$changed_since_tag = $opts['changed-since-tag'] ?? null; +$changed_since_tag = $opts['changed-since-tag'] ?? null; $changed_since_last_tag = isset( $opts['changed-since-last-tag'] ); -$dry_run = isset( $opts['dry-run'] ); +$dry_run = isset( $opts['dry-run'] ); if ( null !== $changed_since_tag && $changed_since_last_tag ) { - fwrite( STDERR, "Error: use either --changed-since-tag or --changed-since-last-tag, not both.\n" ); - exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); + edac_write_line( 'Error: use either --changed-since-tag or --changed-since-last-tag, not both.', STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } if ( $changed_since_last_tag ) { $changed_since_tag = trim( edac_run_git( $root, 'describe --tags --abbrev=0' ) ); if ( '' === $changed_since_tag ) { - fwrite( STDERR, "Error: no tags found to use with --changed-since-last-tag.\n" ); - exit( EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR ); + edac_write_line( 'Error: no tags found to use with --changed-since-last-tag.', STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } } $excluded_dirs = [ '.git', 'vendor', 'node_modules', 'build', 'dist' ]; -$php_files = ( null !== $changed_since_tag ) +$php_files = ( null !== $changed_since_tag ) ? edac_get_changed_php_files_since_ref( $root, (string) $changed_since_tag ) : edac_get_all_php_files( $root, $excluded_dirs ); $placeholder_regex = preg_quote( $placeholder, '/' ); -$pattern = '/@since(\s+)' . $placeholder_regex . '/i'; -$replacement = '@since${1}' . $version; +$pattern = '/@since(\s+)' . $placeholder_regex . '/i'; +$replacement = '@since${1}' . $version; $updated_files = 0; -$updated_tags = 0; +$updated_tags = 0; $scanned_files = 0; foreach ( $php_files as $file_path ) { @@ -102,7 +103,7 @@ $contents = file_get_contents( $file_path ); // phpcs:ignore WordPressVIPMinimum.Performance.FetchingRemoteData.FileGetContentsUnknown if ( false === $contents ) { - fwrite( STDERR, "Warning: unable to read file: {$file_path}\n" ); + edac_write_line( "Warning: unable to read file: {$file_path}", STDERR ); continue; } @@ -119,24 +120,24 @@ $display_path = ltrim( str_replace( $root, '', $file_path ), DIRECTORY_SEPARATOR ); if ( $dry_run ) { - echo "Would update {$count} tag(s) in {$display_path}\n"; + edac_write_line( "Would update {$count} tag(s) in {$display_path}" ); } else { $write_result = file_put_contents( $file_path, $new_contents ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_file_put_contents if ( false === $write_result ) { - fwrite( STDERR, "Warning: unable to write file: {$file_path}\n" ); + edac_write_line( "Warning: unable to write file: {$file_path}", STDERR ); continue; } - echo "Updated {$count} tag(s) in {$display_path}\n"; + edac_write_line( "Updated {$count} tag(s) in {$display_path}" ); } ++$updated_files; $updated_tags += (int) $count; } -$mode = $dry_run ? 'DRY RUN' : 'DONE'; -echo "{$mode}: replaced {$updated_tags} placeholder @since tag(s) across {$updated_files} file(s); scanned {$scanned_files} PHP file(s).\n"; +$status_label = $dry_run ? 'DRY RUN' : 'DONE'; +edac_write_line( "{$status_label}: replaced {$updated_tags} placeholder @since tag(s) across {$updated_files} file(s); scanned {$scanned_files} PHP file(s)." ); -exit( EDAC_SINCE_TOOL_EXIT_OK ); +exit( EDAC_SINCE_TOOL_EXIT_OK ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped /** * Get all PHP files in the repository, excluding known dependency/build dirs. @@ -146,7 +147,7 @@ * @return string[] */ function edac_get_all_php_files( string $root, array $excluded_dirs ): array { - $files = []; + $files = []; $iterator = new RecursiveIteratorIterator( new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ) ); @@ -156,7 +157,7 @@ function edac_get_all_php_files( string $root, array $excluded_dirs ): array { continue; } - $path = $item->getPathname(); + $path = $item->getPathname(); $relative_path = ltrim( str_replace( $root, '', $path ), DIRECTORY_SEPARATOR ); foreach ( $excluded_dirs as $excluded ) { @@ -184,7 +185,7 @@ function edac_get_all_php_files( string $root, array $excluded_dirs ): array { * @return string[] */ function edac_get_changed_php_files_since_ref( string $root, string $ref ): array { - $cmd = 'diff --name-only ' . escapeshellarg( $ref . '..HEAD' ) . ' -- ' . escapeshellarg( '*.php' ); + $cmd = 'diff --name-only ' . escapeshellarg( $ref . '..HEAD' ) . ' -- ' . escapeshellarg( '*.php' ); $output = edac_run_git( $root, $cmd ); $files = []; @@ -212,10 +213,19 @@ function edac_get_changed_php_files_since_ref( string $root, string $ref ): arra * @return string */ function edac_run_git( string $root, string $args ): string { - $cmd = 'git -C ' . escapeshellarg( $root ) . ' ' . $args . ' 2>/dev/null'; + $cmd = 'git -C ' . escapeshellarg( $root ) . ' ' . $args . ' 2>/dev/null'; $output = shell_exec( $cmd ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec return is_string( $output ) ? $output : ''; } - +/** + * Write a line to STDOUT/STDERR for CLI usage. + * + * @param string $message Message to output. + * @param resource $stream Output stream, STDOUT by default. + * @return void + */ +function edac_write_line( string $message, $stream = STDOUT ): void { + fwrite( $stream, $message . "\n" ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.file_ops_fwrite +} From 9eb48e01e351070b603849bf83c03bfc7b9f7e6f Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 15:56:00 +0100 Subject: [PATCH 07/77] Address code review feedback on @since tool and release script --- scripts/prep_release.sh | 51 +++++++++-------- tools/update-since-tags.php | 106 ++++++++++++++++++++++++++---------- 2 files changed, 104 insertions(+), 53 deletions(-) diff --git a/scripts/prep_release.sh b/scripts/prep_release.sh index 70880797d..0934df09f 100755 --- a/scripts/prep_release.sh +++ b/scripts/prep_release.sh @@ -34,26 +34,21 @@ DEVELOP_BRANCH=develop RELEASE_BRANCH_NAME=release/${BUMPED_VERSION} # Ensure local tags are fully in sync with remote tags before release prep. +# Uses process substitution to avoid orphaned temp files on early exit. verify_local_tags_match_remote() { - local local_tags_file - local remote_tags_file - - local_tags_file=$(mktemp) - remote_tags_file=$(mktemp) - - # Always clean up temporary files before returning. - trap 'rm -f "${local_tags_file}" "${remote_tags_file}"' RETURN - - # --refs avoids peeled annotated-tag entries (^{}) for clean one-line refs. - git show-ref --tags | awk '{print $1" "$2}' | sort > "${local_tags_file}" - git ls-remote --tags --refs origin | awk '{print $1" "$2}' | sort > "${remote_tags_file}" - - if ! diff -u "${remote_tags_file}" "${local_tags_file}" >/dev/null; then + # --refs strips peeled annotated-tag entries (^{}) for clean one-line refs. + if ! diff -u \ + <(git ls-remote --tags --refs origin 2>/dev/null | awk '{print $1" "$2}' | sort) \ + <(git show-ref --tags 2>/dev/null | awk '{print $1" "$2}' | sort) \ + >/dev/null; then echo echo "Error: local tags do not match tags on origin." - echo "Please sync tags before running release prep." + echo "Please sync tags before running release prep:" + echo " git fetch origin --tags --prune-tags" echo - diff -u "${remote_tags_file}" "${local_tags_file}" || true + diff -u \ + <(git ls-remote --tags --refs origin 2>/dev/null | awk '{print $1" "$2}' | sort) \ + <(git show-ref --tags 2>/dev/null | awk '{print $1" "$2}' | sort) || true return 1 fi @@ -206,16 +201,25 @@ echo # Stash any existing uncommitted work (tracked modifications + untracked files) # so that the subsequent git diff picks up only what the tool changes. +# Track stash ref before/after to guard against popping an unrelated stash when +# git stash push exits 0 even if there was nothing to save. STASH_MESSAGE="prep_release: pre-since-tag stash" +STASH_BEFORE=$(git rev-parse -q --verify refs/stash || true) git stash push --include-untracked -m "${STASH_MESSAGE}" -STASH_CREATED=$? +STASH_AFTER=$(git rev-parse -q --verify refs/stash || true) +STASH_CREATED=false +if [[ -n "${STASH_AFTER}" && "${STASH_AFTER}" != "${STASH_BEFORE}" ]]; then + STASH_CREATED=true +fi # Ensure the stash is always restored, even if the script exits early. +# Setting STASH_CREATED=false before popping prevents the EXIT trap double-pop. restore_stash() { - if [[ ${STASH_CREATED} -eq 0 ]]; then + if [[ "${STASH_CREATED}" == true ]]; then echo echo "Restoring stashed changes" echo + STASH_CREATED=false git stash pop fi } @@ -223,15 +227,14 @@ trap restore_stash EXIT php tools/update-since-tags.php --version="${BUMPED_VERSION}" --changed-since-last-tag -# Stage only the tracked PHP files that were modified by the tool. -# git diff --name-only only lists tracked files with unstaged changes, -# so untracked files are never included. -SINCE_CHANGES=$(git diff --name-only -- '*.php') -if [[ -n "${SINCE_CHANGES}" ]]; then +# Stage only the tracked PHP files modified by the tool. +# Use NUL-delimited output + mapfile + xargs -0 so paths with spaces are safe. +mapfile -d '' SINCE_CHANGES < <(git diff --name-only -z -- '*.php') +if (( ${#SINCE_CHANGES[@]} > 0 )); then echo echo "Committing @since tag updates" echo - echo "${SINCE_CHANGES}" | xargs git add -- + printf '%s\0' "${SINCE_CHANGES[@]}" | xargs -0 git add -- git commit -m "Update @since placeholders to ${BUMPED_VERSION}" else echo "No @since placeholder tags found — skipping commit." diff --git a/tools/update-since-tags.php b/tools/update-since-tags.php index 36130b526..3bd38b3de 100644 --- a/tools/update-since-tags.php +++ b/tools/update-since-tags.php @@ -35,7 +35,7 @@ edac_write_line( 'Usage: php tools/update-since-tags.php --version= [options]' ); edac_write_line( '' ); edac_write_line( 'Options:' ); - edac_write_line( ' --root= Root directory to scan (default: current working directory)' ); + edac_write_line( ' --root= Root directory to scan (default: parent of the tools/ directory)' ); edac_write_line( ' --placeholder= Placeholder token after @since (default: x.x.x)' ); edac_write_line( ' --changed-since-tag= Only scan tracked PHP files changed since this Git tag/ref' ); edac_write_line( ' --changed-since-last-tag Only scan tracked PHP files changed since latest Git tag' ); @@ -51,20 +51,41 @@ exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } -$root = $opts['root'] ?? dirname( __DIR__ ); -if ( ! is_string( $root ) || ! is_dir( $root ) ) { - edac_write_line( 'Error: root directory does not exist: ' . (string) $root, STDERR ); +// getopt() returns false (not null) for optional params given without a value (e.g. --root). +$root_opt = $opts['root'] ?? dirname( __DIR__ ); +if ( false === $root_opt ) { + edac_write_line( 'Error: --root requires a value.', STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +} +if ( ! is_string( $root_opt ) || ! is_dir( $root_opt ) ) { + edac_write_line( 'Error: root directory does not exist: ' . (string) $root_opt, STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +} +// Use realpath() so filesystem-root paths like / or C:\ are preserved correctly. +$root = realpath( $root_opt ); +if ( false === $root ) { + edac_write_line( 'Error: could not resolve root directory: ' . $root_opt, STDERR ); exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } -$root = rtrim( (string) $root, DIRECTORY_SEPARATOR ); -$placeholder = $opts['placeholder'] ?? 'x.x.x'; -if ( ! is_string( $placeholder ) || '' === trim( $placeholder ) ) { +$placeholder_opt = $opts['placeholder'] ?? 'x.x.x'; +if ( false === $placeholder_opt ) { + edac_write_line( 'Error: --placeholder requires a value.', STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +} +if ( ! is_string( $placeholder_opt ) || '' === trim( $placeholder_opt ) ) { edac_write_line( 'Error: --placeholder must be a non-empty string.', STDERR ); exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } +$placeholder = $placeholder_opt; -$changed_since_tag = $opts['changed-since-tag'] ?? null; +// getopt() returns false for optional params given without a value (e.g. --changed-since-tag). +$changed_since_tag_opt = $opts['changed-since-tag'] ?? null; +if ( false === $changed_since_tag_opt ) { + edac_write_line( 'Error: --changed-since-tag requires a value.', STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped +} +$changed_since_tag = $changed_since_tag_opt; $changed_since_last_tag = isset( $opts['changed-since-last-tag'] ); $dry_run = isset( $opts['dry-run'] ); @@ -73,6 +94,15 @@ exit( EDAC_SINCE_TOOL_EXIT_BAD_ARGS ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } +// Validate the supplied ref actually exists before proceeding. +if ( null !== $changed_since_tag ) { + $ref_check = trim( edac_run_git( $root, 'rev-parse --verify ' . escapeshellarg( (string) $changed_since_tag ) ) ); + if ( '' === $ref_check ) { + edac_write_line( 'Error: invalid Git reference or tag: ' . (string) $changed_since_tag, STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } +} + if ( $changed_since_last_tag ) { $changed_since_tag = trim( edac_run_git( $root, 'describe --tags --abbrev=0' ) ); if ( '' === $changed_since_tag ) { @@ -147,28 +177,36 @@ * @return string[] */ function edac_get_all_php_files( string $root, array $excluded_dirs ): array { - $files = []; - $iterator = new RecursiveIteratorIterator( - new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ) + $files = []; + $directory_iterator = new RecursiveDirectoryIterator( $root, RecursiveDirectoryIterator::SKIP_DOTS ); + + // Prune excluded directories before recursing into them for efficiency. + $filter = new RecursiveCallbackFilterIterator( + $directory_iterator, + // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter.FoundAfterLastUsed,VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable + static function ( SplFileInfo $item, $key, RecursiveCallbackFilterIterator $iterator ) use ( $root, $excluded_dirs ): bool { + if ( $item->isDir() ) { + $relative_path = ltrim( substr( $item->getPathname(), strlen( $root ) ), DIRECTORY_SEPARATOR ); + foreach ( $excluded_dirs as $excluded ) { + if ( $relative_path === $excluded || 0 === strpos( $relative_path, $excluded . DIRECTORY_SEPARATOR ) ) { + return false; + } + } + } + + return true; + } ); + $iterator = new RecursiveIteratorIterator( $filter ); + foreach ( $iterator as $item ) { if ( ! $item instanceof SplFileInfo || $item->isDir() ) { continue; } - $path = $item->getPathname(); - $relative_path = ltrim( str_replace( $root, '', $path ), DIRECTORY_SEPARATOR ); - - foreach ( $excluded_dirs as $excluded ) { - $needle = $excluded . DIRECTORY_SEPARATOR; - if ( 0 === strpos( $relative_path, $needle ) || false !== strpos( $relative_path, DIRECTORY_SEPARATOR . $needle ) ) { - continue 2; - } - } - if ( 'php' === strtolower( (string) $item->getExtension() ) ) { - $files[] = $path; + $files[] = $item->getPathname(); } } @@ -180,13 +218,21 @@ function edac_get_all_php_files( string $root, array $excluded_dirs ): array { /** * Get tracked PHP files changed between a given ref and HEAD. * + * Exits with EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR if the git command fails, + * so a bad ref or non-repo root does not silently return an empty list. + * * @param string $root Root directory. * @param string $ref Git ref/tag. * @return string[] */ function edac_get_changed_php_files_since_ref( string $root, string $ref ): array { $cmd = 'diff --name-only ' . escapeshellarg( $ref . '..HEAD' ) . ' -- ' . escapeshellarg( '*.php' ); - $output = edac_run_git( $root, $cmd ); + $output = edac_run_git( $root, $cmd, $exit_code ); + + if ( 0 !== $exit_code ) { + edac_write_line( "Error: git diff failed (exit {$exit_code}) for ref: {$ref}", STDERR ); + exit( EDAC_SINCE_TOOL_EXIT_RUNTIME_ERROR ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + } $files = []; foreach ( preg_split( '/\r?\n/', trim( $output ) ) as $line ) { @@ -208,15 +254,17 @@ function edac_get_changed_php_files_since_ref( string $root, string $ref ): arra /** * Run a Git command in the target root and return stdout. * - * @param string $root Root directory. - * @param string $args Git args. + * @param string $root Root directory. + * @param string $args Git args (already shell-escaped as needed). + * @param int|null $exit_code Reference populated with the git process exit code. * @return string */ -function edac_run_git( string $root, string $args ): string { - $cmd = 'git -C ' . escapeshellarg( $root ) . ' ' . $args . ' 2>/dev/null'; - $output = shell_exec( $cmd ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_shell_exec +function edac_run_git( string $root, string $args, ?int &$exit_code = null ): string { + $cmd = 'git -C ' . escapeshellarg( $root ) . ' ' . $args; + $output = []; + exec( $cmd, $output, $exit_code ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec - return is_string( $output ) ? $output : ''; + return implode( "\n", $output ); } /** From 84273c49e21680c141f5aad33f82974acd15379c Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 19:24:39 +0100 Subject: [PATCH 08/77] Tests: add Jest coverage for 6 scanner rules missing tests Adds test files for color_contrast_failure, text_justified, link_ambiguous_text, link_pdf, link_ms_office_file, and label rules (closes #1692). Also fixes a bug in image-input-has-alt.js where null?.trim() returned undefined instead of failing for textareas and other non-image-input elements, which caused the label rule to pass textareas with no label. Co-Authored-By: Claude Sonnet 4.6 --- src/pageScanner/checks/image-input-has-alt.js | 19 +-- tests/jest/rules/colorContrastFailure.test.js | 69 +++++++++ tests/jest/rules/label.test.js | 142 ++++++++++++++++++ tests/jest/rules/linkAmbiguousText.test.js | 140 +++++++++++++++++ tests/jest/rules/linkMsOfficeFile.test.js | 134 +++++++++++++++++ tests/jest/rules/linkPdf.test.js | 105 +++++++++++++ tests/jest/rules/textJustified.test.js | 88 +++++++++++ 7 files changed, 682 insertions(+), 15 deletions(-) create mode 100644 tests/jest/rules/colorContrastFailure.test.js create mode 100644 tests/jest/rules/label.test.js create mode 100644 tests/jest/rules/linkAmbiguousText.test.js create mode 100644 tests/jest/rules/linkMsOfficeFile.test.js create mode 100644 tests/jest/rules/linkPdf.test.js create mode 100644 tests/jest/rules/textJustified.test.js diff --git a/src/pageScanner/checks/image-input-has-alt.js b/src/pageScanner/checks/image-input-has-alt.js index e0237578d..b720c3c31 100644 --- a/src/pageScanner/checks/image-input-has-alt.js +++ b/src/pageScanner/checks/image-input-has-alt.js @@ -1,23 +1,12 @@ -/** - * Axe core check against nodes to determine if they are tags. - * - * @param {Node} node The node to evaluate. - * @return {boolean} True if the node is a tag, false otherwise. - */ - export default { id: 'image_input_has_alt', evaluate: ( node ) => { - // Not an image input, skip. - if ( node.tagName.toLowerCase() === 'input' && node.type !== 'image' ) { + // Only applies to image inputs. + if ( node.tagName.toLowerCase() !== 'input' || node.type !== 'image' ) { return false; } - // Non empty alt attribute. - if ( node.getAttribute( 'alt' )?.trim() !== '' ) { - return true; - } - - return false; + const alt = node.getAttribute( 'alt' ); + return alt !== null && alt.trim() !== ''; }, }; diff --git a/tests/jest/rules/colorContrastFailure.test.js b/tests/jest/rules/colorContrastFailure.test.js new file mode 100644 index 000000000..354517cb8 --- /dev/null +++ b/tests/jest/rules/colorContrastFailure.test.js @@ -0,0 +1,69 @@ +/** + * Note: axe-core's color-contrast check uses canvas pixel-sampling to determine + * background colors. JSDOM does not implement HTMLCanvasElement.getContext, so + * axe returns checked elements as "incomplete" rather than "violated". Violation + * detection for this rule requires a real browser environment (e2e or Playwright). + * + * These tests confirm the rule registers correctly and does not produce false + * positives for elements where no contrast check is triggered (elements excluded + * by the color-contrast-matches built-in matcher in JSDOM). + */ +import axe from 'axe-core'; + +beforeAll( async () => { + const colorContrastRuleModule = await import( '../../../src/pageScanner/rules/color-contrast-failure.js' ); + + axe.configure( { + rules: [ colorContrastRuleModule.default ], + } ); +} ); + +beforeEach( () => { + document.body.innerHTML = ''; +} ); + +describe( 'Color Contrast Failure', () => { + describe( 'rule registration', () => { + test( 'rule is registered with the correct id', () => { + const rules = axe.getRules( [ 'cat.color' ] ); + const rule = rules.find( ( r ) => r.ruleId === 'color_contrast_failure' ); + expect( rule ).toBeDefined(); + } ); + + test( 'rule targets the correct WCAG criteria', () => { + const rules = axe.getRules(); + const rule = rules.find( ( r ) => r.ruleId === 'color_contrast_failure' ); + expect( rule.tags ).toContain( 'wcag2aa' ); + expect( rule.tags ).toContain( 'wcag143' ); + } ); + } ); + + describe( 'no false positives for non-text elements', () => { + const testCases = [ + { + name: 'should not flag an empty paragraph', + html: '

', + }, + { + name: 'should not flag a hidden element', + html: '

Hidden text

', + }, + { + name: 'should not flag a div with no text content', + html: '
', + }, + ]; + + testCases.forEach( ( testCase ) => { + test( testCase.name, async () => { + document.body.innerHTML = testCase.html; + + const results = await axe.run( document.body, { + runOnly: [ 'color_contrast_failure' ], + } ); + + expect( results.violations.length ).toBe( 0 ); + } ); + } ); + } ); +} ); diff --git a/tests/jest/rules/label.test.js b/tests/jest/rules/label.test.js new file mode 100644 index 000000000..2b5244905 --- /dev/null +++ b/tests/jest/rules/label.test.js @@ -0,0 +1,142 @@ +import axe from 'axe-core'; + +beforeAll( async () => { + const labelRuleModule = await import( '../../../src/pageScanner/rules/extended/label.js' ); + const imageInputHasAltCheckModule = await import( '../../../src/pageScanner/checks/image-input-has-alt.js' ); + + axe.configure( { + rules: [ labelRuleModule.default ], + checks: [ imageInputHasAltCheckModule.default ], + } ); +} ); + +beforeEach( () => { + document.body.innerHTML = ''; +} ); + +describe( 'Label Rule (Extended)', () => { + const testCases = [ + // Passing cases — properly labeled inputs + { + name: 'should pass for input with explicit label via for/id', + html: '', + shouldPass: true, + }, + { + name: 'should pass for input wrapped in an implicit label', + html: '', + shouldPass: true, + }, + { + name: 'should pass for input with aria-label', + html: '', + shouldPass: true, + }, + { + name: 'should pass for input with aria-labelledby', + html: 'Phone Number', + shouldPass: true, + }, + { + name: 'should pass for input with a non-empty title attribute', + html: '', + shouldPass: true, + }, + { + name: 'should pass for a textarea with an explicit label', + html: '', + shouldPass: true, + }, + { + name: 'should pass for an image input with a descriptive alt attribute', + html: '', + shouldPass: true, + }, + { + name: 'should pass for a checkbox with an explicit label', + html: '', + shouldPass: true, + }, + { + name: 'should pass for a radio button with an explicit label', + html: '', + shouldPass: true, + }, + + // Passing cases — excluded input types + { + name: 'should pass for hidden input (excluded by rule)', + html: '', + shouldPass: true, + }, + { + name: 'should pass for submit button input (excluded by rule)', + html: '', + shouldPass: true, + }, + { + name: 'should pass for reset button input (excluded by rule)', + html: '', + shouldPass: true, + }, + { + name: 'should pass for button type input (excluded by rule)', + html: '', + shouldPass: true, + }, + + // Failing cases — missing or inadequate labels + { + name: 'should fail for text input with no label', + html: '', + shouldPass: false, + }, + { + name: 'should fail for text input with id but no associated label', + html: '

Email address

', + shouldPass: false, + }, + { + name: 'should fail for textarea with no label', + html: '', + shouldPass: false, + }, + { + name: 'should fail for password input with no label', + html: '', + shouldPass: false, + }, + { + name: 'should fail for email input with no label', + html: '', + shouldPass: false, + }, + { + name: 'should fail for image input with empty alt attribute', + html: '', + shouldPass: false, + }, + { + name: 'should fail for checkbox with no label', + html: '', + shouldPass: false, + }, + ]; + + testCases.forEach( ( testCase ) => { + test( testCase.name, async () => { + document.body.innerHTML = testCase.html; + + const results = await axe.run( document.body, { + runOnly: [ 'label' ], + } ); + + if ( testCase.shouldPass ) { + expect( results.violations.length ).toBe( 0 ); + } else { + expect( results.violations.length ).toBeGreaterThan( 0 ); + expect( results.violations[ 0 ].id ).toBe( 'label' ); + } + } ); + } ); +} ); diff --git a/tests/jest/rules/linkAmbiguousText.test.js b/tests/jest/rules/linkAmbiguousText.test.js new file mode 100644 index 000000000..9f97d8a60 --- /dev/null +++ b/tests/jest/rules/linkAmbiguousText.test.js @@ -0,0 +1,140 @@ +import axe from 'axe-core'; + +beforeAll( async () => { + const linkAmbiguousTextRuleModule = await import( '../../../src/pageScanner/rules/link-ambiguous-text.js' ); + const hasAmbiguousTextCheckModule = await import( '../../../src/pageScanner/checks/has-ambiguous-text.js' ); + + axe.configure( { + rules: [ linkAmbiguousTextRuleModule.default ], + checks: [ hasAmbiguousTextCheckModule.default ], + } ); +} ); + +beforeEach( () => { + document.body.innerHTML = ''; +} ); + +describe( 'Link Ambiguous Text Rule', () => { + const testCases = [ + // Passing cases — descriptive link text + { + name: 'should pass for descriptive link text', + html: 'Learn about our team', + shouldPass: true, + }, + { + name: 'should pass for "Read More About Accessibility"', + html: 'Read More About Accessibility', + shouldPass: true, + }, + { + name: 'should pass for "Download Our Accessibility Guide"', + html: 'Download Our Accessibility Guide', + shouldPass: true, + }, + { + name: 'should pass for descriptive aria-label on ambiguous link text', + html: 'Read more', + shouldPass: true, + }, + { + name: 'should pass for descriptive aria-labelledby', + html: 'Visit our accessibility resourceshere', + shouldPass: true, + }, + { + name: 'should pass for an image link with descriptive alt text', + html: 'Return to homepage', + shouldPass: true, + }, + { + name: 'should pass for a link with no text content (empty link)', + html: '', + shouldPass: true, + }, + + // Failing cases — ambiguous link text + { + name: 'should fail for "click here"', + html: 'click here', + shouldPass: false, + }, + { + name: 'should fail for "Click Here" (case-insensitive)', + html: 'Click Here', + shouldPass: false, + }, + { + name: 'should fail for "here"', + html: 'here', + shouldPass: false, + }, + { + name: 'should fail for "read more"', + html: 'read more', + shouldPass: false, + }, + { + name: 'should fail for "learn more"', + html: 'learn more', + shouldPass: false, + }, + { + name: 'should fail for "more"', + html: 'more', + shouldPass: false, + }, + { + name: 'should fail for "download"', + html: 'download', + shouldPass: false, + }, + { + name: 'should fail for "continue reading"', + html: 'continue reading', + shouldPass: false, + }, + { + name: 'should fail for "details"', + html: 'details', + shouldPass: false, + }, + { + name: 'should fail for ambiguous aria-label', + html: 'Visit our page', + shouldPass: false, + }, + { + name: 'should fail for ambiguous aria-labelledby', + html: 'click hereVisit page', + shouldPass: false, + }, + { + name: 'should fail for image link with ambiguous alt text', + html: 'here', + shouldPass: false, + }, + { + name: 'should fail for "More..." (normalized to "more")', + html: 'More...', + shouldPass: false, + }, + ]; + + testCases.forEach( ( testCase ) => { + test( testCase.name, async () => { + document.body.innerHTML = testCase.html; + + const results = await axe.run( document.body, { + runOnly: [ 'link_ambiguous_text' ], + } ); + + if ( testCase.shouldPass ) { + expect( results.violations.length ).toBe( 0 ); + } else { + expect( results.violations.length ).toBeGreaterThan( 0 ); + expect( results.violations[ 0 ].id ).toBe( 'link_ambiguous_text' ); + } + } ); + } ); +} ); diff --git a/tests/jest/rules/linkMsOfficeFile.test.js b/tests/jest/rules/linkMsOfficeFile.test.js new file mode 100644 index 000000000..5227fef3b --- /dev/null +++ b/tests/jest/rules/linkMsOfficeFile.test.js @@ -0,0 +1,134 @@ +import axe from 'axe-core'; + +beforeAll( async () => { + const linkMsOfficeFileRuleModule = await import( '../../../src/pageScanner/rules/link-ms-office-file.js' ); + const alwaysFailCheckModule = await import( '../../../src/pageScanner/checks/always-fail.js' ); + + axe.configure( { + rules: [ linkMsOfficeFileRuleModule.default ], + checks: [ alwaysFailCheckModule.default ], + } ); +} ); + +beforeEach( () => { + document.body.innerHTML = ''; +} ); + +describe( 'Link to MS Office File Rule', () => { + const testCases = [ + // Passing cases — not MS Office links + { + name: 'should pass for a link to an HTML page', + html: 'Visit our page', + shouldPass: true, + }, + { + name: 'should pass for a link to a PDF', + html: 'Download PDF', + shouldPass: true, + }, + { + name: 'should pass for a link with no href', + html: 'Anchor without href', + shouldPass: true, + }, + { + name: 'should pass for a link with empty href', + html: 'Empty href', + shouldPass: true, + }, + { + name: 'should pass for a URL with "doc" in a path segment but not as extension', + html: 'Documentation Guide', + shouldPass: true, + }, + + // Failing cases — Word documents + { + name: 'should fail for a link to a .doc file', + html: 'Download Report', + shouldPass: false, + }, + { + name: 'should fail for a link to a .docx file', + html: 'Download a Sample Plan', + shouldPass: false, + }, + { + name: 'should fail for a .DOC file (uppercase)', + html: 'Report', + shouldPass: false, + }, + { + name: 'should fail for a .docx file with query parameters', + html: 'Download Document', + shouldPass: false, + }, + { + name: 'should fail for a .docx file with an anchor', + html: 'Document Section', + shouldPass: false, + }, + + // Failing cases — Excel spreadsheets + { + name: 'should fail for a link to a .xls file', + html: 'Download Spreadsheet', + shouldPass: false, + }, + { + name: 'should fail for a link to a .xlsx file', + html: 'Download Budget', + shouldPass: false, + }, + { + name: 'should fail for a .XLSX file (uppercase)', + html: 'Budget Spreadsheet', + shouldPass: false, + }, + + // Failing cases — PowerPoint presentations + { + name: 'should fail for a link to a .ppt file', + html: 'Download Presentation', + shouldPass: false, + }, + { + name: 'should fail for a link to a .pptx file', + html: 'Download Slides', + shouldPass: false, + }, + { + name: 'should fail for a link to a .pps file', + html: 'Download Slideshow', + shouldPass: false, + }, + { + name: 'should fail for a link to a .ppsx file', + html: 'Download Slideshow', + shouldPass: false, + }, + { + name: 'should fail for a .PPTX file (uppercase)', + html: 'Presentation', + shouldPass: false, + }, + ]; + + testCases.forEach( ( testCase ) => { + test( testCase.name, async () => { + document.body.innerHTML = testCase.html; + + const results = await axe.run( document.body, { + runOnly: [ 'link_ms_office_file' ], + } ); + + if ( testCase.shouldPass ) { + expect( results.violations.length ).toBe( 0 ); + } else { + expect( results.violations.length ).toBeGreaterThan( 0 ); + expect( results.violations[ 0 ].id ).toBe( 'link_ms_office_file' ); + } + } ); + } ); +} ); diff --git a/tests/jest/rules/linkPdf.test.js b/tests/jest/rules/linkPdf.test.js new file mode 100644 index 000000000..e9aa9ee74 --- /dev/null +++ b/tests/jest/rules/linkPdf.test.js @@ -0,0 +1,105 @@ +import axe from 'axe-core'; + +beforeAll( async () => { + const linkPdfRuleModule = await import( '../../../src/pageScanner/rules/link-pdf.js' ); + const alwaysFailCheckModule = await import( '../../../src/pageScanner/checks/always-fail.js' ); + + axe.configure( { + rules: [ linkPdfRuleModule.default ], + checks: [ alwaysFailCheckModule.default ], + } ); +} ); + +beforeEach( () => { + document.body.innerHTML = ''; +} ); + +describe( 'Link to PDF Rule', () => { + const testCases = [ + // Passing cases — not PDF links + { + name: 'should pass for a link to an HTML page', + html: 'Visit our page', + shouldPass: true, + }, + { + name: 'should pass for a link to a Word document', + html: 'Download report', + shouldPass: true, + }, + { + name: 'should pass for a link with no href', + html: 'Anchor without href', + shouldPass: true, + }, + { + name: 'should pass for a link with empty href', + html: 'Empty href', + shouldPass: true, + }, + { + name: 'should pass for a link to an image', + html: 'View photo', + shouldPass: true, + }, + { + name: 'should pass for a URL that contains "pdf" in a directory name but not as extension', + html: 'PDF Resources', + shouldPass: true, + }, + + // Failing cases — PDF links + { + name: 'should fail for a link ending in .pdf', + html: 'Download our Brochure', + shouldPass: false, + }, + { + name: 'should fail for a link ending in .PDF (uppercase)', + html: 'Download Report', + shouldPass: false, + }, + { + name: 'should fail for a PDF link with query parameters', + html: 'Versioned Document', + shouldPass: false, + }, + { + name: 'should fail for a PDF link with uppercase extension and query params', + html: 'Download PDF', + shouldPass: false, + }, + { + name: 'should fail for a PDF link with a URL anchor', + html: 'Report Page 1', + shouldPass: false, + }, + { + name: 'should fail for a PDF link with uppercase extension and anchor', + html: 'Report Section', + shouldPass: false, + }, + { + name: 'should fail for a relative PDF link', + html: 'Annual Report', + shouldPass: false, + }, + ]; + + testCases.forEach( ( testCase ) => { + test( testCase.name, async () => { + document.body.innerHTML = testCase.html; + + const results = await axe.run( document.body, { + runOnly: [ 'link_pdf' ], + } ); + + if ( testCase.shouldPass ) { + expect( results.violations.length ).toBe( 0 ); + } else { + expect( results.violations.length ).toBeGreaterThan( 0 ); + expect( results.violations[ 0 ].id ).toBe( 'link_pdf' ); + } + } ); + } ); +} ); diff --git a/tests/jest/rules/textJustified.test.js b/tests/jest/rules/textJustified.test.js new file mode 100644 index 000000000..bf48e781f --- /dev/null +++ b/tests/jest/rules/textJustified.test.js @@ -0,0 +1,88 @@ +import axe from 'axe-core'; + +const LONG_TEXT = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor.'; +const SHORT_TEXT = 'Short text.'; + +beforeAll( async () => { + const textJustifiedRuleModule = await import( '../../../src/pageScanner/rules/text-justified.js' ); + const textIsJustifiedCheckModule = await import( '../../../src/pageScanner/checks/text-is-justified.js' ); + + axe.configure( { + rules: [ textJustifiedRuleModule.default ], + checks: [ textIsJustifiedCheckModule.default ], + } ); +} ); + +beforeEach( () => { + document.body.innerHTML = ''; +} ); + +describe( 'Text Justified Rule', () => { + const testCases = [ + // Passing cases — not justified + { + name: 'should pass for long paragraph with left alignment', + html: `

${ LONG_TEXT }

`, + shouldPass: true, + }, + { + name: 'should pass for long paragraph with no alignment style', + html: `

${ LONG_TEXT }

`, + shouldPass: true, + }, + { + name: 'should pass for long paragraph with center alignment', + html: `

${ LONG_TEXT }

`, + shouldPass: true, + }, + { + name: 'should pass for long paragraph with right alignment', + html: `

${ LONG_TEXT }

`, + shouldPass: true, + }, + { + name: 'should pass for short justified text (under 200 character threshold)', + html: `

${ SHORT_TEXT }

`, + shouldPass: true, + }, + { + name: 'should fail for long justified text in a heading', + html: `

${ LONG_TEXT }

`, + shouldPass: false, + }, + + // Failing cases — long text with justify + { + name: 'should fail for long paragraph with justified text', + html: `

${ LONG_TEXT }

`, + shouldPass: false, + }, + { + name: 'should fail for long span with justified text', + html: `${ LONG_TEXT }`, + shouldPass: false, + }, + { + name: 'should fail for long div with justified text', + html: `
${ LONG_TEXT }
`, + shouldPass: false, + }, + ]; + + testCases.forEach( ( testCase ) => { + test( testCase.name, async () => { + document.body.innerHTML = testCase.html; + + const results = await axe.run( document.body, { + runOnly: [ 'text_justified' ], + } ); + + if ( testCase.shouldPass ) { + expect( results.violations.length ).toBe( 0 ); + } else { + expect( results.violations.length ).toBeGreaterThan( 0 ); + expect( results.violations[ 0 ].id ).toBe( 'text_justified' ); + } + } ); + } ); +} ); From 231b13a63f25e55db47959482aec8be973d27db5 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 20:26:48 +0100 Subject: [PATCH 09/77] Tests: replace stub color contrast tests with real violation coverage axe.configure() cannot replace the built-in color-contrast check's evaluate binding at runtime, and the built-in check requires canvas pixel-sampling which JSDOM doesn't implement. Instead, the test spreads the real rule config (preserving id, tags, selector), replaces the canvas-dependent matches filter with a simple CSS visibility check, and registers a new 'color-contrast-cssonly' check that reads getComputedStyle directly. This gives 13 meaningful tests covering passing cases (high contrast, large text exceptions), failing cases (below 4.5:1 and 3:1 thresholds), and edge cases (hidden/empty elements). Co-Authored-By: Claude Sonnet 4.6 --- tests/jest/rules/colorContrastFailure.test.js | 232 ++++++++++++++---- 1 file changed, 187 insertions(+), 45 deletions(-) diff --git a/tests/jest/rules/colorContrastFailure.test.js b/tests/jest/rules/colorContrastFailure.test.js index 354517cb8..d5ae68288 100644 --- a/tests/jest/rules/colorContrastFailure.test.js +++ b/tests/jest/rules/colorContrastFailure.test.js @@ -1,69 +1,211 @@ /** - * Note: axe-core's color-contrast check uses canvas pixel-sampling to determine - * background colors. JSDOM does not implement HTMLCanvasElement.getContext, so - * axe returns checked elements as "incomplete" rather than "violated". Violation - * detection for this rule requires a real browser environment (e2e or Playwright). + * axe-core's built-in color-contrast check uses canvas pixel-sampling for + * background detection, which JSDOM does not implement. axe.configure() also + * cannot replace the built-in check's evaluate binding at runtime. This test + * therefore: * - * These tests confirm the rule registers correctly and does not produce false - * positives for elements where no contrast check is triggered (elements excluded - * by the color-contrast-matches built-in matcher in JSDOM). + * 1. Spreads the real rule config (preserving its id, tags, selector, etc.) + * 2. Replaces the `matches` filter with a canvas-free visibility check + * 3. Registers a new check id (`color-contrast-cssonly`) that computes contrast + * from getComputedStyle — valid for inline-style test cases in JSDOM + * + * This tests what this project owns: the rule's id, tags, selector, and + * contrast-evaluation logic, without depending on axe-core internals. */ import axe from 'axe-core'; +function parseRgb( cssColor ) { + const match = cssColor.match( + /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/, + ); + if ( ! match ) { + return null; + } + return { + r: parseInt( match[ 1 ] ), + g: parseInt( match[ 2 ] ), + b: parseInt( match[ 3 ] ), + a: match[ 4 ] !== undefined ? parseFloat( match[ 4 ] ) : 1, + }; +} + +function relativeLuminance( r, g, b ) { + return [ r, g, b ] + .map( ( c ) => { + const s = c / 255; + const linear = ( s + 0.055 ) / 1.055; + return s <= 0.03928 ? s / 12.92 : linear ** 2.4; + } ) + .reduce( ( sum, c, i ) => sum + ( [ 0.2126, 0.7152, 0.0722 ][ i ] * c ), 0 ); +} + +function contrastRatio( fg, bg ) { + const fgL = relativeLuminance( fg.r, fg.g, fg.b ); + const bgL = relativeLuminance( bg.r, bg.g, bg.b ); + const [ lighter, darker ] = fgL > bgL ? [ fgL, bgL ] : [ bgL, fgL ]; + return ( lighter + 0.05 ) / ( darker + 0.05 ); +} + beforeAll( async () => { - const colorContrastRuleModule = await import( '../../../src/pageScanner/rules/color-contrast-failure.js' ); + const { default: colorContrastRule } = await import( + '../../../src/pageScanner/rules/color-contrast-failure.js' + ); axe.configure( { - rules: [ colorContrastRuleModule.default ], + rules: [ + { + // Spread the real rule so its id, tags, helpUrl, etc. are preserved. + ...colorContrastRule, + + // Replace the canvas-dependent built-in matcher with a simple + // visibility check so JSDOM can evaluate elements. + matches: ( node ) => { + if ( ! node.textContent.trim().length ) { + return false; + } + const style = window.getComputedStyle( node ); + return ( + style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' + ); + }, + + // Use our CSS-only check instead of the built-in 'color-contrast'. + any: [ 'color-contrast-cssonly' ], + all: [], + none: [], + }, + ], + checks: [ + { + id: 'color-contrast-cssonly', + evaluate( node ) { + const style = window.getComputedStyle( node ); + const fg = parseRgb( style.color ); + const bg = parseRgb( style.backgroundColor ); + + if ( ! fg || ! bg || bg.a < 1 ) { + return undefined; // incomplete — transparent or unparseable + } + + const ratio = contrastRatio( fg, bg ); + const fontSize = parseFloat( style.fontSize ); + const fontWeight = parseInt( style.fontWeight ) || 400; + const isLargeText = + fontSize >= 18 || ( fontSize >= 14 && fontWeight >= 700 ); + const threshold = isLargeText ? 3.0 : 4.5; + + this.data( { + fgColor: style.color, + bgColor: style.backgroundColor, + contrastRatio: ratio.toFixed( 2 ), + threshold, + } ); + + return ratio >= threshold; + }, + }, + ], } ); } ); +afterAll( () => { + axe.reset(); +} ); + beforeEach( () => { document.body.innerHTML = ''; } ); describe( 'Color Contrast Failure', () => { - describe( 'rule registration', () => { - test( 'rule is registered with the correct id', () => { - const rules = axe.getRules( [ 'cat.color' ] ); - const rule = rules.find( ( r ) => r.ruleId === 'color_contrast_failure' ); - expect( rule ).toBeDefined(); - } ); + const testCases = [ + // Passing — contrast ratio meets or exceeds the threshold + { + name: 'should pass for black text on white background (21:1)', + html: '

High contrast text

', + shouldPass: true, + }, + { + name: 'should pass for dark navy text on white background (~11:1)', + html: '

Navy text on white

', + shouldPass: true, + }, + { + name: 'should pass for white text on dark background', + html: '

White on dark gray

', + shouldPass: true, + }, + { + name: 'should pass for dark gray (#595959) on white — just above 4.5:1', + html: 'Dark gray text', + shouldPass: true, + }, + { + name: 'should pass for large text (18px) at a lower ratio (≥3:1)', + html: '

Large text — 3:1 threshold

', + shouldPass: true, + }, + { + name: 'should pass for bold large text (14px bold) at the 3:1 threshold', + html: '

Bold large text

', + shouldPass: true, + }, + { + name: 'should not flag a hidden element (display: none)', + html: '

Hidden

', + shouldPass: true, + }, + { + name: 'should not flag an element with no text content', + html: '

', + shouldPass: true, + }, - test( 'rule targets the correct WCAG criteria', () => { - const rules = axe.getRules(); - const rule = rules.find( ( r ) => r.ruleId === 'color_contrast_failure' ); - expect( rule.tags ).toContain( 'wcag2aa' ); - expect( rule.tags ).toContain( 'wcag143' ); - } ); - } ); - - describe( 'no false positives for non-text elements', () => { - const testCases = [ - { - name: 'should not flag an empty paragraph', - html: '

', - }, - { - name: 'should not flag a hidden element', - html: '

Hidden text

', - }, - { - name: 'should not flag a div with no text content', - html: '
', - }, - ]; + // Failing — contrast ratio below the WCAG 2 AA threshold + { + name: 'should fail for light gray (#aaa) on white — 2.32:1', + html: '

Low contrast gray text

', + shouldPass: false, + }, + { + name: 'should fail for medium gray (#777) on white — 4.47:1 (just below 4.5:1)', + html: '

Medium gray text

', + shouldPass: false, + }, + { + name: 'should fail for yellow on white', + html: '

Yellow on white

', + shouldPass: false, + }, + { + name: 'should fail for light blue on white', + html: 'Light blue on white', + shouldPass: false, + }, + { + // #888 on white is ~3.54:1 — passes large-text (3:1) but fails normal-text (4.5:1). + // Confirms that 14px non-bold is not eligible for the large-text exception. + name: 'should fail for normal 14px text (#888) — 3.54:1 fails the normal-text 4.5:1 threshold', + html: '

Regular 14px text

', + shouldPass: false, + }, + ]; - testCases.forEach( ( testCase ) => { - test( testCase.name, async () => { - document.body.innerHTML = testCase.html; + testCases.forEach( ( testCase ) => { + test( testCase.name, async () => { + document.body.innerHTML = testCase.html; - const results = await axe.run( document.body, { - runOnly: [ 'color_contrast_failure' ], - } ); + const results = await axe.run( document.body, { + runOnly: [ 'color_contrast_failure' ], + } ); + if ( testCase.shouldPass ) { expect( results.violations.length ).toBe( 0 ); - } ); + } else { + expect( results.violations.length ).toBeGreaterThan( 0 ); + expect( results.violations[ 0 ].id ).toBe( 'color_contrast_failure' ); + } } ); } ); } ); From 8acb930b524edad9cc9c1be48f4d55ba562f447c Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 20:28:23 +0100 Subject: [PATCH 10/77] Tests: simplify color contrast tests to rule config assertions only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rule has no custom logic — it delegates entirely to axe-core built-ins (color-contrast-matches matcher + color-contrast check), both of which require canvas pixel-sampling unavailable in JSDOM. The previous approach replaced both built-ins with heavy mocks, effectively testing custom math that isn't in production code. These tests verify what is actually ours: the rule's id, tags, and built-in check delegation. Behavioral coverage requires a real browser. Co-Authored-By: Claude Sonnet 4.6 --- tests/jest/rules/colorContrastFailure.test.js | 228 +++--------------- 1 file changed, 27 insertions(+), 201 deletions(-) diff --git a/tests/jest/rules/colorContrastFailure.test.js b/tests/jest/rules/colorContrastFailure.test.js index d5ae68288..0d6163e77 100644 --- a/tests/jest/rules/colorContrastFailure.test.js +++ b/tests/jest/rules/colorContrastFailure.test.js @@ -1,211 +1,37 @@ /** - * axe-core's built-in color-contrast check uses canvas pixel-sampling for - * background detection, which JSDOM does not implement. axe.configure() also - * cannot replace the built-in check's evaluate binding at runtime. This test - * therefore: - * - * 1. Spreads the real rule config (preserving its id, tags, selector, etc.) - * 2. Replaces the `matches` filter with a canvas-free visibility check - * 3. Registers a new check id (`color-contrast-cssonly`) that computes contrast - * from getComputedStyle — valid for inline-style test cases in JSDOM - * - * This tests what this project owns: the rule's id, tags, selector, and - * contrast-evaluation logic, without depending on axe-core internals. + * color-contrast-failure delegates entirely to axe-core built-ins: + * the `color-contrast-matches` matcher and the `color-contrast` check. + * Both require canvas pixel-sampling that JSDOM cannot provide, so + * behavioral testing (pass/fail on actual contrast values) requires a + * real browser (e.g. Playwright). These tests verify the rule's static + * configuration — id, tags, and which built-ins it uses — so that + * accidental renames or misconfiguration are caught. */ -import axe from 'axe-core'; +import colorContrastRule from '../../../src/pageScanner/rules/color-contrast-failure.js'; -function parseRgb( cssColor ) { - const match = cssColor.match( - /rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([\d.]+))?\s*\)/, - ); - if ( ! match ) { - return null; - } - return { - r: parseInt( match[ 1 ] ), - g: parseInt( match[ 2 ] ), - b: parseInt( match[ 3 ] ), - a: match[ 4 ] !== undefined ? parseFloat( match[ 4 ] ) : 1, - }; -} - -function relativeLuminance( r, g, b ) { - return [ r, g, b ] - .map( ( c ) => { - const s = c / 255; - const linear = ( s + 0.055 ) / 1.055; - return s <= 0.03928 ? s / 12.92 : linear ** 2.4; - } ) - .reduce( ( sum, c, i ) => sum + ( [ 0.2126, 0.7152, 0.0722 ][ i ] * c ), 0 ); -} - -function contrastRatio( fg, bg ) { - const fgL = relativeLuminance( fg.r, fg.g, fg.b ); - const bgL = relativeLuminance( bg.r, bg.g, bg.b ); - const [ lighter, darker ] = fgL > bgL ? [ fgL, bgL ] : [ bgL, fgL ]; - return ( lighter + 0.05 ) / ( darker + 0.05 ); -} - -beforeAll( async () => { - const { default: colorContrastRule } = await import( - '../../../src/pageScanner/rules/color-contrast-failure.js' - ); - - axe.configure( { - rules: [ - { - // Spread the real rule so its id, tags, helpUrl, etc. are preserved. - ...colorContrastRule, - - // Replace the canvas-dependent built-in matcher with a simple - // visibility check so JSDOM can evaluate elements. - matches: ( node ) => { - if ( ! node.textContent.trim().length ) { - return false; - } - const style = window.getComputedStyle( node ); - return ( - style.display !== 'none' && - style.visibility !== 'hidden' && - style.opacity !== '0' - ); - }, - - // Use our CSS-only check instead of the built-in 'color-contrast'. - any: [ 'color-contrast-cssonly' ], - all: [], - none: [], - }, - ], - checks: [ - { - id: 'color-contrast-cssonly', - evaluate( node ) { - const style = window.getComputedStyle( node ); - const fg = parseRgb( style.color ); - const bg = parseRgb( style.backgroundColor ); - - if ( ! fg || ! bg || bg.a < 1 ) { - return undefined; // incomplete — transparent or unparseable - } - - const ratio = contrastRatio( fg, bg ); - const fontSize = parseFloat( style.fontSize ); - const fontWeight = parseInt( style.fontWeight ) || 400; - const isLargeText = - fontSize >= 18 || ( fontSize >= 14 && fontWeight >= 700 ); - const threshold = isLargeText ? 3.0 : 4.5; - - this.data( { - fgColor: style.color, - bgColor: style.backgroundColor, - contrastRatio: ratio.toFixed( 2 ), - threshold, - } ); - - return ratio >= threshold; - }, - }, - ], +describe( 'color-contrast-failure rule config', () => { + test( 'has the correct rule id', () => { + expect( colorContrastRule.id ).toBe( 'color_contrast_failure' ); } ); -} ); - -afterAll( () => { - axe.reset(); -} ); - -beforeEach( () => { - document.body.innerHTML = ''; -} ); -describe( 'Color Contrast Failure', () => { - const testCases = [ - // Passing — contrast ratio meets or exceeds the threshold - { - name: 'should pass for black text on white background (21:1)', - html: '

High contrast text

', - shouldPass: true, - }, - { - name: 'should pass for dark navy text on white background (~11:1)', - html: '

Navy text on white

', - shouldPass: true, - }, - { - name: 'should pass for white text on dark background', - html: '

White on dark gray

', - shouldPass: true, - }, - { - name: 'should pass for dark gray (#595959) on white — just above 4.5:1', - html: 'Dark gray text', - shouldPass: true, - }, - { - name: 'should pass for large text (18px) at a lower ratio (≥3:1)', - html: '

Large text — 3:1 threshold

', - shouldPass: true, - }, - { - name: 'should pass for bold large text (14px bold) at the 3:1 threshold', - html: '

Bold large text

', - shouldPass: true, - }, - { - name: 'should not flag a hidden element (display: none)', - html: '

Hidden

', - shouldPass: true, - }, - { - name: 'should not flag an element with no text content', - html: '

', - shouldPass: true, - }, - - // Failing — contrast ratio below the WCAG 2 AA threshold - { - name: 'should fail for light gray (#aaa) on white — 2.32:1', - html: '

Low contrast gray text

', - shouldPass: false, - }, - { - name: 'should fail for medium gray (#777) on white — 4.47:1 (just below 4.5:1)', - html: '

Medium gray text

', - shouldPass: false, - }, - { - name: 'should fail for yellow on white', - html: '

Yellow on white

', - shouldPass: false, - }, - { - name: 'should fail for light blue on white', - html: 'Light blue on white', - shouldPass: false, - }, - { - // #888 on white is ~3.54:1 — passes large-text (3:1) but fails normal-text (4.5:1). - // Confirms that 14px non-bold is not eligible for the large-text exception. - name: 'should fail for normal 14px text (#888) — 3.54:1 fails the normal-text 4.5:1 threshold', - html: '

Regular 14px text

', - shouldPass: false, - }, - ]; + test( 'uses the built-in color-contrast-matches matcher', () => { + expect( colorContrastRule.matches ).toBe( 'color-contrast-matches' ); + } ); - testCases.forEach( ( testCase ) => { - test( testCase.name, async () => { - document.body.innerHTML = testCase.html; + test( 'delegates to the built-in color-contrast check', () => { + expect( colorContrastRule.any ).toContain( 'color-contrast' ); + expect( colorContrastRule.all ).toHaveLength( 0 ); + expect( colorContrastRule.none ).toHaveLength( 0 ); + } ); - const results = await axe.run( document.body, { - runOnly: [ 'color_contrast_failure' ], - } ); + test( 'targets WCAG 2 AA success criterion 1.4.3', () => { + expect( colorContrastRule.tags ).toContain( 'wcag2aa' ); + expect( colorContrastRule.tags ).toContain( 'wcag143' ); + } ); - if ( testCase.shouldPass ) { - expect( results.violations.length ).toBe( 0 ); - } else { - expect( results.violations.length ).toBeGreaterThan( 0 ); - expect( results.violations[ 0 ].id ).toBe( 'color_contrast_failure' ); - } - } ); + test( 'includes accessibility framework tags', () => { + expect( colorContrastRule.tags ).toContain( 'TTv5' ); + expect( colorContrastRule.tags ).toContain( 'EN-301-549' ); + expect( colorContrastRule.tags ).toContain( 'ACT' ); } ); } ); From a795a56bf50734166f86396a3403d7cb249b9e3a Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 20:33:11 +0100 Subject: [PATCH 11/77] Tests: add missing image input alt edge cases to label tests Adds two cases flagged in code review: image input with no alt attribute and image input with whitespace-only alt. Both should fail the label rule via image_input_has_alt, which requires alt !== null && alt.trim() !== ''. Co-Authored-By: Claude Sonnet 4.6 --- tests/jest/rules/label.test.js | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/jest/rules/label.test.js b/tests/jest/rules/label.test.js index 2b5244905..e79b8058d 100644 --- a/tests/jest/rules/label.test.js +++ b/tests/jest/rules/label.test.js @@ -116,6 +116,16 @@ describe( 'Label Rule (Extended)', () => { html: '', shouldPass: false, }, + { + name: 'should fail for image input with no alt attribute', + html: '', + shouldPass: false, + }, + { + name: 'should fail for image input with whitespace-only alt attribute', + html: '', + shouldPass: false, + }, { name: 'should fail for checkbox with no label', html: '', From 9e7589189c9d6155943e3f70fd03682e068d601a Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 20:36:56 +0100 Subject: [PATCH 12/77] Tests: address code review feedback on four test files - textJustified: move heading test case to the failing block (was misplaced under the passing-cases comment) - colorContrastFailure: use toEqual(['color-contrast']) instead of toContain so the delegation contract is exact, not just partial - linkPdf, linkMsOfficeFile: add documented test cases for the known gap where a file extension followed by & in a query string is not matched by the CSS attribute selectors; explains why adding the selector variant risks false positives Co-Authored-By: Claude Sonnet 4.6 --- tests/jest/rules/colorContrastFailure.test.js | 2 +- tests/jest/rules/linkMsOfficeFile.test.js | 8 ++++++++ tests/jest/rules/linkPdf.test.js | 8 ++++++++ tests/jest/rules/textJustified.test.js | 4 ++-- 4 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/jest/rules/colorContrastFailure.test.js b/tests/jest/rules/colorContrastFailure.test.js index 0d6163e77..ede06e942 100644 --- a/tests/jest/rules/colorContrastFailure.test.js +++ b/tests/jest/rules/colorContrastFailure.test.js @@ -19,7 +19,7 @@ describe( 'color-contrast-failure rule config', () => { } ); test( 'delegates to the built-in color-contrast check', () => { - expect( colorContrastRule.any ).toContain( 'color-contrast' ); + expect( colorContrastRule.any ).toEqual( [ 'color-contrast' ] ); expect( colorContrastRule.all ).toHaveLength( 0 ); expect( colorContrastRule.none ).toHaveLength( 0 ); } ); diff --git a/tests/jest/rules/linkMsOfficeFile.test.js b/tests/jest/rules/linkMsOfficeFile.test.js index 5227fef3b..2f6fa55a0 100644 --- a/tests/jest/rules/linkMsOfficeFile.test.js +++ b/tests/jest/rules/linkMsOfficeFile.test.js @@ -42,6 +42,14 @@ describe( 'Link to MS Office File Rule', () => { html: 'Documentation Guide', shouldPass: true, }, + { + // The selector matches .docx? and .docx# but not .docx& (second query param). + // Adding a[href*=".docx&"] risks false positives (e.g. /?q=docx&sort=name). + // This case is a known gap — tracked for a future rule selector update. + name: 'does not flag an Office file served via a query parameter followed by & (known selector gap)', + html: 'Download Report', + shouldPass: true, + }, // Failing cases — Word documents { diff --git a/tests/jest/rules/linkPdf.test.js b/tests/jest/rules/linkPdf.test.js index e9aa9ee74..bc60d2a64 100644 --- a/tests/jest/rules/linkPdf.test.js +++ b/tests/jest/rules/linkPdf.test.js @@ -47,6 +47,14 @@ describe( 'Link to PDF Rule', () => { html: 'PDF Resources', shouldPass: true, }, + { + // The selector matches .pdf? and .pdf# but not .pdf& (second query param). + // Adding a[href*=".pdf&"] risks false positives (e.g. /?q=compare-pdf&sort=name). + // This case is a known gap — tracked for a future rule selector update. + name: 'does not flag a PDF served via a query parameter followed by & (known selector gap)', + html: 'Download PDF', + shouldPass: true, + }, // Failing cases — PDF links { diff --git a/tests/jest/rules/textJustified.test.js b/tests/jest/rules/textJustified.test.js index bf48e781f..57f6df4c5 100644 --- a/tests/jest/rules/textJustified.test.js +++ b/tests/jest/rules/textJustified.test.js @@ -45,13 +45,13 @@ describe( 'Text Justified Rule', () => { html: `

${ SHORT_TEXT }

`, shouldPass: true, }, + + // Failing cases — long text with justify { name: 'should fail for long justified text in a heading', html: `

${ LONG_TEXT }

`, shouldPass: false, }, - - // Failing cases — long text with justify { name: 'should fail for long paragraph with justified text', html: `

${ LONG_TEXT }

`, From cc86fc0069b82146509964e791fc8b2e08d23fda Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 21:19:47 +0100 Subject: [PATCH 13/77] Tests: add runtime smoke test to colorContrastFailure suite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second describe block that registers the rule with axe and runs it against a visible text element. In JSDOM, axe cannot sample canvas pixels so it marks evaluated elements as incomplete rather than violated — asserting incomplete.length > 0 and that the rule is not in inapplicable proves the rule's selector and matcher are functional, not just that the exported config object has the right shape. Co-Authored-By: Claude Sonnet 4.6 --- tests/jest/rules/colorContrastFailure.test.js | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/tests/jest/rules/colorContrastFailure.test.js b/tests/jest/rules/colorContrastFailure.test.js index ede06e942..e800c54da 100644 --- a/tests/jest/rules/colorContrastFailure.test.js +++ b/tests/jest/rules/colorContrastFailure.test.js @@ -6,7 +6,14 @@ * real browser (e.g. Playwright). These tests verify the rule's static * configuration — id, tags, and which built-ins it uses — so that * accidental renames or misconfiguration are caught. + * + * One runtime smoke test is also included: it confirms the rule's selector + * and matcher actually evaluate text elements (not silently skip them). In + * JSDOM, axe cannot sample canvas pixels so it marks evaluated elements as + * `incomplete` rather than `violated` — that incomplete result proves the + * rule ran rather than being disabled or matching nothing. */ +import axe from 'axe-core'; import colorContrastRule from '../../../src/pageScanner/rules/color-contrast-failure.js'; describe( 'color-contrast-failure rule config', () => { @@ -35,3 +42,50 @@ describe( 'color-contrast-failure rule config', () => { expect( colorContrastRule.tags ).toContain( 'ACT' ); } ); } ); + +describe( 'color-contrast-failure rule execution', () => { + beforeAll( async () => { + // The color-contrast-matches built-in matcher calls _isIconLigature, which + // needs HTMLCanvasElement.getContext. Without it, axe throws and excludes + // every element before the check runs. Provide a minimal mock so the matcher + // completes (equal measureText widths → not an icon ligature → element passes + // through to the contrast check). + HTMLCanvasElement.prototype.getContext = function() { + return { + font: '', + measureText: ( text ) => ( { width: text.length * 8 } ), + fillText: () => {}, + clearRect: () => {}, + fillRect: () => {}, + drawImage: () => {}, + getImageData: () => ( { data: new Uint8ClampedArray( 4 ) } ), + }; + }; + + axe.configure( { rules: [ colorContrastRule ] } ); + } ); + + afterAll( () => { + axe.reset(); + } ); + + beforeEach( () => { + document.body.innerHTML = ''; + } ); + + test( 'rule evaluates text elements (returns incomplete in JSDOM, not inapplicable)', async () => { + // Any visible text element is sufficient — the exact contrast value does not + // matter here. What matters is that axe marks it `incomplete` (evaluated but + // unable to determine a result) rather than `inapplicable` (selector/matcher + // never matched). An `inapplicable` result would mean the rule is broken. + document.body.innerHTML = + '

Sample text

'; + + const results = await axe.run( document.body, { + runOnly: [ 'color_contrast_failure' ], + } ); + + expect( results.inapplicable.some( ( r ) => r.id === 'color_contrast_failure' ) ).toBe( false ); + expect( results.incomplete.length ).toBeGreaterThan( 0 ); + } ); +} ); From 190fc50e236c760ef05620cc07e20e8c67382984 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 21:22:44 +0100 Subject: [PATCH 14/77] Fix: exclude core cover block elements from aria-hidden rule (PRO-966) Cover blocks legitimately place aria-hidden="true" on background image and overlay elements (wp-block-cover__background, wp-block-cover__image-background). These are decorative and should not be flagged. Excludes any element whose class contains "wp-block-cover" at the selector level so the check never runs on them, rather than special-casing them in the check logic. Co-Authored-By: Claude Sonnet 4.6 --- src/pageScanner/rules/aria-hidden-validation.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pageScanner/rules/aria-hidden-validation.js b/src/pageScanner/rules/aria-hidden-validation.js index e50ff218d..0d1ef60fb 100644 --- a/src/pageScanner/rules/aria-hidden-validation.js +++ b/src/pageScanner/rules/aria-hidden-validation.js @@ -1,6 +1,6 @@ export default { id: 'aria_hidden_validation', - selector: '[aria-hidden="true"]', + selector: '[aria-hidden="true"]:not([class*="wp-block-cover"])', excludeHidden: false, tags: [ 'wcag2a', From 69359fa4731661746416e18d530e218ec64a07dc Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 21:23:09 +0100 Subject: [PATCH 15/77] Refactor: move spacer and separator exclusions from check to selector wp-block-spacer and wp-block-separator were excluded via early-return true in the aria_hidden_valid_usage check. Since these are unconditional by class name they belong at the selector level, which is faster (axe never instantiates the check for those elements) and makes the rule's scope self-documenting. Co-Authored-By: Claude Sonnet 4.6 --- src/pageScanner/checks/aria-hidden-valid-usage.js | 10 ---------- src/pageScanner/rules/aria-hidden-validation.js | 2 +- 2 files changed, 1 insertion(+), 11 deletions(-) diff --git a/src/pageScanner/checks/aria-hidden-valid-usage.js b/src/pageScanner/checks/aria-hidden-valid-usage.js index a6f51bbc2..05e186205 100644 --- a/src/pageScanner/checks/aria-hidden-valid-usage.js +++ b/src/pageScanner/checks/aria-hidden-valid-usage.js @@ -21,16 +21,6 @@ export default { return true; } - // Check for valid element properties - if ( node.classList.contains( 'wp-block-spacer' ) ) { - return true; - } - - // Core separator block is decorative and correctly uses aria-hidden - if ( node.classList.contains( 'wp-block-separator' ) ) { - return true; - } - const role = node.getAttribute( 'role' ); if ( role?.split( /\s+/ ).includes( 'presentation' ) ) { return true; diff --git a/src/pageScanner/rules/aria-hidden-validation.js b/src/pageScanner/rules/aria-hidden-validation.js index 0d1ef60fb..9caa375ea 100644 --- a/src/pageScanner/rules/aria-hidden-validation.js +++ b/src/pageScanner/rules/aria-hidden-validation.js @@ -1,6 +1,6 @@ export default { id: 'aria_hidden_validation', - selector: '[aria-hidden="true"]:not([class*="wp-block-cover"])', + selector: '[aria-hidden="true"]:not([class*="wp-block-cover"]):not(.wp-block-spacer):not(.wp-block-separator)', excludeHidden: false, tags: [ 'wcag2a', From 7d62afe28251cc4b862b1278855697515e44c0c6 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 21:41:29 +0100 Subject: [PATCH 16/77] Tests: add cover block before/after cases for PRO-966 aria-hidden fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three passing cases for cover block elements (background overlay, background image, container) that were incorrectly flagged before PRO-966. Also adds a regression describe block that reconfigures axe with the old bare [aria-hidden="true"] selector to confirm the violations would have been raised — proving the :not([class*="wp-block-cover"]) exclusion is what fixes them, not a silent change in axe or the check logic. Co-Authored-By: Claude Sonnet 4.6 --- tests/jest/rules/ariaHiddenValid.test.js | 74 ++++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/jest/rules/ariaHiddenValid.test.js b/tests/jest/rules/ariaHiddenValid.test.js index c5a6f2428..b9cea02be 100644 --- a/tests/jest/rules/ariaHiddenValid.test.js +++ b/tests/jest/rules/ariaHiddenValid.test.js @@ -84,6 +84,25 @@ describe( 'Aria Hidden Validation', () => { html: '', shouldPass: true, }, + // Cover block cases (PRO-966) — these were incorrectly flagged before the + // selector exclusion was added. The core cover block places aria-hidden="true" + // on its background overlay and background image elements, which is correct + // decorative usage and should not be reported as a violation. + { + name: 'should pass for cover block background overlay (wp-block-cover__background)', + html: '', + shouldPass: true, + }, + { + name: 'should pass for cover block background image (wp-block-cover__image-background)', + html: '', + shouldPass: true, + }, + { + name: 'should pass for cover block container element with aria-hidden', + html: '', + shouldPass: true, + }, { name: 'should pass for element with role="presentation"', html: '', @@ -231,3 +250,58 @@ describe( 'Aria Hidden Validation', () => { } ); } ); } ); + +// Regression tests demonstrating the PRO-966 "before" state: cover block elements +// were incorrectly flagged when the rule used a bare [aria-hidden="true"] selector. +// These tests reconfigure axe with the old selector to confirm the violation would +// have been raised, proving the new :not([class*="wp-block-cover"]) exclusion is +// what fixes it — not a silent change in axe or the check. +describe( 'Aria Hidden Validation — PRO-966 regression (cover block before/after)', () => { + beforeAll( async () => { + const ariaHiddenCheckModule = await import( '../../../src/pageScanner/checks/aria-hidden-valid-usage.js' ); + + // Intentionally use the old bare selector — no cover block exclusion. + axe.configure( { + rules: [ { + id: 'aria_hidden_validation', + selector: '[aria-hidden="true"]', + excludeHidden: false, + tags: [ 'wcag2a' ], + all: [], + any: [ 'aria_hidden_valid_usage' ], + none: [], + } ], + checks: [ ariaHiddenCheckModule.default ], + } ); + } ); + + afterAll( () => { + axe.reset(); + } ); + + beforeEach( () => { + document.body.innerHTML = ''; + } ); + + test( 'BEFORE fix: cover block background overlay was incorrectly flagged', async () => { + document.body.innerHTML = + ''; + + const results = await axe.run( document.body, { + runOnly: [ 'aria_hidden_validation' ], + } ); + + expect( results.violations.length ).toBeGreaterThan( 0 ); + } ); + + test( 'BEFORE fix: cover block background image was incorrectly flagged', async () => { + document.body.innerHTML = + ''; + + const results = await axe.run( document.body, { + runOnly: [ 'aria_hidden_validation' ], + } ); + + expect( results.violations.length ).toBeGreaterThan( 0 ); + } ); +} ); From c442c9f99e59c4f3b8a0cbd8b65ae14147c8ec95 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 21:44:25 +0100 Subject: [PATCH 17/77] Tests: remove regression describe block, keep cover block passing cases Co-Authored-By: Claude Sonnet 4.6 --- tests/jest/rules/ariaHiddenValid.test.js | 54 ------------------------ 1 file changed, 54 deletions(-) diff --git a/tests/jest/rules/ariaHiddenValid.test.js b/tests/jest/rules/ariaHiddenValid.test.js index b9cea02be..395ecef99 100644 --- a/tests/jest/rules/ariaHiddenValid.test.js +++ b/tests/jest/rules/ariaHiddenValid.test.js @@ -251,57 +251,3 @@ describe( 'Aria Hidden Validation', () => { } ); } ); -// Regression tests demonstrating the PRO-966 "before" state: cover block elements -// were incorrectly flagged when the rule used a bare [aria-hidden="true"] selector. -// These tests reconfigure axe with the old selector to confirm the violation would -// have been raised, proving the new :not([class*="wp-block-cover"]) exclusion is -// what fixes it — not a silent change in axe or the check. -describe( 'Aria Hidden Validation — PRO-966 regression (cover block before/after)', () => { - beforeAll( async () => { - const ariaHiddenCheckModule = await import( '../../../src/pageScanner/checks/aria-hidden-valid-usage.js' ); - - // Intentionally use the old bare selector — no cover block exclusion. - axe.configure( { - rules: [ { - id: 'aria_hidden_validation', - selector: '[aria-hidden="true"]', - excludeHidden: false, - tags: [ 'wcag2a' ], - all: [], - any: [ 'aria_hidden_valid_usage' ], - none: [], - } ], - checks: [ ariaHiddenCheckModule.default ], - } ); - } ); - - afterAll( () => { - axe.reset(); - } ); - - beforeEach( () => { - document.body.innerHTML = ''; - } ); - - test( 'BEFORE fix: cover block background overlay was incorrectly flagged', async () => { - document.body.innerHTML = - ''; - - const results = await axe.run( document.body, { - runOnly: [ 'aria_hidden_validation' ], - } ); - - expect( results.violations.length ).toBeGreaterThan( 0 ); - } ); - - test( 'BEFORE fix: cover block background image was incorrectly flagged', async () => { - document.body.innerHTML = - ''; - - const results = await axe.run( document.body, { - runOnly: [ 'aria_hidden_validation' ], - } ); - - expect( results.violations.length ).toBeGreaterThan( 0 ); - } ); -} ); From 3f2c47ad7808533636a409bdaf8903df6f4161f6 Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 21:52:39 +0100 Subject: [PATCH 18/77] Fix: narrow cover block selector exclusion to specific decorative classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The broad [class*="wp-block-cover"] substring match excluded ANY element whose class contained that string — including the outer .wp-block-cover container and .wp-block-cover__inner-container, which hold real user content. If aria-hidden="true" were incorrectly placed on either, the checker would silently miss it. WordPress core only places aria-hidden="true" on two specific decorative child elements: .wp-block-cover__background (colour overlay) and .wp-block-cover__image-background (background image). Use those exact classes in the selector instead. Also adds a comment documenting each exclusion, and replaces the false- negative passing test for the outer container with a correct failing test. Co-Authored-By: Claude Sonnet 4.6 --- src/pageScanner/rules/aria-hidden-validation.js | 7 ++++++- tests/jest/rules/ariaHiddenValid.test.js | 12 +++++++----- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/pageScanner/rules/aria-hidden-validation.js b/src/pageScanner/rules/aria-hidden-validation.js index 9caa375ea..61ea8ae45 100644 --- a/src/pageScanner/rules/aria-hidden-validation.js +++ b/src/pageScanner/rules/aria-hidden-validation.js @@ -1,6 +1,11 @@ export default { id: 'aria_hidden_validation', - selector: '[aria-hidden="true"]:not([class*="wp-block-cover"]):not(.wp-block-spacer):not(.wp-block-separator)', + // WordPress core block elements that legitimately use aria-hidden="true" on decorative content: + // wp-block-cover__background — colour overlay span inside a cover block + // wp-block-cover__image-background — background image inside a cover block + // wp-block-spacer — intentionally empty spacing element + // wp-block-separator — decorative HR element + selector: '[aria-hidden="true"]:not(.wp-block-cover__background):not(.wp-block-cover__image-background):not(.wp-block-spacer):not(.wp-block-separator)', excludeHidden: false, tags: [ 'wcag2a', diff --git a/tests/jest/rules/ariaHiddenValid.test.js b/tests/jest/rules/ariaHiddenValid.test.js index 395ecef99..6c4adf458 100644 --- a/tests/jest/rules/ariaHiddenValid.test.js +++ b/tests/jest/rules/ariaHiddenValid.test.js @@ -98,11 +98,6 @@ describe( 'Aria Hidden Validation', () => { html: '', shouldPass: true, }, - { - name: 'should pass for cover block container element with aria-hidden', - html: '', - shouldPass: true, - }, { name: 'should pass for element with role="presentation"', html: '', @@ -205,6 +200,13 @@ describe( 'Aria Hidden Validation', () => { html: '', shouldPass: false, }, + { + // The outer .wp-block-cover container holds real content and is not + // excluded by the selector — only the decorative child elements are. + name: 'should fail for aria-hidden="true" on the outer cover block container (hides real content)', + html: '', + shouldPass: false, + }, { name: 'should fail for aria-hidden on form controls', html: '', From 909f8d6b88d0045df36751a5dab00aaf24613e9d Mon Sep 17 00:00:00 2001 From: William Patton Date: Sat, 27 Jun 2026 21:59:22 +0100 Subject: [PATCH 19/77] Tests: address self-review findings on PR #1800 - colorContrastFailure: assert incomplete result by rule ID (not just length) so the check is robust if runOnly semantics ever change; also clean up the canvas prototype mock in afterAll alongside axe.reset() - label: add missing 'should fail for radio button with no label' case (checkbox had a failing test, radio did not); add afterAll axe.reset() for consistency with other test files - image-input-has-alt: replace the wrong JSDoc (copy-pasted from a different check) with an accurate description of what the function does Co-Authored-By: Claude Sonnet 4.6 --- src/pageScanner/checks/image-input-has-alt.js | 9 +++++++++ tests/jest/rules/colorContrastFailure.test.js | 3 ++- tests/jest/rules/label.test.js | 9 +++++++++ 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/pageScanner/checks/image-input-has-alt.js b/src/pageScanner/checks/image-input-has-alt.js index b720c3c31..8f8384352 100644 --- a/src/pageScanner/checks/image-input-has-alt.js +++ b/src/pageScanner/checks/image-input-has-alt.js @@ -1,3 +1,12 @@ +/** + * Returns true when the node is an with a non-empty, + * non-whitespace alt attribute. Returns false for all other element types so + * non-image-input nodes never receive a spurious passing vote in the label + * rule's any[] group. + * + * @param {Node} node The node to evaluate. + * @return {boolean} True if the node is an image input with a meaningful alt attribute. + */ export default { id: 'image_input_has_alt', evaluate: ( node ) => { diff --git a/tests/jest/rules/colorContrastFailure.test.js b/tests/jest/rules/colorContrastFailure.test.js index e800c54da..42cea4700 100644 --- a/tests/jest/rules/colorContrastFailure.test.js +++ b/tests/jest/rules/colorContrastFailure.test.js @@ -66,6 +66,7 @@ describe( 'color-contrast-failure rule execution', () => { } ); afterAll( () => { + delete HTMLCanvasElement.prototype.getContext; axe.reset(); } ); @@ -86,6 +87,6 @@ describe( 'color-contrast-failure rule execution', () => { } ); expect( results.inapplicable.some( ( r ) => r.id === 'color_contrast_failure' ) ).toBe( false ); - expect( results.incomplete.length ).toBeGreaterThan( 0 ); + expect( results.incomplete.some( ( r ) => r.id === 'color_contrast_failure' ) ).toBe( true ); } ); } ); diff --git a/tests/jest/rules/label.test.js b/tests/jest/rules/label.test.js index e79b8058d..92ea64365 100644 --- a/tests/jest/rules/label.test.js +++ b/tests/jest/rules/label.test.js @@ -10,6 +10,10 @@ beforeAll( async () => { } ); } ); +afterAll( () => { + axe.reset(); +} ); + beforeEach( () => { document.body.innerHTML = ''; } ); @@ -126,6 +130,11 @@ describe( 'Label Rule (Extended)', () => { html: '', shouldPass: false, }, + { + name: 'should fail for radio button with no label', + html: '', + shouldPass: false, + }, { name: 'should fail for checkbox with no label', html: '', From 4976da44ded23cfa2d6b06dfb14f38b57f0f5f3d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:25:30 +0000 Subject: [PATCH 20/77] docs: regenerate hooks docs --- docs/hooks.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/hooks.md b/docs/hooks.md index b2b1d67a7..f59c9ff5e 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -15,6 +15,7 @@ This document is auto-generated by `tools/generate-hooks-docs.php`. It lists onl | `edac_before_validate` | action | [includes/classes/class-rest-api.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/class-rest-api.php#L501) | 501 | Fires before the validation process starts. | 1.5.0 | | `edac_check_license_hook` | action | [includes/classes/MyDot/Connector.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/MyDot/Connector.php#L154) | 154 | Sets up the license page and handlers. | 1.xx.x | | `edac_debug_information` | filter | [admin/site-health/class-information.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/site-health/class-information.php#L79) | 79 | Filter the debug information. | 1.6.10 | +| `edac_dismiss_reasons` | filter | [admin/class-ignore-ui.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-ignore-ui.php#L64) | 64 | Filters the dismiss reasons available in the ignore/dismiss panel. | 1.xx.x | | `edac_filter_admin_scripts_slugs` | filter | [admin/class-enqueue-admin.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-enqueue-admin.php#L58) | 58 | Enqueue the admin and editorApp scripts. | | | `edac_filter_command_classes` | filter | [includes/classes/WPCLI/BootstrapCLI.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/WPCLI/BootstrapCLI.php#L83) | 83 | Filter the list of classes that hold the commands to be registered. | 1.15.0 | | `edac_filter_dashboard_widget_capability` | filter | [admin/class-helpers.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-helpers.php#L229) | 229 | Filter the capability required to view the dashboard widget. | 1.9.3 | @@ -36,14 +37,15 @@ This document is auto-generated by `tools/generate-hooks-docs.php`. It lists onl | `edac_filter_simplified_summary_heading` | filter | [includes/classes/class-simplified-summary.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/class-simplified-summary.php#L75) | 75 | Filter the heading that gets output before the simplified summary inside an

tag. | 1.4.0 | | `edac_fix_underline_target` | filter | [includes/classes/Fixes/Fix/LinkUnderline.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/Fixes/Fix/LinkUnderline.php#L118) | 118 | Filters the target element selector for forcing underlines. | 1.16.0 | | `edac_free_product_id` | filter | [includes/classes/MyDot/Connector.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/MyDot/Connector.php#L144) | 144 | Sets up the license page and handlers. | 1.xx.x | -| `edac_get_origin_url_for_virtual_page` | filter | [admin/class-enqueue-admin.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-enqueue-admin.php#L118) | 118 | Enqueue the admin and editorApp scripts. | | +| `edac_get_origin_url_for_virtual_page` | filter | [admin/class-enqueue-admin.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-enqueue-admin.php#L119) | 119 | Enqueue the admin and editorApp scripts. | | | `edac_ignore_permission` | filter | [admin/class-ajax.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-ajax.php#L326) | 326 | Filters if a user can ignore issues. | 1.4.0 | | `edac_is_sale_time` | filter | [admin/class-upgrade-promotion.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-upgrade-promotion.php#L207) | 207 | Filter whether it's currently sale time for upgrade promotions. | 1.27.0 | | `edac_jwt_audience` | filter | [includes/classes/MyDot/Connector.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/MyDot/Connector.php#L925) | 925 | Get the expected audience for JWT validation (RFC 8725). | 1.xx.x | | `edac_jwt_issuer` | filter | [includes/classes/MyDot/Connector.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/MyDot/Connector.php#L913) | 913 | Get the expected issuer for JWT validation (RFC 8725). | 1.xx.x | +| `edac_landmark_types` | filter | [includes/helper-functions.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/helper-functions.php#L868) | 868 | Filter the landmark types used by the scanner and Issues Explorer filter. | 1.44.0 | | `edac_link_wrapper_mock` | filter | [tests/phpunit/Admin/PluginRowMetaTest.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/tests/phpunit/Admin/PluginRowMetaTest.php#L57) | 57 | Wrapper function that delegates to mock in test mode. | | | `edac_link_wrapper_test_mode` | filter | [tests/phpunit/Admin/PluginRowMetaTest.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/tests/phpunit/Admin/PluginRowMetaTest.php#L56) | 56 | Wrapper function that delegates to mock in test mode. | | -| `edac_max_alt_length` | filter | [admin/class-enqueue-admin.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-enqueue-admin.php#L144) | 144 | Enqueue the admin and editorApp scripts. | | +| `edac_max_alt_length` | filter | [admin/class-enqueue-admin.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/admin/class-enqueue-admin.php#L145) | 145 | Enqueue the admin and editorApp scripts. | | | `edac_mydot_api_endpoint` | filter | [includes/classes/MyDot/Connector.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/MyDot/Connector.php#L528) | 528 | Filters the MyDot API endpoint URL. | 1.xx.x | | `edac_mydot_product_id` | filter | [includes/classes/MyDot/Connector.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/MyDot/Connector.php#L548) | 548 | Filters the MyDot product ID. | 1.xx.x | | `edac_pro_product_id` | filter | [includes/classes/MyDot/Connector.php](https://github.com/equalizedigital/accessibility-checker/blob/develop/includes/classes/MyDot/Connector.php#L1302) | 1302 | Infer license metadata from an EDD response. | 1.xx.x | From d9e4de85d0f16a410643d5a7376435a842d7c795 Mon Sep 17 00:00:00 2001 From: pattonwebz Date: Tue, 30 Jun 2026 23:39:12 +0100 Subject: [PATCH 21/77] fix: strip leading v from release tag before using as SVN version github.event.release.tag_name was passed straight through to the WordPress.org deploy action as VERSION, so a GitHub release tagged v1.45.0 produced an SVN tag literally named v1.45.0. WP.org requires bare version numbers (matching the readme's Stable tag), so the mismatched tag never got picked up as the live release - had to be renamed manually on svn. Co-Authored-By: Claude --- .github/workflows/deploy-on-release-to-dot-org.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/deploy-on-release-to-dot-org.yml b/.github/workflows/deploy-on-release-to-dot-org.yml index 7366cfcfe..434fa9cde 100644 --- a/.github/workflows/deploy-on-release-to-dot-org.yml +++ b/.github/workflows/deploy-on-release-to-dot-org.yml @@ -50,6 +50,12 @@ jobs: npm run dist:dotorg echo "::set-output name=zip-path::./dist/${{ github.event.repository.name }}/${{ github.event.repository.name }}.zip" + - name: Normalize release version for SVN tag + id: normalize-version + run: | + TAG="${{ github.event.release.tag_name }}" + echo "version=${TAG#v}" >> "$GITHUB_OUTPUT" + - name: WordPress plugin deploy id: deploy uses: 10up/action-wordpress-plugin-deploy@stable @@ -57,4 +63,4 @@ jobs: SVN_USERNAME: ${{ secrets.SVN_USERNAME }} SVN_PASSWORD: ${{ secrets.SVN_PASSWORD }} BUILD_DIR: ./dist/${{ github.event.repository.name }}/ - VERSION: ${{ github.event.release.tag_name }} + VERSION: ${{ steps.normalize-version.outputs.version }} From 50b4b933de615bccfe8c399c74169e09eeba6a50 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 30 Jun 2026 22:37:27 -0400 Subject: [PATCH 22/77] fix: show success icon in Accessibility Analysis panel when no issues found Closes #1807 Co-Authored-By: Claude Sonnet 4.6 --- src/sidebar/components/Panels/AccessibilityAnalysis.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/sidebar/components/Panels/AccessibilityAnalysis.js b/src/sidebar/components/Panels/AccessibilityAnalysis.js index 803938daf..155c91d92 100644 --- a/src/sidebar/components/Panels/AccessibilityAnalysis.js +++ b/src/sidebar/components/Panels/AccessibilityAnalysis.js @@ -37,12 +37,14 @@ const AccessibilityAnalysis = () => { // Calculate total issue count (problems + warnings). const totalIssueCount = problemCount + warningCount; - // Determine which icon to show, error if any problems, warning otherwise. + // Determine which icon to show: error > warning > check (success). let iconName = null; if ( problemCount > 0 ) { iconName = 'error'; - } else { + } else if ( warningCount > 0 ) { iconName = 'warning'; + } else { + iconName = 'check'; } const tabs = [ From e8fd5e1db39265b11bd624ab9cd1e53704e717a7 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 30 Jun 2026 22:54:44 -0400 Subject: [PATCH 23/77] refactor: drop redundant null initializer for iconName Co-Authored-By: Claude Sonnet 4.6 --- src/sidebar/components/Panels/AccessibilityAnalysis.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sidebar/components/Panels/AccessibilityAnalysis.js b/src/sidebar/components/Panels/AccessibilityAnalysis.js index 155c91d92..de3610530 100644 --- a/src/sidebar/components/Panels/AccessibilityAnalysis.js +++ b/src/sidebar/components/Panels/AccessibilityAnalysis.js @@ -38,7 +38,7 @@ const AccessibilityAnalysis = () => { const totalIssueCount = problemCount + warningCount; // Determine which icon to show: error > warning > check (success). - let iconName = null; + let iconName; if ( problemCount > 0 ) { iconName = 'error'; } else if ( warningCount > 0 ) { From 8a91ec694de9d10b9fd16fb805b90b63743779e5 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 30 Jun 2026 23:01:22 -0400 Subject: [PATCH 24/77] fix: add missing @wordpress jest module resolutions for IssueImage and DismissPanel tests Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 85 ++++++++++++++++++++++------- package.json | 1 + tests/jest/__mocks__/emptyModule.js | 2 + tests/jest/jest.config.js | 1 + 4 files changed, 69 insertions(+), 20 deletions(-) create mode 100644 tests/jest/__mocks__/emptyModule.js diff --git a/package-lock.json b/package-lock.json index 413d2dd3f..211fca85f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "accessibility-checker", - "version": "1.44.1", + "version": "1.45.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "accessibility-checker", - "version": "1.44.1", + "version": "1.45.0", "hasInstallScript": true, "license": "GPL-2.0+", "devDependencies": { @@ -17,6 +17,7 @@ "@svgr/webpack": "^8.1.0", "@wordpress/components": "^33.0.0", "@wordpress/eslint-plugin": "^17.5.0", + "@wordpress/html-entities": "^4.49.0", "@wordpress/i18n": "^6.10.0", "@wordpress/icons": "^11.5.0", "@wordpress/scripts": "^31.0.0", @@ -212,6 +213,7 @@ "integrity": "sha512-yJ474Zv3cwiSOO9nXJuqzvwEeM+chDuQ8GJirw+pZ91sCGCyOZ3dJkVE09fTV0VEVzXyLWhh3G/AolYTPX7Mow==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.25.7", @@ -2142,6 +2144,7 @@ "integrity": "sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@keyv/serialize": "^1.1.1" } @@ -2255,6 +2258,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" }, @@ -2295,6 +2299,7 @@ } ], "license": "MIT", + "peer": true, "engines": { "node": ">=18" } @@ -2620,6 +2625,7 @@ "integrity": "sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/runtime": "^7.18.3", "@emotion/babel-plugin": "^11.13.5", @@ -3803,6 +3809,7 @@ "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=8.0.0" } @@ -3826,6 +3833,7 @@ "integrity": "sha512-s5vvxXPVdjqS3kTLKMeBMvop9hbWkwzBpu+mUO2M7sZtlkyDJGwFe33wRKnbaYDo8ExRVBIIdwIGrqpxHuKttA==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" }, @@ -3839,6 +3847,7 @@ "integrity": "sha512-OOCM2C/QIURhJMuKaekP3TRBxBKxG/TWWA0TL2J6nXUtDnuCtccy49LUJF8xPFXMX+0LMcxFpCo8M9cGY1W6rQ==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "1.28.0" }, @@ -4321,6 +4330,7 @@ "integrity": "sha512-5UxZqiAgLYGFjS4s9qm5mBVo433u+dSPUFWVWXmLAD4wB65oMCoXaJP1KJa9DIYYMeHu3z4BZcStG3LC593cWA==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/semantic-conventions": "1.28.0" @@ -4348,6 +4358,7 @@ "integrity": "sha512-jVPgBbH1gCy2Lb7X0AVQ8XAfgg0pJ4nvl8/IiQA6nxOsPvS+0zMJaFSs2ltXe0J6C8dqjcnpyqINDJmU30+uOg==", "dev": true, "license": "Apache-2.0", + "peer": true, "dependencies": { "@opentelemetry/core": "1.30.1", "@opentelemetry/resources": "1.30.1", @@ -4376,6 +4387,7 @@ "integrity": "sha512-R5R9tb2AXs2IRLNKLBJDynhkfmx7mX0vi8NkhZb3gUkPWHn6HXk5J8iQ/dql0U3ApfWym4kXXmBDRGO+oeOfjg==", "dev": true, "license": "Apache-2.0", + "peer": true, "engines": { "node": ">=14" } @@ -5251,6 +5263,7 @@ "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", "dev": true, + "peer": true, "dependencies": { "@babel/core": "^7.21.3", "@svgr/babel-preset": "8.1.0", @@ -5517,6 +5530,7 @@ "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-8.44.7.tgz", "integrity": "sha512-f5ORu2hcBbKei97U73mf+l9t4zTGl74IqZ0GQk4oVea/VS8tQZYkUveSYojk+frraAVYId0V2WC9O4PTNru2FQ==", "dev": true, + "peer": true, "dependencies": { "@types/estree": "*", "@types/json-schema": "*" @@ -5700,6 +5714,7 @@ "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -5773,6 +5788,7 @@ "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" @@ -5865,8 +5881,7 @@ "resolved": "https://registry.npmjs.org/@types/source-list-map/-/source-list-map-0.1.6.tgz", "integrity": "sha512-5JcVt1u5HDmlXkwOD2nslZVllBBc7HDuOICfiZah2Z0is8M8g+ddAEawbmd3VjedfDHBzxCaXLs07QEmb7y54g==", "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/stack-utils": { "version": "2.0.3", @@ -5879,8 +5894,7 @@ "resolved": "https://registry.npmjs.org/@types/tapable/-/tapable-1.0.12.tgz", "integrity": "sha512-bTHG8fcxEqv1M9+TD14P8ok8hjxoOCkfKc8XXLaaD05kI7ohpeI956jtDOD3XHKBQrlyPughUtzm1jtVhHpA5Q==", "dev": true, - "optional": true, - "peer": true + "optional": true }, "node_modules/@types/tedious": { "version": "4.0.14", @@ -5904,7 +5918,6 @@ "integrity": "sha512-Hm/T0kV3ywpJyMGNbsItdivRhYNCQQf1IIsYsXnoVPES4t+FMLyDe0/K+Ea7ahWtMtSNb22ZdY7MIyoD9rqARg==", "dev": true, "optional": true, - "peer": true, "dependencies": { "source-map": "^0.6.1" } @@ -5915,7 +5928,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5926,7 +5938,6 @@ "integrity": "sha512-oOW7E931XJU1mVfCnxCVgv8GLFL768pDO5u2Gzk82i8yTIgX6i7cntyZOkZYb/JtYM8252SN9bQp9tgkVDSsRw==", "dev": true, "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "@types/tapable": "^1", @@ -5942,7 +5953,6 @@ "integrity": "sha512-4nZOdMwSPHZ4pTEZzSp0AsTM4K7Qmu40UKW4tJDiOVs20UzYF9l+qUe4s0ftfN0pin06n+5cWWDJXH+sbhAiDw==", "dev": true, "optional": true, - "peer": true, "dependencies": { "@types/node": "*", "@types/source-list-map": "*", @@ -5955,7 +5965,6 @@ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "dev": true, "optional": true, - "peer": true, "engines": { "node": ">=0.10.0" } @@ -6068,6 +6077,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-6.13.1.tgz", "integrity": "sha512-fs2XOhWCzRhqMmQf0eicLa/CWSaYss2feXsy7xBD/pLyWke/jCIVc2s1ikEAtSW7ina1HNhv7kONoEfVNEcdDQ==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "6.13.1", "@typescript-eslint/types": "6.13.1", @@ -6952,6 +6962,7 @@ "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -6993,6 +7004,7 @@ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7294,9 +7306,9 @@ } }, "node_modules/@wordpress/html-entities": { - "version": "4.47.0", - "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.47.0.tgz", - "integrity": "sha512-D3sVJF1uTkjTUJvaAVJsoV8dCkP8q8L29NpavP4qClVQAhlYEynhmMUpesoL163f65Fi7XHwfIVejGjGDrMoXA==", + "version": "4.49.0", + "resolved": "https://registry.npmjs.org/@wordpress/html-entities/-/html-entities-4.49.0.tgz", + "integrity": "sha512-/ZYK6CPks3VAGFQZ+h56jOy4LB4CC1ZB1SMVY3eeUPStyH84YSz7nTa2P7gw/4uGJhaITMQCmFl7yGFPQOROtg==", "dev": true, "license": "GPL-2.0-or-later", "engines": { @@ -7474,6 +7486,7 @@ "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7515,6 +7528,7 @@ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -7637,6 +7651,7 @@ "integrity": "sha512-eRwcGNHve+E8qtEQSSRl6urh+rFop4v8gm6O8rGv25CodbvFdLjA1vVQ1KkiFE0w0UPOnb8tDiFKL5lp0rtY5Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "csstype": "^3.2.2" } @@ -7736,6 +7751,7 @@ "integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=0.10.0" } @@ -8127,6 +8143,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -8652,6 +8669,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -8750,6 +8768,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -9378,6 +9397,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -9927,6 +9947,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -10868,6 +10889,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -11219,6 +11241,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -12357,7 +12380,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1507524.tgz", "integrity": "sha512-OjaNE7qpk6GRTXtqQjAE5bGx6+c4F1zZH0YXtpZQLM92HNXx4zMAaqlKhP4T52DosG6hDW8gPMNhGOF8xbwk/w==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/diff-sequences": { "version": "29.6.3", @@ -12953,6 +12977,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -16536,6 +16561,7 @@ "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, + "peer": true, "dependencies": { "@jest/core": "^29.7.0", "@jest/types": "^29.6.3", @@ -17228,6 +17254,7 @@ "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", "dev": true, + "peer": true, "dependencies": { "abab": "^2.0.6", "acorn": "^8.8.1", @@ -17649,7 +17676,8 @@ "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1551306.tgz", "integrity": "sha512-CFx8QdSim8iIv+2ZcEOclBKTQY6BI1IEDa7Tm9YkwAXzEWFndTEzpTo5jAUhSnq24IC7xaDw0wvGcm96+Y3PEg==", "dev": true, - "license": "BSD-3-Clause" + "license": "BSD-3-Clause", + "peer": true }, "node_modules/lighthouse/node_modules/puppeteer-core/node_modules/ws": { "version": "8.19.0", @@ -18882,6 +18910,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -19274,6 +19303,7 @@ "integrity": "sha512-cuXAJJB1Rdqz0UO6w524matlBqDBjcNt7Ru+RDIu4y6RI1gVqiWBnylrK8sPRk81gGBA0X8hJbDXolVOoTc+sA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ajv": "^6.12.6", "ajv-errors": "^1.0.1", @@ -20288,7 +20318,6 @@ "integrity": "sha512-2SVA0sbPktiIY/MCOPX8e86ehA/e+tDNq+e5Y8qjKYti2Z/JG7xnronT/TXTIkKbYGWlCbuucZ6dziEgkoEjQQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "playwright-core": "1.58.0" }, @@ -20308,7 +20337,6 @@ "integrity": "sha512-aaoB1RWrdNi3//rOeKuMiS65UCcgOVljU46At6eFcOFPFHWtd2weHRRow6z/n+Lec0Lvu0k9ZPKJSjPugikirw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "playwright-core": "cli.js" }, @@ -20327,7 +20355,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -20378,6 +20405,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -21096,6 +21124,7 @@ "resolved": "https://registry.npmjs.org/wp-prettier/-/wp-prettier-3.0.3.tgz", "integrity": "sha512-X4UlrxDTH8oom9qXlcjnydsjAOD2BmB6yFmvS4Z2zdTzqqpRWb+fbqrH412+l+OUXmbzJlSXjlMFYPgYG12IAA==", "dev": true, + "peer": true, "bin": { "prettier": "bin/prettier.cjs" }, @@ -21511,6 +21540,7 @@ "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -21569,6 +21599,7 @@ "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -21589,6 +21620,7 @@ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.0.tgz", "integrity": "sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ==", "dev": true, + "peer": true, "engines": { "node": ">=0.10.0" } @@ -21823,7 +21855,8 @@ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -23560,6 +23593,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-syntax-patches-for-csstree": "^1.0.19", @@ -23951,6 +23985,7 @@ "integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" @@ -24305,6 +24340,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -24525,6 +24561,7 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=12" }, @@ -24813,6 +24850,7 @@ "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.21.3.tgz", "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==", "dev": true, + "peer": true, "engines": { "node": ">=10" }, @@ -25050,6 +25088,7 @@ "dev": true, "hasInstallScript": true, "license": "MIT", + "peer": true, "dependencies": { "napi-postinstall": "^0.3.0" }, @@ -25391,6 +25430,7 @@ "integrity": "sha512-Qphch25abbMNtekmEGJmeRUhLDbe+QfiWTiqpKYkpCOWY64v9eyl+KRRLmqOFA2AvKPpc9DC6+u2n76tQLBoaA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/eslint-scope": "^3.7.7", "@types/estree": "^1.0.8", @@ -25517,6 +25557,7 @@ "resolved": "https://registry.npmjs.org/webpack-cli/-/webpack-cli-5.1.4.tgz", "integrity": "sha512-pIDJHIEI9LR0yxHXQ+Qh95k2EvXpWzZ5l+d+jIo+RdSm9MiHfzazIxwwni/p7+x4eJZuvG1AJwgC4TNQ7NRgsg==", "dev": true, + "peer": true, "dependencies": { "@discoveryjs/json-ext": "^0.5.0", "@webpack-cli/configtest": "^2.1.1", @@ -25594,6 +25635,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -25647,6 +25689,7 @@ "resolved": "https://registry.npmjs.org/webpack-dev-server/-/webpack-dev-server-4.15.1.tgz", "integrity": "sha512-5hbAst3h3C3L8w6W4P96L5vaV0PxSmJhxZvWKYIdgxOQm8pNZ5dEOmmSLBVpP85ReeyRt6AS1QJNyo/oFFPeVA==", "dev": true, + "peer": true, "dependencies": { "@types/bonjour": "^3.5.9", "@types/connect-history-api-fallback": "^1.3.5", @@ -25706,6 +25749,7 @@ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", "dev": true, + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.1", "json-schema-traverse": "^1.0.0", @@ -25819,6 +25863,7 @@ "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", diff --git a/package.json b/package.json index c66a1db4f..2ee8006a7 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "@svgr/webpack": "^8.1.0", "@wordpress/components": "^33.0.0", "@wordpress/eslint-plugin": "^17.5.0", + "@wordpress/html-entities": "^4.49.0", "@wordpress/i18n": "^6.10.0", "@wordpress/icons": "^11.5.0", "@wordpress/scripts": "^31.0.0", diff --git a/tests/jest/__mocks__/emptyModule.js b/tests/jest/__mocks__/emptyModule.js new file mode 100644 index 000000000..df4d384e4 --- /dev/null +++ b/tests/jest/__mocks__/emptyModule.js @@ -0,0 +1,2 @@ +/* global module */ +module.exports = {}; diff --git a/tests/jest/jest.config.js b/tests/jest/jest.config.js index e533fded8..1807d743a 100644 --- a/tests/jest/jest.config.js +++ b/tests/jest/jest.config.js @@ -9,6 +9,7 @@ module.exports = { ], moduleNameMapper: { '\\.(css|scss)$': '/styleMock.js', + '@wordpress/components': '/__mocks__/emptyModule.js', }, setupFilesAfterEnv: [ '/setupTests.js', From a4059479fd658e3ab1a9b714a1a49379dccf9488 Mon Sep 17 00:00:00 2001 From: Steve Jones Date: Tue, 30 Jun 2026 23:15:12 -0400 Subject: [PATCH 25/77] fix: anchor @wordpress/components moduleNameMapper regex to prevent subpath matches Co-Authored-By: Claude Sonnet 4.6 --- tests/jest/jest.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/jest/jest.config.js b/tests/jest/jest.config.js index 1807d743a..7cf97bade 100644 --- a/tests/jest/jest.config.js +++ b/tests/jest/jest.config.js @@ -9,7 +9,7 @@ module.exports = { ], moduleNameMapper: { '\\.(css|scss)$': '/styleMock.js', - '@wordpress/components': '/__mocks__/emptyModule.js', + '^@wordpress/components$': '/__mocks__/emptyModule.js', }, setupFilesAfterEnv: [ '/setupTests.js', From 38c3c234869e64cd1e62ef8fe29d5a216051d5d4 Mon Sep 17 00:00:00 2001 From: pattonwebz Date: Thu, 2 Jul 2026 14:33:40 +0100 Subject: [PATCH 26/77] fix: don't flag .ogg audio as video content in video_present rule The Gutenberg Audio block's Ogg Vorbis sample file was being detected as video because .ogg is also a valid Ogg Theora video extension. Only treat a .ogg match as video when it isn't attached to an