diff --git a/accessibility-checker.php b/accessibility-checker.php index ae7839f98..cc7005a13 100755 --- a/accessibility-checker.php +++ b/accessibility-checker.php @@ -40,7 +40,7 @@ // Current database version. if ( ! defined( 'EDAC_DB_VERSION' ) ) { - define( 'EDAC_DB_VERSION', '1.0.3' ); + define( 'EDAC_DB_VERSION', '1.0.4' ); } // Plugin Folder Path. diff --git a/admin/class-ajax.php b/admin/class-ajax.php index 349e9c98c..69b66a609 100644 --- a/admin/class-ajax.php +++ b/admin/class-ajax.php @@ -305,7 +305,7 @@ function ( $a, $b ) { foreach ( $rules as $rule ) { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Using direct query for interacting with custom database, safe variable used for table name, caching not required for one time operation. - $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, postid, object, ruletype, ignre, ignre_user, ignre_date, ignre_comment, ignre_global FROM %i where postid = %d and rule = %s and siteid = %d', $table_name, $postid, $rule['slug'], $siteid ), ARRAY_A ); + $results = $wpdb->get_results( $wpdb->prepare( 'SELECT id, postid, object, ruletype, ignre, ignre_user, ignre_date, ignre_comment, ignre_global, landmark, landmark_selector FROM %i where postid = %d and rule = %s and siteid = %d', $table_name, $postid, $rule['slug'], $siteid ), ARRAY_A ); $count_classes = ( 'error' === $rule['rule_type'] ) ? ' edac-details-rule-count-error' : ' edac-details-rule-count-warning'; $count_classes .= ( 0 !== $rule['count'] ) ? ' active' : ''; @@ -406,6 +406,8 @@ function ( $a, $b ) { $html .= ob_get_clean(); } + + $html .= '
'; + $html .= '
'; + + $landmark = isset( $row['landmark'] ) ? esc_html( $row['landmark'] ) : ''; + $landmark_selector = isset( $row['landmark_selector'] ) ? $row['landmark_selector'] : ''; + + if ( $landmark && $landmark_selector ) { + $landmark_url = add_query_arg( + [ + 'edac_landmark' => base64_encode( $landmark_selector ), + 'edac_nonce' => wp_create_nonce( 'edac_highlight' ), + ], + get_the_permalink( $postid ) + ); + + // translators: %s is the landmark type (e.g., "Header", "Navigation", "Main"). + $landmark_aria_label = sprintf( __( 'View %s landmark on website, opens a new window', 'accessibility-checker' ), ucwords( $landmark ) ); + // translators: %s is the landmark type (e.g., "Header", "Navigation", "Main"). + $html .= '' . ucwords( $landmark ) . ''; + } elseif ( $landmark ) { + $html .= ucwords( $landmark ); + } + + $html .= '
'; + $html .= '
'; if ( ! isset( $rule['viewable'] ) || $rule['viewable'] ) { diff --git a/admin/class-insert-rule-data.php b/admin/class-insert-rule-data.php index db15a3816..51901827a 100644 --- a/admin/class-insert-rule-data.php +++ b/admin/class-insert-rule-data.php @@ -21,15 +21,17 @@ 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 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. * * @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 ) { + public function insert( object $post, string $rule, string $ruletype, string $rule_obj, ?string $landmark = null, ?string $landmark_selector = null ) { if ( ! isset( $post->ID, $post->post_type ) || empty( $rule ) @@ -44,19 +46,21 @@ public function insert( object $post, string $rule, string $ruletype, string $ru // set up rule data array. $rule_data = [ - 'postid' => $post->ID, - 'siteid' => get_current_blog_id(), - 'type' => $post->post_type, - 'rule' => $rule, - 'ruletype' => $ruletype, - 'object' => esc_attr( $rule_obj ), - 'recordcheck' => 1, - 'user' => get_current_user_id(), - 'ignre' => 0, - 'ignre_user' => null, - 'ignre_date' => null, - 'ignre_comment' => null, - 'ignre_global' => 0, + 'postid' => $post->ID, + 'siteid' => get_current_blog_id(), + 'type' => $post->post_type, + 'landmark' => $landmark, + 'landmark_selector' => $landmark_selector, + 'rule' => $rule, + 'ruletype' => $ruletype, + 'object' => esc_attr( $rule_obj ), + 'recordcheck' => 1, + 'user' => get_current_user_id(), + 'ignre' => 0, + 'ignre_user' => null, + 'ignre_date' => null, + 'ignre_comment' => null, + 'ignre_global' => 0, ]; // return if revision. @@ -125,19 +129,21 @@ public function insert( object $post, string $rule, string $ruletype, string $ru // the data is still as valid as it was when it was first set. // Sanitize the filtered data. $rule_data_sanitized = [ - 'postid' => absint( $rule_data['postid'] ), - 'siteid' => absint( $rule_data['siteid'] ), - 'type' => sanitize_text_field( $rule_data['type'] ), - 'rule' => sanitize_text_field( $rule_data['rule'] ), - 'ruletype' => sanitize_text_field( $rule_data['ruletype'] ), - 'object' => esc_attr( $rule_data['object'] ), - 'recordcheck' => absint( $rule_data['recordcheck'] ), - 'user' => absint( $rule_data['user'] ), - 'ignre' => absint( $rule_data['ignre'] ), - 'ignre_user' => isset( $rule_data['ignre_user'] ) ? absint( $rule_data['ignre_user'] ) : null, - 'ignre_date' => isset( $rule_data['ignre_date'] ) ? sanitize_text_field( $rule_data['ignre_date'] ) : null, - 'ignre_comment' => isset( $rule_data['ignre_comment'] ) ? sanitize_text_field( $rule_data['ignre_comment'] ) : null, - 'ignre_global' => absint( $rule_data['ignre_global'] ), + 'postid' => absint( $rule_data['postid'] ), + 'siteid' => absint( $rule_data['siteid'] ), + '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, + 'rule' => sanitize_text_field( $rule_data['rule'] ), + 'ruletype' => sanitize_text_field( $rule_data['ruletype'] ), + 'object' => esc_attr( $rule_data['object'] ), + 'recordcheck' => absint( $rule_data['recordcheck'] ), + 'user' => absint( $rule_data['user'] ), + 'ignre' => absint( $rule_data['ignre'] ), + 'ignre_user' => isset( $rule_data['ignre_user'] ) ? absint( $rule_data['ignre_user'] ) : null, + 'ignre_date' => isset( $rule_data['ignre_date'] ) ? sanitize_text_field( $rule_data['ignre_date'] ) : null, + 'ignre_comment' => isset( $rule_data['ignre_comment'] ) ? sanitize_text_field( $rule_data['ignre_comment'] ) : null, + 'ignre_global' => absint( $rule_data['ignre_global'] ), ]; // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- Using direct query for adding data to database. diff --git a/admin/class-update-database.php b/admin/class-update-database.php index 894882373..10bd6f30d 100644 --- a/admin/class-update-database.php +++ b/admin/class-update-database.php @@ -48,6 +48,8 @@ public function edac_update_database() { postid bigint(20) NOT NULL, siteid text NOT NULL, type text NOT NULL, + landmark varchar(20) NULL, + landmark_selector text NULL, rule text NOT NULL, ruletype text NOT NULL, object mediumtext NOT NULL, diff --git a/includes/classes/class-rest-api.php b/includes/classes/class-rest-api.php index fb4fe4fbe..ea6fa2ce0 100644 --- a/includes/classes/class-rest-api.php +++ b/includes/classes/class-rest-api.php @@ -380,7 +380,10 @@ public function set_post_scan_results( $request ) { */ do_action( 'edac_before_rule', $post_id, $actual_rule_id, 'js' ); - ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html ); + $landmark = $violation['landmark'] ?? null; + $landmark_selector = $violation['landmarkSelector'] ?? null; + + ( new Insert_Rule_Data() )->insert( $post, $actual_rule_id, $impact, $html, $landmark, $landmark_selector ); /** * Fires after a rule is run against the content. diff --git a/src/admin/sass/accessibility-checker-admin.scss b/src/admin/sass/accessibility-checker-admin.scss index d8e57ae83..8246e2de4 100644 --- a/src/admin/sass/accessibility-checker-admin.scss +++ b/src/admin/sass/accessibility-checker-admin.scss @@ -621,7 +621,7 @@ color: variables.$color-white; background-color: variables.$color-dark-gray; display: grid; - grid-template-columns: auto 125px 125px; + grid-template-columns: auto 125px 125px 125px; &-label { padding: 3px; @@ -631,7 +631,7 @@ &-record { display: grid; - grid-template-columns: auto 125px 125px; + grid-template-columns: auto 125px 125px 125px; &-cell { padding: 10px; diff --git a/src/frontendHighlighterApp/index.js b/src/frontendHighlighterApp/index.js index dc3eb474a..50cabdc07 100644 --- a/src/frontendHighlighterApp/index.js +++ b/src/frontendHighlighterApp/index.js @@ -32,6 +32,7 @@ class AccessibilityCheckerHighlight { this.fixes = null; this.currentButtonIndex = null; this.urlParameter = this.get_url_parameter( 'edac' ); + this.landmarkParameter = this.get_url_parameter( 'edac_landmark' ); this.currentIssueStatus = null; this.tooltips = []; this.panelControlsFocusTrap = createFocusTrap( '#' + this.panelControls.id, { @@ -97,6 +98,8 @@ class AccessibilityCheckerHighlight { // Open panel if a URL parameter exists if ( this.urlParameter ) { this.panelOpen( this.urlParameter ); + } else if ( this.landmarkParameter ) { + this.highlightLandmark( this.landmarkParameter ); } } @@ -242,6 +245,9 @@ class AccessibilityCheckerHighlight { buttons.forEach( ( button ) => { button.remove(); } ); + + // Clean up any landmark labels + this.removeLandmarkLabels(); } /** @@ -577,6 +583,9 @@ class AccessibilityCheckerHighlight { selectedElement.removeAttribute( 'class' ); } } ); + + // Clean up any landmark labels when highlights are removed + this.removeLandmarkLabels(); }; /** @@ -889,6 +898,196 @@ class AccessibilityCheckerHighlight { div.textContent = textContent; } + + /** + * This function highlights a landmark based on the selector. + * @param {string} encodedSelector Base64-encoded CSS selector for the landmark + */ + highlightLandmark( encodedSelector ) { + try { + // Decode the base64 selector + const selector = atob( encodedSelector ); + + // Find the landmark element using multiple strategies + let landmarkElement = null; + + try { + // Try the original selector first + landmarkElement = document.querySelector( selector ); + } catch ( error ) { + // Selector might be invalid, try fallbacks + } + + // If original selector failed, try some fallback strategies + if ( ! landmarkElement ) { + // Try common landmark selectors as fallbacks + const fallbackSelectors = [ + // Remove complex pseudo-selectors and try simpler versions + selector.replace( /:nth-child\(\d+\)/g, '' ).replace( /\s+>\s+/g, ' ' ), + // Try just the last part of the selector + selector.split( ' > ' ).pop(), + // Try without classes + selector.replace( /\.[^:\s>]+/g, '' ), + ]; + + for ( const fallback of fallbackSelectors ) { + if ( fallback && fallback.trim() ) { + try { + landmarkElement = document.querySelector( fallback.trim() ); + if ( landmarkElement ) { + break; + } + } catch ( e ) { + // Continue to next fallback + } + } + } + } + + if ( landmarkElement ) { + // Clean up any existing landmark labels first + this.removeLandmarkLabels(); + + // Add highlighting styles + landmarkElement.classList.add( 'edac-highlight-element-selected' ); + landmarkElement.classList.add( 'edac-landmark-highlight' ); + + // Create and add landmark type label + const landmarkType = this.getLandmarkType( landmarkElement ); + const landmarkLabel = document.createElement( 'div' ); + landmarkLabel.classList.add( 'edac-landmark-label' ); + landmarkLabel.textContent = `Landmark: ${ landmarkType }`; + landmarkLabel.setAttribute( 'aria-hidden', 'true' ); + landmarkLabel.style.cssText = ` + position: absolute; + background: #072446; + color: white; + padding: 4px 8px; + font-size: 12px; + font-weight: bold; + border-radius: 3px; + z-index: 99998; + pointer-events: none; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + line-height: 1; + box-shadow: 0 2px 4px rgba(0,0,0,0.2); + `; + + // Position the label inside the top-left corner of the landmark + const rect = landmarkElement.getBoundingClientRect(); + landmarkLabel.style.left = ( rect.left + window.scrollX - 0 ) + 'px'; // 15px inside from left edge + landmarkLabel.style.top = ( rect.top + window.scrollY - 0 ) + 'px'; // 15px inside from top edge + + // Add label to the page + document.body.appendChild( landmarkLabel ); + + // Store reference for cleanup + landmarkElement.setAttribute( 'data-edac-landmark-label-id', Date.now() ); + landmarkLabel.setAttribute( 'data-edac-landmark-for', landmarkElement.getAttribute( 'data-edac-landmark-label-id' ) ); + + // Adjust for small elements + if ( landmarkElement.offsetWidth < 20 ) { + landmarkElement.classList.add( 'edac-highlight-element-selected-min-width' ); + } + + if ( landmarkElement.offsetHeight < 5 ) { + landmarkElement.classList.add( 'edac-highlight-element-selected-min-height' ); + } + + // Scroll to the landmark with 20px offset from start + const elementRect = landmarkElement.getBoundingClientRect(); + const elementTop = elementRect.top + window.scrollY - 75; + window.scrollTo( { + top: elementTop, + behavior: 'smooth', + } ); + + } else { + // Landmark element not found - silently fail + } + } catch ( error ) { + // Error highlighting landmark - silently fail + } + } + + /** + * Determines the landmark type of an element + * @param {HTMLElement} element The element to check + * @return {string} The landmark type (e.g., "Header", "Navigation", "Main") + */ + getLandmarkType( element ) { + // Check explicit ARIA role first + const role = element.getAttribute( 'role' ); + if ( role ) { + switch ( role.toLowerCase() ) { + case 'banner': + return 'Header'; + case 'navigation': + return 'Navigation'; + case 'main': + return 'Main'; + case 'complementary': + return 'Complementary'; + case 'contentinfo': + return 'Footer'; + case 'search': + return 'Search'; + case 'form': + return 'Form'; + case 'region': + return 'Region'; + default: + return role.charAt( 0 ).toUpperCase() + role.slice( 1 ); + } + } + + // Check semantic HTML elements + const tagName = element.tagName.toLowerCase(); + switch ( tagName ) { + case 'header': + return 'Header'; + case 'nav': + return 'Navigation'; + case 'main': + return 'Main'; + case 'aside': + return 'Complementary'; + case 'footer': + return 'Footer'; + case 'section': { + // Check if section has accessible name + const hasAccessibleName = element.getAttribute( 'aria-label' ) || + element.getAttribute( 'aria-labelledby' ) || + element.querySelector( 'h1, h2, h3, h4, h5, h6' ); + return hasAccessibleName ? 'Region' : 'Section'; + } + case 'form': { + // Check if form has accessible name + const formHasAccessibleName = element.getAttribute( 'aria-label' ) || + element.getAttribute( 'aria-labelledby' ); + return formHasAccessibleName ? 'Form' : 'Form (unlabeled)'; + } + default: + return 'Landmark'; + } + } + + /** + * Remove all landmark labels from the page + */ + removeLandmarkLabels() { + const landmarkLabels = document.querySelectorAll( '.edac-landmark-label' ); + landmarkLabels.forEach( ( label ) => { + label.remove(); + } ); + + // Remove landmark highlight classes + const landmarkHighlights = document.querySelectorAll( '.edac-landmark-highlight' ); + landmarkHighlights.forEach( ( element ) => { + element.classList.remove( 'edac-landmark-highlight' ); + element.removeAttribute( 'data-edac-landmark-label-id' ); + } ); + } } // Some systems (Cloudflare Rocket Loader) defers scripts for performance but that can diff --git a/src/frontendHighlighterApp/sass/app.scss b/src/frontendHighlighterApp/sass/app.scss index 4c91a7ac2..b840a9e7e 100644 --- a/src/frontendHighlighterApp/sass/app.scss +++ b/src/frontendHighlighterApp/sass/app.scss @@ -34,6 +34,8 @@ body { outline: dashed 4px transparent !important; outline-offset: 5px !important; outline-color: magenta !important; + max-width: calc(100vw - 30px) !important; + box-sizing: border-box !important; &-min-width { min-width: 25px !important; diff --git a/src/pageScanner/index.js b/src/pageScanner/index.js index 5456a1c23..5d306305b 100644 --- a/src/pageScanner/index.js +++ b/src/pageScanner/index.js @@ -10,6 +10,158 @@ import { getPageDensity } from './helpers/density'; const SCAN_TIMEOUT_IN_SECONDS = 30; +// Landmark tags for semantic regions +const LANDMARK_TAGS = [ 'MAIN', 'HEADER', 'FOOTER', 'NAV', 'ASIDE' ]; +const LANDMARK_ROLES = [ + 'main', + 'navigation', + 'banner', + 'contentinfo', + 'complementary', +]; + +// Conditional landmark tags that only become landmarks when they have accessible names +const CONDITIONAL_LANDMARK_TAGS = [ 'SECTION', 'ARTICLE', 'FORM' ]; +const CONDITIONAL_LANDMARK_ROLES = [ 'region', 'article', 'form' ]; + +function getLandmarkForSelector( selector ) { + const el = document.querySelector( selector ); + if ( ! el ) { + return { type: null, selector: null }; + } + let current = el; + while ( current && current !== document.body ) { + // Check unconditional landmark tags + if ( LANDMARK_TAGS.includes( current.tagName ) ) { + return { type: current.tagName.toLowerCase(), selector: getElementSelector( current ) }; + } + + // Check conditional landmark tags (require accessible name) + if ( + CONDITIONAL_LANDMARK_TAGS.includes( current.tagName ) && + ( current.hasAttribute( 'aria-label' ) || current.hasAttribute( 'aria-labelledby' ) ) + ) { + return { type: current.tagName.toLowerCase(), selector: getElementSelector( current ) }; + } + + // Check roles + if ( current.hasAttribute( 'role' ) ) { + const role = current.getAttribute( 'role' ).toLowerCase(); + + // Check unconditional landmark roles + if ( LANDMARK_ROLES.includes( role ) ) { + return { type: role, selector: getElementSelector( current ) }; + } + + // Check conditional landmark roles (require accessible name) + if ( + CONDITIONAL_LANDMARK_ROLES.includes( role ) && + ( current.hasAttribute( 'aria-label' ) || current.hasAttribute( 'aria-labelledby' ) ) + ) { + return { type: role, selector: getElementSelector( current ) }; + } + } + + current = current.parentElement; + } + return { type: null, selector: null }; +} + +// Helper to get a unique CSS selector for an element +function getElementSelector( element ) { + if ( ! element ) { + return null; + } + + // Use ID if available (most reliable) + if ( element.id ) { + return `#${ element.id }`; + } + + // For landmark elements, try to use semantic selectors first + const tagName = element.tagName.toLowerCase(); + + // For main element, use tag selector if it's unique + if ( tagName === 'main' ) { + const mainElements = document.querySelectorAll( 'main' ); + if ( mainElements.length === 1 ) { + return 'main'; + } + } + + // For header/footer, check if they're direct children of body + if ( ( tagName === 'header' || tagName === 'footer' ) && element.parentElement === document.body ) { + return tagName; + } + + // For nav elements, try role-based selector first + if ( tagName === 'nav' || element.getAttribute( 'role' ) === 'navigation' ) { + const navElements = document.querySelectorAll( 'nav, [role="navigation"]' ); + if ( navElements.length === 1 ) { + return tagName === 'nav' ? 'nav' : '[role="navigation"]'; + } + // If multiple, try to use aria-label or other identifying attributes + if ( element.hasAttribute( 'aria-label' ) ) { + const ariaLabel = element.getAttribute( 'aria-label' ); + return `${ tagName === 'nav' ? 'nav' : '[role="navigation"]' }[aria-label="${ ariaLabel }"]`; + } + } + + // For other landmark roles, use role selector if unique + const role = element.getAttribute( 'role' ); + if ( role && LANDMARK_ROLES.includes( role ) ) { + const roleElements = document.querySelectorAll( `[role="${ role }"]` ); + if ( roleElements.length === 1 ) { + return `[role="${ role }"]`; + } + // If multiple, try to use aria-label + if ( element.hasAttribute( 'aria-label' ) ) { + const ariaLabel = element.getAttribute( 'aria-label' ); + return `[role="${ role }"][aria-label="${ ariaLabel }"]`; + } + } + + // Fallback to path-based selector (simplified) + const path = []; + let current = element; + while ( current && current.nodeType === Node.ELEMENT_NODE && current !== document.body ) { + let selector = current.nodeName.toLowerCase(); + + // Add ID if available + if ( current.id ) { + selector = `#${ current.id }`; + path.unshift( selector ); + break; // Stop here since ID is unique + } + + // Add stable classes (avoid dynamic/generated classes) + if ( current.className ) { + const classes = current.className.trim().split( /\s+/ ) + .map( ( cls ) => CSS.escape( cls ) ) + .filter( ( cls ) => ! cls.match( /^(wp-|js-|css-|generated-|dynamic-)/ ) ) // Filter out common dynamic classes + .slice( 0, 2 ); // Limit to first 2 classes for stability + if ( classes.length > 0 ) { + selector += `.${ classes.join( '.' ) }`; + } + } + + // Only add nth-child as last resort and only if element has no other identifying features + if ( ! current.id && ! current.className ) { + const siblingIndex = Array.from( current.parentNode.children ).indexOf( current ) + 1; + selector += `:nth-child(${ siblingIndex })`; + } + + path.unshift( selector ); + current = current.parentElement; + + // Limit path depth to avoid overly complex selectors + if ( path.length >= 4 ) { + break; + } + } + return path.length ? path.join( ' > ' ) : null; +} + // Read the data passed from the parent document. const body = document.querySelector( 'body' ); const iframeId = body.getAttribute( 'data-iframe-id' ); @@ -58,12 +210,17 @@ 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: violation.node.selector, - html: document.querySelector( violation.node.selector ).outerHTML, + selector, + html, ruleId: item.id, impact: item.impact, tags: item.tags, + landmark: landmark.type, + landmarkSelector: landmark.selector, } ); } } ); @@ -71,12 +228,17 @@ const scan = async ( // 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: incompleteItem.node.selector, - html: document.querySelector( incompleteItem.node.selector ).outerHTML, + selector, + 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 a2cef58c3..3e3d7ead2 100644 --- a/tests/phpunit/Admin/InsertRuleDataTest.php +++ b/tests/phpunit/Admin/InsertRuleDataTest.php @@ -37,6 +37,8 @@ public function setUp(): void { 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;";