diff --git a/admin/class-insert-rule-data.php b/admin/class-insert-rule-data.php index 51901827a..bccc97703 100644 --- a/admin/class-insert-rule-data.php +++ b/admin/class-insert-rule-data.php @@ -21,17 +21,18 @@ class Insert_Rule_Data { * * @since 1.10.0 * - * @param object $post The post object. Must have a valid ID. - * @param string $rule The rule. - * @param string $ruletype The rule type. - * @param string $rule_obj The object. - * @param string|null $landmark The landmark type (main, header, footer, nav), optional. + * @param object $post The post object. Must have a valid ID. + * @param string $rule The rule. + * @param string $ruletype The rule type. + * @param string $rule_obj The object. + * @param string|null $landmark The landmark type (main, header, footer, nav), optional. * @param string|null $landmark_selector The landmark selector, optional. + * @param array $selectors An array of selectors that point to the object, optional. * * @return void|int|\WP_Error The ID of the inserted record, void if no * record was inserted or a WP_Error if the insert failed. */ - public function insert( object $post, string $rule, string $ruletype, string $rule_obj, ?string $landmark = null, ?string $landmark_selector = null ) { + public function insert( object $post, string $rule, string $ruletype, string $rule_obj, ?string $landmark = null, ?string $landmark_selector = null, array $selectors = [] ) { if ( ! isset( $post->ID, $post->post_type ) || empty( $rule ) @@ -51,6 +52,9 @@ public function insert( object $post, string $rule, string $ruletype, string $ru 'type' => $post->post_type, 'landmark' => $landmark, 'landmark_selector' => $landmark_selector, + 'selector' => $selectors['selector'][0] ?? null, + 'ancestry' => $selectors['ancestry'][0] ?? null, + 'xpath' => $selectors['xpath'][0] ?? null, 'rule' => $rule, 'ruletype' => $ruletype, 'object' => esc_attr( $rule_obj ), @@ -96,9 +100,14 @@ public function insert( object $post, string $rule, string $ruletype, string $ru // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for adding data to database, caching not required for one time operation. $wpdb->query( $wpdb->prepare( - 'UPDATE %i SET recordcheck = %d, ignre = %d WHERE siteid = %d and postid = %d and rule = %s and object = %s and type = %s', + 'UPDATE %i SET recordcheck = %d, landmark = %s, landmark_selector = %s, selector = %s, ancestry = %s, xpath = %s, ignre = %d WHERE siteid = %d and postid = %d and rule = %s and object = %s and type = %s', $table_name, 1, + $rule_data['landmark'], + $rule_data['landmark_selector'], + $rule_data['selector'], + $rule_data['ancestry'], + $rule_data['xpath'], $rule_data['ignre'], $rule_data['siteid'], $rule_data['postid'], @@ -134,6 +143,9 @@ public function insert( object $post, string $rule, string $ruletype, string $ru 'type' => sanitize_text_field( $rule_data['type'] ), 'landmark' => isset( $rule_data['landmark'] ) ? sanitize_text_field( $rule_data['landmark'] ) : null, 'landmark_selector' => isset( $rule_data['landmark_selector'] ) ? sanitize_text_field( $rule_data['landmark_selector'] ) : null, + 'selector' => sanitize_text_field( $rule_data['selector'] ?? '' ), + 'ancestry' => sanitize_text_field( $rule_data['ancestry'] ?? '' ), + 'xpath' => sanitize_text_field( $rule_data['xpath'] ?? '' ), 'rule' => sanitize_text_field( $rule_data['rule'] ), 'ruletype' => sanitize_text_field( $rule_data['ruletype'] ), 'object' => esc_attr( $rule_data['object'] ), diff --git a/admin/class-update-database.php b/admin/class-update-database.php index 10bd6f30d..3149b3bf0 100644 --- a/admin/class-update-database.php +++ b/admin/class-update-database.php @@ -50,6 +50,9 @@ public function edac_update_database() { type text NOT NULL, landmark varchar(20) NULL, landmark_selector text NULL, + selector text NULL, + ancestry text NULL, + xpath text NULL, rule text NOT NULL, ruletype text NOT NULL, object mediumtext NOT NULL, diff --git a/includes/classes/class-enqueue-frontend.php b/includes/classes/class-enqueue-frontend.php index abdd6c525..60931d338 100644 --- a/includes/classes/class-enqueue-frontend.php +++ b/includes/classes/class-enqueue-frontend.php @@ -97,16 +97,17 @@ public static function maybe_enqueue_frontend_highlighter() { 'edac-frontend-highlighter-app', 'edacFrontendHighlighterApp', [ - 'postID' => $post_id, - 'nonce' => wp_create_nonce( 'ajax-nonce' ), - 'restNonce' => wp_create_nonce( 'wp_rest' ), - 'userCanFix' => current_user_can( apply_filters( 'edac_filter_settings_capability', 'manage_options' ) ), - 'edacUrl' => esc_url_raw( get_site_url() ), - 'ajaxurl' => admin_url( 'admin-ajax.php' ), - 'loggedIn' => is_user_logged_in(), - 'appCssUrl' => EDAC_PLUGIN_URL . 'build/css/frontendHighlighterApp.css?ver=' . EDAC_VERSION, - 'widgetPosition' => get_option( 'edac_frontend_highlighter_position', 'right' ), - 'editorLink' => get_edit_post_link( $post_id ), + 'postID' => $post_id, + 'nonce' => wp_create_nonce( 'ajax-nonce' ), + 'restNonce' => wp_create_nonce( 'wp_rest' ), + 'userCanFix' => current_user_can( apply_filters( 'edac_filter_settings_capability', 'manage_options' ) ), + 'edacUrl' => esc_url_raw( get_site_url() ), + 'ajaxurl' => admin_url( 'admin-ajax.php' ), + 'loggedIn' => is_user_logged_in(), + 'appCssUrl' => EDAC_PLUGIN_URL . 'build/css/frontendHighlighterApp.css?ver=' . EDAC_VERSION, + 'widgetPosition' => get_option( 'edac_frontend_highlighter_position', 'right' ), + 'editorLink' => get_edit_post_link( $post_id ), + 'scannerBundleUrl' => plugin_dir_url( EDAC_PLUGIN_FILE ) . 'build/pageScanner.bundle.js', ] ); diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index ea6fa2ce0..cbc2394ff 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -383,7 +383,12 @@ public function set_post_scan_results( $request ) { $landmark = $violation['landmark'] ?? null; $landmark_selector = $violation['landmarkSelector'] ?? null; - ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html, $landmark, $landmark_selector ); + $selectors = [ + 'selector' => $violation['selector'] ?? [], + 'ancestry' => $violation['ancestry'] ?? [], + 'xpath' => $violation['xpath'] ?? [], + ]; + ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html, $landmark, $landmark_selector, $selectors ); /** * Fires after a rule is run against the content. diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index 50cabdc07..d9f68eee2 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -19,6 +19,7 @@ class AccessibilityCheckerHighlight { }; this.settings = { ...defaultSettings, ...settings }; + this._scanAttempted = false; this.highlightPanel = this.addHighlightPanel(); this.nextButton = document.querySelector( '#edac-highlight-next' ); @@ -188,15 +189,21 @@ class AccessibilityCheckerHighlight { }, ); } + } else if ( ! self._scanAttempted && response.data?.[ 0 ]?.code === -3 ) { + // Only try kickoffScan once per highlightAjax call + self._scanAttempted = true; + self.kickoffScan(); + // After kickoffScan, try highlightAjax again, but only once + setTimeout( () => { + self.highlightAjax().then( resolve ).catch( reject ); + }, 5000 ); // Wait 5s for scan to complete. } else { - resolve( [] ); - //console.log(response); + // Default: resolve with empty issues/fixes + resolve( { issues: [], fixes: [] } ); } } else { self.showWait( false ); - //console.log( 'Request failed. Returned status of ' + xhr.status ); - reject( { status: xhr.status, statusText: xhr.statusText, @@ -541,7 +548,11 @@ class AccessibilityCheckerHighlight { } } ).catch( ( err ) => { - //TODO: + // Output a message that says that there are no issues or that the issues could not be loaded. + const summary = document.querySelector( '.edac-highlight-panel-controls-summary' ); + if ( summary ) { + summary.textContent = __( 'An error occurred when loading the issues.', 'accessibility-checker' ); + } } ); } @@ -1088,6 +1099,115 @@ class AccessibilityCheckerHighlight { element.removeAttribute( 'data-edac-landmark-label-id' ); } ); } + + /** + * Kick off the accessibility scan. + */ + kickoffScan() { + const getPageDensity = () => { + const elementCount = document.body.getElementsByTagName( '*' ).length; + const contentLength = document.body.innerText.length; + return { elementCount, contentLength }; + }; + const densityMetrics = getPageDensity(); + const self = this; + const scriptId = 'edac-accessibility-checker-scanner-script'; + if ( ! document.getElementById( scriptId ) ) { + const script = document.createElement( 'script' ); + script.src = window.edacFrontendHighlighterApp?.scannerBundleUrl || '/wp-content/plugins/accessibility-checker/build/pageScanner.bundle.js'; + script.id = scriptId; + script.onload = function() { + setTimeout( () => { + self._runScanOrShowError( densityMetrics ); + }, 100 ); + }; + script.onerror = function() { + self.showWait( false ); + self.showScanError( 'Failed to load scanner script.' ); + }; + document.head.appendChild( script ); + } else { + self._runScanOrShowError( densityMetrics ); + } + } + + _runScanOrShowError( densityMetrics ) { + if ( window.runAccessibilityScan ) { + this.runAccessibilityScanAndSave( densityMetrics ); + } else { + this.showWait( false ); + this.showScanError( __( 'Scanner function not found.', 'accessibility-checker' ) ); + } + } + + runAccessibilityScanAndSave( densityMetrics ) { + const self = this; + const summary = document.querySelector( '.edac-highlight-panel-controls-summary' ); + if ( summary ) { + summary.textContent = __( 'Scanning...', 'accessibility-checker' ); + summary.classList.remove( 'edac-error' ); + } + window.runAccessibilityScan().then( ( result ) => { + const postId = window.edacFrontendHighlighterApp && window.edacFrontendHighlighterApp.postID; + const nonce = window.edacFrontendHighlighterApp && window.edacFrontendHighlighterApp.restNonce; + if ( ! postId || ! nonce ) { + self.showWait( false ); + self.showScanError( __( 'Missing postId or nonce.', 'accessibility-checker' ) ); + return; + } + if ( ! result || ! result.violations || result.violations.length === 0 ) { + self.showWait( false ); + self.showScanError( __( 'No violations found, skipping save.', 'accessibility-checker' ) ); + return; + } + self.saveScanResults( postId, nonce, result.violations, densityMetrics ); + } ).catch( () => { + self.showWait( false ); + self.showScanError( __( 'Accessibility scan error.', 'accessibility-checker' ) ); + } ); + } + + saveScanResults( postId, nonce, violations, densityMetrics ) { + const self = this; + fetch( '/wp-json/accessibility-checker/v1/post-scan-results/' + postId, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce, + }, + body: JSON.stringify( { + violations, + isSkipped: false, + isFailure: false, + densityMetrics, + } ), + } ) + .then( ( response ) => response.json() ) + .then( ( data ) => { + self.showWait( false ); + if ( data && data.success ) { + // Optionally show a success message or update UI + } else { + self.showScanError( __( 'Saving failed.', 'accessibility-checker' ) ); + } + } ) + .catch( () => { + self.showWait( false ); + self.showScanError( __( 'Error saving scan results.', 'accessibility-checker' ) ); + } ); + } + + /** + * Show an error message in the scan panel or as an alert fallback. + * @param {string} message + */ + showScanError( message ) { + const summary = document.querySelector( '.edac-highlight-panel-controls-summary' ); + if ( summary ) { + summary.textContent = message; + summary.classList.add( 'edac-error' ); + } + } } // Some systems (Cloudflare Rocket Loader) defers scripts for performance but that can diff --git a/src/pageScanner/index.js b/src/pageScanner/index.js index 5d306305b..fb52f0a30 100644 --- a/src/pageScanner/index.js +++ b/src/pageScanner/index.js @@ -10,6 +10,9 @@ import { getPageDensity } from './helpers/density'; const SCAN_TIMEOUT_IN_SECONDS = 30; +// Hold the timeout for the scan so it can bail on long-running scans. +let tooLongTimeout; + // Landmark tags for semantic regions const LANDMARK_TAGS = [ 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE' ]; const LANDMARK_ROLES = [ @@ -168,6 +171,30 @@ const iframeId = body.getAttribute( 'data-iframe-id' ); const eventName = body.getAttribute( 'data-iframe-event-name' ); const postId = body.getAttribute( 'data-iframe-post-id' ); +/** + * Check if the current context the script is loaded in is a scanner iframe. + * + * @return {boolean} True if in iframe context, false otherwise. + */ +function isIframeContext() { + return !! ( body && body.hasAttribute( 'data-iframe-id' ) && body.hasAttribute( 'data-iframe-event-name' ) ); +} + +/** + * Get the iframe options from the body attributes/ + * + * @return {Object} {{configOptions: {}, runOptions: {}, iframeId: string | Attribute, eventName: string | Attribute, postId: string | Attribute}} + */ +function getIframeOptions() { + return { + configOptions: {}, + runOptions: {}, + iframeId: body.getAttribute( 'data-iframe-id' ), + eventName: body.getAttribute( 'data-iframe-event-name' ), + postId: body.getAttribute( 'data-iframe-post-id' ), + }; +} + const scan = async ( options = { configOptions: {}, runOptions: {} } ) => { @@ -210,36 +237,14 @@ const scan = async ( //Build an array of the dom selectors and ruleIDs for violations/failed tests item.violations.forEach( ( violation ) => { if ( violation.result === 'failed' ) { - const selector = violation.node.selector; - const html = document.querySelector( selector )?.outerHTML; - const landmark = getLandmarkForSelector( selector ); - violations.push( { - selector, - html, - ruleId: item.id, - impact: item.impact, - tags: item.tags, - landmark: landmark.type, - landmarkSelector: landmark.selector, - } ); + violations.push( processViolation( violation, item ) ); } } ); // Handle incomplete results for form-field-multiple-labels only. if ( item.id === 'form-field-multiple-labels' ) { // Allow incomplete results for this rule. item.incomplete.forEach( ( incompleteItem ) => { - const selector = incompleteItem.node.selector; - const html = document.querySelector( selector )?.outerHTML; - const landmark = getLandmarkForSelector( selector ); - violations.push( { - selector, - html, - ruleId: item.id, - impact: item.impact, - tags: item.tags, - landmark: landmark.type, - landmarkSelector: landmark.selector, - } ); + violations.push( processViolation( incompleteItem, item ) ); } ); } } ); @@ -305,6 +310,7 @@ function dispatchDoneEvent( violations, errorMsgs, error ) { top.dispatchEvent( customEvent ); } +// eslint-disable-next-line no-unused-vars const onDone = ( violations = [], errorMsgs = [], error = false ) => { // cleanup the timeout. clearTimeout( tooLongTimeout ); @@ -315,38 +321,75 @@ const onDone = ( violations = [], errorMsgs = [], error = false ) => { function() { axe.teardown(); axe = null; - - dispatchDoneEvent( violations, errorMsgs, error ); + dispatchDoneEvent( violations, errorMsgs, '' ); }, function() { axe.teardown(); axe = null; - - // Create a custom event errorMsgs.push( '***** axe.cleanup() failed.' ); - - dispatchDoneEvent( violations, errorMsgs, error ); + dispatchDoneEvent( violations, errorMsgs, 'cleanup-failed' ); } ); } else { - error = true; - errorMsgs.push( '***** axe.cleanup() does not exist.' ); axe = null; - - dispatchDoneEvent( violations, errorMsgs, error ); + dispatchDoneEvent( violations, errorMsgs, 'cleanup-not-exists' ); } }; -// Fire a failed event if the scan doesn't complete on time. -const tooLongTimeout = setTimeout( function() { - onDone( [], [ '***** axe scan took too long.' ], true ); -}, SCAN_TIMEOUT_IN_SECONDS * 1000 ); - -// Start the scan. -scan().then( ( results ) => { - const violations = JSON.parse( JSON.stringify( results.violations ) ); - onDone( violations ); -} ).catch( ( err ) => { - onDone( [], [ err.message ], true ); -} ); +/** + * Attach an axe runner to the window object to allow for running the scan from + * the active document. + * + * @param {Object} options Options for the accessibility scan. + * @return {Promise} Promise resolving to the scan result. + */ +window.runAccessibilityScan = async function( options = {} ) { + return scan( options ) + .then( ( result ) => { + if ( typeof options.onComplete === 'function' ) { + options.onComplete( result ); + } + return result; + } ) + .catch( ( err ) => { + if ( typeof options.onComplete === 'function' ) { + options.onComplete( null, err ); + } + throw err; + } ); +}; + +// Auto-run scan and dispatch event to parent frame if in iframe context +if ( isIframeContext() ) { + const iframeOptions = getIframeOptions(); + + tooLongTimeout = setTimeout( () => { + dispatchDoneEvent( [], [ 'Scan timed out' ], 'timeout' ); + }, SCAN_TIMEOUT_IN_SECONDS * 1000 ); + + scan( iframeOptions ) + .then( ( result ) => onDone( result.violations, [], null ) ) + .catch( ( err ) => onDone( [], [ err.message || 'Unknown error' ], err.message ) ); +} + +// Helper to process a violation and return the formatted object +function processViolation( violation, item ) { + // Note that this is an array, generally with one item, but can be more. + const selector = violation.node.selector; + const landmark = getLandmarkForSelector( selector ); + const ancestry = violation.node.ancestry || []; + const xpath = violation.node.xpath || []; + const html = document.querySelector( selector )?.outerHTML; + return { + selector, + ancestry, + xpath, + html, + ruleId: item.id, + impact: item.impact, + tags: item.tags, + landmark: landmark.type, + landmarkSelector: landmark.selector, + }; +} diff --git a/tests/phpunit/Admin/InsertRuleDataTest.php b/tests/phpunit/Admin/InsertRuleDataTest.php index 3e3d7ead2..628dafbf7 100644 --- a/tests/phpunit/Admin/InsertRuleDataTest.php +++ b/tests/phpunit/Admin/InsertRuleDataTest.php @@ -20,31 +20,11 @@ class InsertRuleDataTest extends WP_UnitTestCase { public function setUp(): void { global $wpdb; $this->table_name = $wpdb->prefix . 'accessibility_checker'; - $charset_collate = $wpdb->get_charset_collate(); - $sql = "CREATE TABLE $this->table_name ( - id bigint(20) NOT NULL AUTO_INCREMENT, - postid bigint(20) NOT NULL, - siteid text NOT NULL, - type text NOT NULL, - rule text NOT NULL, - ruletype text NOT NULL, - object mediumtext NOT NULL, - recordcheck mediumint(9) NOT NULL, - created timestamp NOT NULL default CURRENT_TIMESTAMP, - user bigint(20) NOT NULL, - ignre mediumint(9) NOT NULL, - ignre_global mediumint(9) NOT NULL, - ignre_user bigint(20) NULL, - ignre_date timestamp NULL, - ignre_comment mediumtext NULL, - landmark varchar(20) NULL, - landmark_selector text NULL, - UNIQUE KEY id (id), - KEY postid_index (postid) - ) $charset_collate;"; - require_once ABSPATH . 'wp-admin/includes/upgrade.php'; - dbDelta( $sql ); + // Use the Update_Database class to create/update the table schema. + require_once dirname( __DIR__, 3 ) . '/admin/class-update-database.php'; + $update_db = new \EDAC\Admin\Update_Database(); + $update_db->edac_update_database(); } /**