From 758c341ab86736682ccd469bc275e149d72b4302 Mon Sep 17 00:00:00 2001 From: Gaurav Tiwari Date: Fri, 2 Oct 2026 18:46:59 +0530 Subject: [PATCH] Fix v1.7.0 release review regressions Rebase link scan progress after source edits, serialize current report and ignore updates, and preserve WordPress post-update hooks. Guard structured block links, stream filtered exports, and record final upgrader outcomes. Deliver component CSS through a real editor asset handle. Regression tests cover the cached-state, stale-form, updater-class, and iframe paths that earlier happy-path checks missed. --- .github/workflows/quality.yml | 1 + .github/workflows/release.yml | 1 + assets/css/components-editor.css | 2 + assets/js/admin-link-health.js | 1 + build.sh | 1 + docs/release-fixes-verification.md | 34 +++ includes/admin/class-module-controller.php | 2 +- includes/admin/trait-admin-utilities-ui.php | 22 +- includes/features/class-components.php | 34 ++- .../features/class-link-health-editor.php | 29 ++- .../features/class-link-health-report.php | 82 ++++-- includes/features/class-link-health.php | 57 +++- includes/features/class-site-activity.php | 52 +++- languages/functionalities.pot | 246 +++++++++--------- readme.txt | 14 +- tests/FeatureAssetsTest.php | 9 + tests/UtilityModulesTest.php | 161 ++++++++++++ tests/bootstrap.php | 11 + tests/fixtures/feature-assets-worker.php | 10 +- tests/fixtures/link-health-ignore-worker.php | 32 +++ tests/fixtures/link-health-native-worker.php | 32 +++ .../link-health-recheck-cache-worker.php | 37 +++ .../link-health-refresh-cache-worker.php | 37 +++ tests/link-health-live.test.mjs | 10 + 24 files changed, 726 insertions(+), 191 deletions(-) create mode 100644 assets/css/components-editor.css create mode 100644 docs/release-fixes-verification.md create mode 100644 tests/fixtures/link-health-ignore-worker.php create mode 100644 tests/fixtures/link-health-native-worker.php create mode 100644 tests/fixtures/link-health-recheck-cache-worker.php create mode 100644 tests/fixtures/link-health-refresh-cache-worker.php diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 3ab6587..d1a01fb 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -96,6 +96,7 @@ jobs: functionalities/assets/css/admin-brand.css \ functionalities/assets/css/admin-icons.css \ functionalities/assets/css/admin-polish.css \ + functionalities/assets/css/components-editor.css \ functionalities/assets/js/admin-polish.js \ functionalities/assets/js/admin-link-health.js \ functionalities/assets/js/admin-link-health-editor.js \ diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f028aaa..929ab16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -123,6 +123,7 @@ jobs: "includes/core/class-wordpress-7-integration.php" \ "includes/storage/class-data-directory.php" \ "assets/vendor/prism/prism.min.js" \ + "assets/css/components-editor.css" \ "languages/functionalities.pot" \ "assets/js/wp7-admin.js" \ "assets/js/wp7-admin.asset.php" \ diff --git a/assets/css/components-editor.css b/assets/css/components-editor.css new file mode 100644 index 0000000..27ec6b3 --- /dev/null +++ b/assets/css/components-editor.css @@ -0,0 +1,2 @@ +/* Real stylesheet anchor for component rules added through enqueue_block_assets. + Keep this asset in the package: WordPress carries its inline CSS into the editor iframe. */ diff --git a/assets/js/admin-link-health.js b/assets/js/admin-link-health.js index 356f071..0b4e0fe 100644 --- a/assets/js/admin-link-health.js +++ b/assets/js/admin-link-health.js @@ -131,6 +131,7 @@ const fields = Object.fromEntries(new FormData(form)); delete fields.action; delete fields.operation; + delete fields.run; const data = await request(operation, fields); if (revision === epoch) { suspended = false; diff --git a/build.sh b/build.sh index 66ce645..5a255aa 100755 --- a/build.sh +++ b/build.sh @@ -45,6 +45,7 @@ REQUIRED=( "assets/css/admin-brand.css" "assets/css/admin-icons.css" "assets/css/admin-polish.css" + "assets/css/components-editor.css" "assets/js/admin-polish.js" "assets/js/admin-link-health.js" "assets/js/admin-link-health-editor.js" diff --git a/docs/release-fixes-verification.md b/docs/release-fixes-verification.md new file mode 100644 index 0000000..8d19c94 --- /dev/null +++ b/docs/release-fixes-verification.md @@ -0,0 +1,34 @@ +# v1.7.0 release-fix verification + +The release review findings are covered by regression tests and a Gatilab verification pass. The checks below cover the v1.7.0 release contents. + +## Corrected behavior + +- Link edits defer File-block unlinks, bound URLs, and serialized block settings to the post editor. Valid block-comment whitespace cannot bypass the guard; safety checks inspect beyond the scan limit. +- Source fingerprints and edit revisions prevent a partial scan or overlapping worker from restoring an obsolete link offset. +- Published-post edits dispatch WordPress's status transition and edit/save hooks, including feed-cache invalidation. +- Recheck, Ignore, and reconciliation read current post/metadata caches under the shared lock. Native controls require the displayed scan generation; AJAX uses its live generation rather than a stale hidden field. +- Filtered CSV exports traverse each source once. The 100,000-link synthetic benchmark fell from 162,000 queries to 81, retaining every row. This benchmark uses production report code with a zero-latency simulated database. +- Site Activity uses final per-item installation results, distinguishes manual AJAX skins from automatic skins, and waits for automatic rollback outcomes. +- The JSON preset field has a translated accessible label. +- Components enqueue a real editor stylesheet handle with inline rules through `enqueue_block_assets`, so the canvas does not depend on generated uploads CSS or the settings-style channel. + +## Automated checks + +- `composer check` with the real WordPress 6.3 HTML API: 180 tests, 6,978 assertions; syntax, coding standards, and PHP compatibility checks passed. +- The 51 utility tests also passed with WordPress 6.9 core; the five asset tests passed on both core versions. +- `npm test`: 19 tests passed. +- Original defects were reproduced before changes, including nine failures in an isolated copy of `f85d308` with the new tests. Cache races, the native/AJAX generation interaction, and canvas asset delivery each received explicit failing-then-passing checks. +- Translation freshness, package required-file checks, and whitespace checks passed. The runtime package contains 151 files; development fixtures remain excluded. + +## Gatilab runtime checks + +Tested on WordPress 7.1.2 and PHP 8.5.10. Original content and settings were snapshotted before installing the candidate. Only disposable fixtures were edited. + +A six-link fixture was scanned for four links, stopped, unlinked, replaced through the browser's reviewed edit flow, and resumed. Every remaining URL was checked before completion. A deliberately cached feed timestamp was invalidated. Parallel real WordPress requests retained both recheck results and both Ignore choices. Browser filters and downloaded CSV agreed on the two ignored fixture rows. Final upgrade callbacks were exercised with real WordPress automatic and manual AJAX skin classes, without updating unrelated installed plugins. + +Editor checks were completed in Brave Origin. The configured variable font appeared in the picker and canvas with a normalized `100 1000` face. SVG Icon transformed to Core Icon and back with its SVG and 48px size preserved. Component styles were verified with generated CSS present and with generation deliberately unavailable through a temporary QA-only path filter; the badge retained its background and padding in both cases. + +The temporary path filter and both fixtures were removed. Original module options, link report metadata, and scan state matched the deployment snapshot after cleanup. Concurrent, unrelated content edits were preserved; cleanup did not restore a database over other changes. Existing activity history was retained, and update-outcome test events used isolated private storage. + +Deployment backups and detailed verification receipts are kept outside the repository. Before repeating these checks, snapshot the target site's settings, source content, report metadata, and scan state; scope edits to disposable fixtures; then remove only test-owned records and compare the preserved data. diff --git a/includes/admin/class-module-controller.php b/includes/admin/class-module-controller.php index 2d40264..69d09cd 100644 --- a/includes/admin/class-module-controller.php +++ b/includes/admin/class-module-controller.php @@ -525,7 +525,7 @@ public static function field_json_preset_url(): void { ?>
- + diff --git a/includes/admin/trait-admin-utilities-ui.php b/includes/admin/trait-admin-utilities-ui.php index 8f4ea3f..0648ab2 100644 --- a/includes/admin/trait-admin-utilities-ui.php +++ b/includes/admin/trait-admin-utilities-ui.php @@ -92,6 +92,10 @@ public static function handle_link_health_action(): void { $operation = \sanitize_key( \wp_unslash( $_POST['operation'] ?? '' ) ); $post_id = \absint( $_POST['post_id'] ?? 0 ); $url = Link_Health_Controller::request_url(); + $run = isset( $_POST['run'] ) && is_string( $_POST['run'] ) ? \sanitize_text_field( \wp_unslash( $_POST['run'] ) ) : ''; + if ( in_array( $operation, array( 'resume', 'stop' ), true ) && '' === $run ) { + \wp_die( \esc_html__( 'Refresh the scan status before continuing.', 'functionalities' ), '', array( 'response' => 400 ) ); + } if ( ! \Functionalities\Core\Module_Registry::is_enabled( 'link-health' ) ) { \wp_die( \esc_html__( 'Enable Link Health before running an action.', 'functionalities' ), '', array( 'response' => 403 ) ); } @@ -100,10 +104,10 @@ public static function handle_link_health_action(): void { $result = \Functionalities\Features\Link_Health::start_scan(); break; case 'resume': - $result = 'stopped' === ( \Functionalities\Features\Link_Health::state()['status'] ?? '' ) ? \Functionalities\Features\Link_Health::resume_scan() : \Functionalities\Features\Link_Health::run_batch(); + $result = 'stopped' === ( \Functionalities\Features\Link_Health::state()['status'] ?? '' ) ? \Functionalities\Features\Link_Health::resume_scan( $run ) : \Functionalities\Features\Link_Health::run_batch( $run ); break; case 'stop': - $result = \Functionalities\Features\Link_Health::stop_scan(); + $result = \Functionalities\Features\Link_Health::stop_scan( $run ); break; case 'ignore': $result = \Functionalities\Features\Link_Health::set_ignored( $post_id, $url, true ); @@ -152,16 +156,11 @@ private static function export_link_health(): void { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Stream a generated CSV download. $stream = fopen( 'php://output', 'w' ); fputcsv( $stream, array( 'Post ID', 'Title', 'URL', 'Status', 'HTTP', 'Checked UTC', 'Stale', 'Complete', 'Truncated' ), ',', '"', '' ); - $page = 1; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Caller already verifies the administrator and export nonce. $filters = \Functionalities\Features\Link_Health_Report::filters( \wp_unslash( $_POST ) ); - do { - $report = \Functionalities\Features\Link_Health::report_page( $page, $filters ); - foreach ( $report['rows'] as $row ) { - fputcsv( $stream, array( $row['post_id'], self::csv_safe( $row['title'] ), self::csv_safe( $row['url'] ), $row['status'], $row['code'], $row['checked'] ? gmdate( 'c', $row['checked'] ) : '', (int) $row['stale'], (int) $row['complete'], (int) $row['truncated'] ), ',', '"', '' ); - } - ++$page; - } while ( $page <= $report['pages'] ); + foreach ( \Functionalities\Features\Link_Health_Report::export_rows( $filters ) as $row ) { + fputcsv( $stream, array( $row['post_id'], self::csv_safe( $row['title'] ), self::csv_safe( $row['url'] ), $row['status'], $row['code'], $row['checked'] ? gmdate( 'c', $row['checked'] ) : '', (int) $row['stale'], (int) $row['complete'], (int) $row['truncated'] ), ',', '"', '' ); + } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Complete the CSV response stream. fclose( $stream ); exit; @@ -177,6 +176,9 @@ private static function link_health_button( string $operation, string $label, in '; + } if ( 'export' === $operation ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only selected report filters. foreach ( \Functionalities\Features\Link_Health_Report::filters( \wp_unslash( $_GET ) ) as $key => $value ) { diff --git a/includes/features/class-components.php b/includes/features/class-components.php index a5ddf89..c3b8745 100644 --- a/includes/features/class-components.php +++ b/includes/features/class-components.php @@ -108,14 +108,31 @@ public static function init(): void { \add_action( 'wp_enqueue_scripts', array( __CLASS__, 'print_footer_link' ) ); \add_action( 'admin_enqueue_scripts', array( __CLASS__, 'print_footer_link' ) ); - // Serve the block editor canvas (an iframe) via the editor `styles` - // setting — the only channel guaranteed to reach the iframe. + // A real asset handle carries inline rules into the iframe on the block-assets channel. + \add_action( 'enqueue_block_assets', array( __CLASS__, 'enqueue_editor_assets' ) ); + + // Retain settings styles for editor integrations using that channel. \add_filter( 'block_editor_settings_all', array( __CLASS__, 'add_editor_settings_components' ) ); // Regenerate CSS file when settings are updated. \add_action( 'update_option_functionalities_components', array( __CLASS__, 'on_option_update' ), 10, 2 ); } + /** Deliver component rules to the canvas without depending on writable uploads. */ + public static function enqueue_editor_assets(): void { + if ( ! \is_admin() || ! \Functionalities\Core\Module_Registry::is_enabled( 'components' ) ) { + return; + } + $opts = self::get_options(); + $items = \apply_filters( 'functionalities_components_items', $opts['items'] ); + $css = self::sanitize_css( self::build_css( $items ) ); + if ( '' === $css ) { + return; + } + \wp_enqueue_style( 'functionalities-components-editor', FUNCTIONALITIES_URL . 'assets/css/components-editor.css', array(), FUNCTIONALITIES_VERSION ); + \wp_add_inline_style( 'functionalities-components-editor', $css ); + } + /** * Cached options. * @@ -160,16 +177,9 @@ protected static function get_options(): array { /** * Inject component CSS into the block editor's style settings. * - * The editor canvas is iframed (WP 6.3+/7.x). Feeding CSS through the editor - * `styles` setting — the channel add_editor_style() and the Font Library use — - * is the reliable way into that iframe: WordPress copies it in verbatim and - * scopes the selectors to the content wrapper. - * - * This replaces an enqueue_block_assets path that only reached the iframe when - * a generated CSS file existed; its inline fallback used a src-less style handle - * (wp_register_style( $h, false ) + wp_add_inline_style) that does NOT cross - * into the iframe, so component CSS silently vanished in the editor whenever the - * uploads file could not be written. + * Retain this channel for editor integrations that consume settings styles. + * The native canvas also receives the real stylesheet handle from + * enqueue_editor_assets(), independent of generated uploads and theme filters. * * @since 1.4.7 * diff --git a/includes/features/class-link-health-editor.php b/includes/features/class-link-health-editor.php index 82f37f9..acc533b 100644 --- a/includes/features/class-link-health-editor.php +++ b/includes/features/class-link-health-editor.php @@ -36,8 +36,20 @@ private static function valid_destination( string $url ): bool { return strlen( $url ) <= 2048 && ! preg_match( '/[\x00-\x20\x7f]/', $url ) && is_array( $parts ) && in_array( strtolower( $parts['scheme'] ?? '' ), array( 'http', 'https' ), true ) && ! empty( $parts['host'] ) && ! isset( $parts['user'] ) && ! isset( $parts['pass'] ); } + /** Safety checks must inspect all anchors, including those beyond the scan limit. */ + private static function html_references( string $html, string $base, string $from ): bool { + $parser = new \WP_HTML_Tag_Processor( $html ); + while ( $parser->next_tag( 'A' ) ) { + $href = $parser->get_attribute( 'href' ); + if ( is_string( $href ) && Link_Health::normalize_url( $href, $base ) === $from ) { + return true; + } + } + return false; + } + /** A quick HTML edit must not disagree with a block's separately serialized settings. */ - private static function block_settings_reference( array $blocks, string $base, string $from ): bool { + private static function block_settings_reference( array $blocks, string $base, string $from, string $operation ): bool { $contains = static function ( $values ) use ( &$contains, $base, $from ): bool { foreach ( (array) $values as $key => $value ) { if ( is_array( $value ) && $contains( $value ) ) { @@ -47,13 +59,17 @@ private static function block_settings_reference( array $blocks, string $base, s $is_url = preg_match( '~^(?:https?:)?//|^[/?#]~i', $value ) || preg_match( '/(?:url|href|link|src)$/i', (string) $key ); if ( $is_url && Link_Health::normalize_url( $value, $base ) === $from ) { return true; } - if ( false !== strpos( $value, '<' ) && in_array( $from, Link_Health::extract_links( $value, $base ), true ) ) { + if ( false !== strpos( $value, '<' ) && self::html_references( $value, $base, $from ) ) { return true; } } return false; }; foreach ( $blocks as $block ) { - if ( $contains( $block['attrs'] ?? array() ) || self::block_settings_reference( $block['innerBlocks'] ?? array(), $base, $from ) ) { + $structured = ! empty( $block['attrs']['metadata']['bindings'] ) || ( 'unlink' === $operation && 'core/file' === ( $block['blockName'] ?? '' ) ); + if ( $structured && self::html_references( $block['innerHTML'] ?? '', $base, $from ) ) { + return true; + } + if ( $contains( $block['attrs'] ?? array() ) || self::block_settings_reference( $block['innerBlocks'] ?? array(), $base, $from, $operation ) ) { return true; } } return false; @@ -64,8 +80,8 @@ public static function transform( string $content, string $base, string $from, s if ( ! in_array( $operation, array( 'replace', 'unlink' ), true ) || ( 'replace' === $operation && ! self::valid_destination( $to ) ) ) { return new \WP_Error( 'invalid_destination', \__( 'Enter a complete http:// or https:// URL without credentials or spaces.', 'functionalities' ) ); } - if ( false !== strpos( $content, 'Card'; + $this->assertSame( 'custom/card', parse_blocks( $content )[0]['blockName'] ); + $this->assertInstanceOf( WP_Error::class, \Functionalities\Features\Link_Health_Editor::transform( $content, $base, $old, 'replace', $base . 'new' ) ); + } + } + public function test_release_fix_file_blocks_preserve_their_filename_structure(): void { + $this->require_core(); $base = 'https://example.test/'; $old = $base . 'old'; + $file = ''; + $this->assertInstanceOf( WP_Error::class, \Functionalities\Features\Link_Health_Editor::transform( $file, $base, $old, 'unlink' ) ); + $this->assertIsArray( \Functionalities\Features\Link_Health_Editor::transform( $file, $base, $old, 'replace', $base . 'new' ) ); + } + public function test_release_fix_bound_links_defer_to_their_source(): void { + $this->require_core(); $base = 'https://example.test/'; $old = $base . 'old'; + $bound = ''; + foreach ( array( 'replace', 'unlink' ) as $mode ) { + $this->assertInstanceOf( WP_Error::class, \Functionalities\Features\Link_Health_Editor::transform( $bound, $base, $old, $mode, $base . 'new' ) ); + } + $this->assertIsArray( \Functionalities\Features\Link_Health_Editor::transform( $bound . '

Ordinary

', $base, $base . 'ordinary', 'unlink' ) ); + } + public function test_release_fix_edits_rebase_a_partially_scanned_source(): void { + $this->require_core(); + foreach ( array( 'unlink', 'replace' ) as $mode ) { + $content = ''; + for ( $i = 0; $i < 6; ++$i ) { $content .= '' . $i . ''; } + $this->post( 10, $content ); + $scan = Links::start_scan(); Links::run_batch(); Links::stop_scan( $scan['run'] ); + $preview = \Functionalities\Features\Link_Health_Editor::preview( 10, 'https://example.test/link-0', $mode, 'https://example.test/replacement' ); + $this->assertIsArray( \Functionalities\Features\Link_Health_Editor::apply( 10, $preview['token'] ) ); + Links::resume_scan( $scan['run'] ); + for ( $i = 0; $i < 4 && 'running' === Links::state()['status']; ++$i ) { Links::run_batch(); } + $report = get_post_meta( 10, Links::META_KEY, true ); + $this->assertTrue( $report['complete'] ); + $this->assertCount( 'unlink' === $mode ? 5 : 6, $report['rows'] ); + foreach ( $report['rows'] as $row ) { $this->assertGreaterThan( 0, $row['checked'], $row['url'] ); } + } + } + public function test_release_fix_edit_overlapping_worker_cannot_restore_an_old_cursor(): void { + $this->require_core(); + $content = ''; + for ( $i = 0; $i < 6; ++$i ) { $content .= '' . $i . ''; } + $this->post( 10, $content ); + Links::start_scan(); Links::run_batch(); + $GLOBALS['functionalities_http_handler'] = static function () { + $GLOBALS['functionalities_http_handler'] = null; + $preview = \Functionalities\Features\Link_Health_Editor::preview( 10, 'https://example.test/overlap-0', 'unlink' ); + \Functionalities\Features\Link_Health_Editor::apply( 10, $preview['token'] ); + return array( 'response' => array( 'code' => 200 ) ); + }; + Links::run_batch(); + for ( $i = 0; $i < 4 && 'running' === Links::state()['status']; ++$i ) { Links::run_batch(); } + $report = get_post_meta( 10, Links::META_KEY, true ); + $this->assertSame( 'completed', Links::state()['status'] ); + $this->assertTrue( $report['complete'] ); + $this->assertCount( 5, $report['rows'] ); + foreach ( $report['rows'] as $row ) { $this->assertGreaterThan( 0, $row['checked'], $row['url'] ); } + } + public function test_release_fix_content_edits_dispatch_the_published_update_lifecycle(): void { + $this->require_core(); + $this->post( 10, 'Old' ); + $events = array(); + $GLOBALS['functionalities_action_handler'] = static function ( $hook, ...$args ) use ( &$events ) { $events[ $hook ] = $args; }; + $preview = \Functionalities\Features\Link_Health_Editor::preview( 10, 'https://example.test/old', 'unlink' ); + $this->assertIsArray( \Functionalities\Features\Link_Health_Editor::apply( 10, $preview['token'] ) ); + $this->assertArrayHasKey( 'transition_post_status', $events ); + $this->assertSame( array( 'publish', 'publish' ), array_slice( $events['transition_post_status'], 0, 2 ) ); + $this->assertArrayHasKey( 'edit_post', $events ); + $this->assertArrayHasKey( 'edit_post_post', $events ); + $this->assertArrayHasKey( 'post_updated', $events ); + unset( $GLOBALS['functionalities_action_handler'] ); + } + public function test_release_fix_export_streams_filtered_sources_once(): void { + for ( $id = 1; $id <= 60; ++$id ) { + $this->post( $id ); $rows = array(); + for ( $i = 0; $i < 100; ++$i ) { $url = 'https://example.test/' . $id . '-' . $i; $rows[ md5( $url ) ] = array( 'url' => $url, 'status' => 'broken', 'checked' => 1, 'chain' => array(), 'code' => 404 ); } + update_post_meta( $id, Links::META_KEY, array( 'rows' => $rows ) ); + } + $this->assertTrue( method_exists( \Functionalities\Features\Link_Health_Report::class, 'export_rows' ) ); + $count = 0; + foreach ( \Functionalities\Features\Link_Health_Report::export_rows( array( 'link_status' => 'broken' ) ) as $row ) { ++$count; $this->assertSame( 'broken', $row['status'] ); } + $this->assertSame( 6000, $count ); + $this->assertSame( 3, $GLOBALS['wpdb']->report_queries ); + } + public function test_release_fix_bulk_and_automatic_update_outcomes(): void { + $this->assertTrue( method_exists( Activity::class, 'upgrade_complete' ) ); + $manual = (object) array( 'skin' => new stdClass() ); + Activity::upgraded( array( 'destination_name' => 'good' ), array( 'plugin' => 'good/main.php', 'temp_backup' => array() ) ); + Activity::upgraded( new WP_Error( 'failed' ), array( 'plugin' => 'bad/main.php', 'temp_backup' => array() ) ); + Activity::upgrade_complete( $manual, array( 'action' => 'update', 'type' => 'plugin', 'bulk' => true, 'plugins' => array( 'good/main.php', 'bad/main.php' ) ) ); + $this->assertSame( array( 'good/main.php' ), array_column( Activity::entries(), 'target' ) ); + Activity::upgraded( array( 'destination_name' => 'theme' ), array( 'theme' => 'test-theme', 'temp_backup' => array() ) ); + Activity::upgrade_complete( $manual, array( 'action' => 'update', 'type' => 'theme', 'bulk' => true, 'themes' => array( 'test-theme' ) ) ); + $this->assertSame( 'theme_updated', Activity::entries()[1]['event'] ); + $ajax = (object) array( 'skin' => new WP_Ajax_Upgrader_Skin() ); + Activity::upgraded( true, array( 'plugin' => 'ajax/main.php' ) ); + Activity::upgrade_complete( $ajax, array( 'action' => 'update', 'type' => 'plugin', 'plugin' => 'ajax/main.php' ) ); + $this->assertSame( 'ajax/main.php', Activity::entries()[2]['target'] ); + $auto = (object) array( 'skin' => new Automatic_Upgrader_Skin() ); + Activity::upgraded( array( 'destination_name' => 'rolled-back' ), array( 'action' => 'update', 'type' => 'plugin', 'plugin' => 'rolled-back/main.php' ) ); + Activity::upgrade_complete( $auto, array( 'action' => 'update', 'type' => 'plugin', 'plugin' => 'rolled-back/main.php' ) ); + $this->assertCount( 3, Activity::entries(), 'Automatic results must wait for rollback checks.' ); + Activity::automatic_updates_complete( array( 'plugin' => array( (object) array( 'item' => (object) array( 'plugin' => 'rolled-back/main.php' ), 'result' => new WP_Error( 'rollback' ) ), (object) array( 'item' => (object) array( 'plugin' => 'auto-good/main.php' ), 'result' => true ) ) ) ); + $this->assertSame( array( 'good/main.php', 'test-theme', 'ajax/main.php', 'auto-good/main.php' ), array_column( Activity::entries(), 'target' ) ); + } + + public function test_release_fix_native_forms_reject_stale_or_missing_generations(): void { + foreach ( array( 'stale', 'missing', 'current' ) as $mode ) { + $process = proc_open( array( PHP_BINARY, __DIR__ . '/fixtures/link-health-native-worker.php', $mode ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes ); + fclose( $pipes[0] ); $output = stream_get_contents( $pipes[1] ); $error = stream_get_contents( $pipes[2] ); fclose( $pipes[1] ); fclose( $pipes[2] ); + $this->assertSame( 0, proc_close( $process ), $error ); + $result = json_decode( $output, true ); + $this->assertSame( 'current' === $mode ? 'redirect' : 'denied', $result['outcome'] ); + $this->assertSame( 'current' === $mode ? 'stopped' : 'running', $result['status'] ); + $this->assertTrue( $result['form_has_run'] ); + } + } + public function test_release_fix_concurrent_ignore_actions_both_survive(): void { + $this->require_core(); + $base = $this->base . '/ignore-concurrency'; mkdir( $base, 0700, true ); + $spec = array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ); + $first = proc_open( array( PHP_BINARY, __DIR__ . '/fixtures/link-health-ignore-worker.php', $base, 'a' ), $spec, $first_pipes ); + $deadline = microtime( true ) + 5; + while ( ! is_file( $base . '/read-a' ) && microtime( true ) < $deadline ) { usleep( 10000 ); clearstatcache(); } + $second = proc_open( array( PHP_BINARY, __DIR__ . '/fixtures/link-health-ignore-worker.php', $base, 'b' ), $spec, $second_pipes ); + foreach ( array( array( $first, $first_pipes ), array( $second, $second_pipes ) ) as $worker ) { + fclose( $worker[1][0] ); $output = stream_get_contents( $worker[1][1] ); $error = stream_get_contents( $worker[1][2] ); fclose( $worker[1][1] ); fclose( $worker[1][2] ); + $this->assertSame( 0, proc_close( $worker[0] ), $error ); + $this->assertTrue( json_decode( $output, true )['success'], $output ); + } + $this->assertEqualsCanonicalizing( array( md5( 'https://example.test/a' ), md5( 'https://example.test/b' ) ), json_decode( file_get_contents( $base . '/meta.json' ), true ) ); + } + + public function test_release_fix_recheck_merges_current_metadata_after_waiting(): void { + $result = $this->cache_worker( 'recheck' ); + $this->assertSame( 'ok', $result['result']['status'] ); + $this->assertSame( 'ok', $result['after_rows'][ md5( 'https://example.test/a' ) ]['status'] ); + } + public function test_release_fix_edit_reconciliation_reads_the_latest_source(): void { + $result = $this->cache_worker( 'refresh' ); + $this->assertTrue( $result['A_response']['refreshed'] ); + $this->assertSame( $result['current_hash'], $result['report_hash'] ); + $this->assertSame( array( 'https://example.test/a2', 'https://example.test/b2' ), $result['report_urls'] ); + } + private function cache_worker( string $mode ): array { + $this->require_core(); + $process = proc_open( array( PHP_BINARY, __DIR__ . '/fixtures/link-health-' . $mode . '-cache-worker.php' ), array( 0 => array( 'pipe', 'r' ), 1 => array( 'pipe', 'w' ), 2 => array( 'pipe', 'w' ) ), $pipes ); + fclose( $pipes[0] ); $output = stream_get_contents( $pipes[1] ); $error = stream_get_contents( $pipes[2] ); fclose( $pipes[1] ); fclose( $pipes[2] ); + $this->assertSame( 0, proc_close( $process ), $error ); + return json_decode( $output, true ); + } + } diff --git a/tests/bootstrap.php b/tests/bootstrap.php index ed28abe..4f55283 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -784,3 +784,14 @@ function wp_http_validate_url( $url ) { return in_array( wp_parse_url( $url, PHP_URL_SCHEME ), array( 'http', 'https' ), true ) && ! wp_parse_url( $url, PHP_URL_USER ) ? $url : false; } } + +if ( ! function_exists( 'wp_transition_post_status' ) ) { + function wp_transition_post_status( $new, $old, $post ) { do_action( 'transition_post_status', $new, $old, $post ); } +} +if ( ! class_exists( 'Automatic_Upgrader_Skin' ) ) { + class Automatic_Upgrader_Skin {} +} + +if ( ! class_exists( 'WP_Ajax_Upgrader_Skin' ) ) { + class WP_Ajax_Upgrader_Skin extends Automatic_Upgrader_Skin {} +} diff --git a/tests/fixtures/feature-assets-worker.php b/tests/fixtures/feature-assets-worker.php index b89b2cc..8d6d346 100644 --- a/tests/fixtures/feature-assets-worker.php +++ b/tests/fixtures/feature-assets-worker.php @@ -11,8 +11,9 @@ define( 'ABSPATH', $wp_root ); define( 'WPINC', 'wp-includes' ); define( 'FUNCTIONALITIES_VERSION', 'audit-test' ); +define( 'FUNCTIONALITIES_URL', 'https://example.test/wp-content/plugins/functionalities/' ); -function is_admin() { return 'admin' === $GLOBALS['mode']; } +function is_admin() { return 'admin' === $GLOBALS['mode'] || 0 === strpos( $GLOBALS['mode'], 'editor-' ); } function wp_installing() { return false; } function current_theme_supports() { return true; } function get_site_option() { return false; } @@ -54,7 +55,7 @@ function wp_has_noncharacters( $value ) { return (bool) preg_match( '/[\x{FDD0}- ); require dirname( __DIR__, 2 ) . '/includes/traits/trait-css-sanitizer.php'; require dirname( __DIR__, 2 ) . '/includes/features/class-components.php'; - if ( 'file' === $mode ) { + if ( 'file' === $mode || 'editor-file' === $mode ) { $file = new ReflectionProperty( \Functionalities\Features\Components::class, 'css_file_info' ); if ( PHP_VERSION_ID < 80100 ) { $file->setAccessible( true ); @@ -69,7 +70,10 @@ function wp_has_noncharacters( $value ) { return (bool) preg_match( '/[\x{FDD0}- add_action( 'wp_print_footer_scripts', '_wp_footer_scripts' ); \Functionalities\Features\Components::init(); ob_start(); - if ( 'admin' === $mode ) { + if ( 0 === strpos( $mode, 'editor-' ) ) { + do_action( 'enqueue_block_assets' ); + wp_print_styles(); + } elseif ( 'admin' === $mode ) { do_action( 'admin_enqueue_scripts', 'post.php' ); wp_print_styles(); } else { diff --git a/tests/fixtures/link-health-ignore-worker.php b/tests/fixtures/link-health-ignore-worker.php new file mode 100644 index 0000000..aba4eb6 --- /dev/null +++ b/tests/fixtures/link-health-ignore-worker.php @@ -0,0 +1,32 @@ + $base . '/private' ); +$GLOBALS['functionalities_test_options'] = array( 'functionalities_data_directory_key' => 'testutilityprivate123', 'functionalities_link_health' => array( 'enabled' => true ) ); +$GLOBALS['functionalities_test_options'][ Functionalities\Storage\Data_Directory::KEY_OPTION ] = 'testutilityprivate123'; +$GLOBALS['functionalities_test_caps']['manage_options'] = true; +$post = new WP_Post(); $post->ID = 1; $post->post_content = 'AB'; +$GLOBALS['functionalities_test_posts'][1] = $post; +$result = Functionalities\Features\Link_Health::set_ignored( 1, 'https://example.test/' . $actor, true ); +echo json_encode( array( 'success' => true === $result ) ); diff --git a/tests/fixtures/link-health-native-worker.php b/tests/fixtures/link-health-native-worker.php new file mode 100644 index 0000000..e98efff --- /dev/null +++ b/tests/fixtures/link-health-native-worker.php @@ -0,0 +1,32 @@ +setAccessible( true ); } + return $reflection->invoke( is_object( $target ) ? $target : null, ...$args ); +} + +/** Native forms use the same generation checks as AJAX. */ +require dirname( __DIR__, 2 ) . '/vendor/autoload.php'; +require dirname( __DIR__ ) . '/bootstrap.php'; +require dirname( __DIR__ ) . '/UtilityModulesTest.php'; +require dirname( __DIR__, 2 ) . '/includes/admin/trait-admin-utilities-ui.php'; +function esc_html__( $text, $domain = '' ) { return esc_html( $text ); } +function check_admin_referer( $action ) { return true; } +function admin_url( $path ) { return $path; } +function wp_safe_redirect( $url ) { throw new RuntimeException( 'redirect' ); } +function wp_die( $message, $title = '', $args = array() ) { throw new RuntimeException( 'denied' ); } +function wp_nonce_field( $action ) { echo ''; } +class NativeReleaseHarness { use Functionalities\Admin\Admin_Utilities_UI; } +use Functionalities\Features\Link_Health as Links; +$test = new UtilityModulesTest( 'test_native' ); +release_invoke( $test, 'setUp' ); +try { + $old = Links::start_scan(); Links::stop_scan( $old['run'] ); $new = Links::start_scan(); + $_POST = array( 'operation' => 'stop', 'run' => 'current' === $argv[1] ? $new['run'] : $old['run'] ); + if ( 'missing' === $argv[1] ) { unset( $_POST['run'] ); } + try { NativeReleaseHarness::handle_link_health_action(); } catch ( RuntimeException $error ) { $outcome = $error->getMessage(); } + ob_start(); + release_invoke( NativeReleaseHarness::class, 'link_health_button', 'stop', 'Stop' ); + $html = ob_get_clean(); + echo json_encode( array( 'outcome' => $outcome, 'status' => Links::state()['status'], 'form_has_run' => false !== strpos( $html, 'name="run" value="' . $new['run'] . '"' ) ) ); +} finally { release_invoke( $test, 'tearDown' ); } diff --git a/tests/fixtures/link-health-recheck-cache-worker.php b/tests/fixtures/link-health-recheck-cache-worker.php new file mode 100644 index 0000000..358fbbe --- /dev/null +++ b/tests/fixtures/link-health-recheck-cache-worker.php @@ -0,0 +1,37 @@ +setAccessible( true ); } + return $reflection->invoke( is_object( $target ) ? $target : null, ...$args ); +} + +$GLOBALS['repro_db_meta']=[]; $GLOBALS['repro_meta_cache']=[]; +function get_post_meta($id,$key='',$single=false){ + if(!array_key_exists($id,$GLOBALS['repro_meta_cache'])) $GLOBALS['repro_meta_cache'][$id]=$GLOBALS['repro_db_meta'][$id]??[]; + return $GLOBALS['repro_meta_cache'][$id][$key]??''; +} +function update_post_meta($id,$key,$value){$GLOBALS['repro_db_meta'][$id][$key]=$value;unset($GLOBALS['repro_meta_cache'][$id]);return true;} +function wp_cache_delete($id,$group=''){if($group==='post_meta')unset($GLOBALS['repro_meta_cache'][$id]);return true;} +require dirname( __DIR__, 2 ) . '/vendor/autoload.php'; +require dirname( __DIR__ ) . '/bootstrap.php'; +require dirname( __DIR__ ) . '/UtilityModulesTest.php'; +use Functionalities\Features\Link_Health as Links; +use Functionalities\Storage\Data_Directory as Directory; +use Functionalities\Storage\Atomic_JSON_Store as Store; +$t=new UtilityModulesTest('test_batch_resumes_within_one_post_and_then_completes'); +release_invoke($t,'setUp'); +try { + $post=release_invoke($t,'post',10,'AB'); + $GLOBALS['functionalities_http_handler']=static function(){return ['response'=>['code'=>404]];}; + Links::start_scan(); Links::run_batch(); + $GLOBALS['functionalities_http_handler']=static function(){ + Store::update(Directory::file('link-health-state.json'),static function($state){ + $GLOBALS['repro_db_meta'][10][Links::META_KEY]['rows'][md5('https://example.test/a')]['status']='ok'; + $GLOBALS['repro_db_meta'][10][Links::META_KEY]['rows'][md5('https://example.test/a')]['code']=200; + return $state; + }); + return ['response'=>['code'=>200]]; + }; + $result=Links::recheck(10,'https://example.test/b'); + echo json_encode(['result'=>$result,'after_rows'=>get_post_meta(10,Links::META_KEY,true)['rows']],JSON_PRETTY_PRINT),"\n"; +} finally {release_invoke($t,'tearDown');} diff --git a/tests/fixtures/link-health-refresh-cache-worker.php b/tests/fixtures/link-health-refresh-cache-worker.php new file mode 100644 index 0000000..5500834 --- /dev/null +++ b/tests/fixtures/link-health-refresh-cache-worker.php @@ -0,0 +1,37 @@ +setAccessible( true ); } + return $reflection->invoke( is_object( $target ) ? $target : null, ...$args ); +} + +$GLOBALS['repro_post_cache']=[]; +function get_post($id){if(is_object($id))return $id;if(!isset($GLOBALS['repro_post_cache'][$id])&&isset($GLOBALS['functionalities_test_posts'][$id]))$GLOBALS['repro_post_cache'][$id]=clone $GLOBALS['functionalities_test_posts'][$id];return $GLOBALS['repro_post_cache'][$id]??null;} +function wp_cache_delete($id,$group=''){if($group==='posts')unset($GLOBALS['repro_post_cache'][$id]);return true;} +function clean_post_cache($id){wp_cache_delete($id,'posts');} +require dirname( __DIR__, 2 ) . '/vendor/autoload.php'; +require dirname( __DIR__ ) . '/bootstrap.php'; +require dirname( __DIR__ ) . '/UtilityModulesTest.php'; +use Functionalities\Features\Link_Health as Links; +use Functionalities\Features\Link_Health_Editor as Editor; +$t=new UtilityModulesTest('test_batch_resumes_within_one_post_and_then_completes'); +release_invoke($t,'setUp'); +try { + $post=release_invoke($t,'post',10,'AB'); + Links::start_scan(); Links::run_batch(); + $GLOBALS['functionalities_action_handler']=static function($hook){ + if($hook!=='save_post')return; + $GLOBALS['functionalities_action_handler']=null; + $cacheA=$GLOBALS['repro_post_cache']; + $GLOBALS['repro_post_cache']=[]; + $GLOBALS['functionalities_test_user_id']=8; + $preview=Editor::preview(10,'https://example.test/b','replace','https://example.test/b2'); + $apply=Editor::apply(10,$preview['token']); + $GLOBALS['functionalities_test_user_id']=7; + $GLOBALS['repro_post_cache']=$cacheA; + }; + $preview=Editor::preview(10,'https://example.test/a','replace','https://example.test/a2'); + $apply=Editor::apply(10,$preview['token']); + $saved=get_post_meta(10,Links::META_KEY,true); + echo json_encode(['A_response'=>$apply,'DB_content'=>$GLOBALS['functionalities_test_posts'][10]->post_content,'report_urls'=>array_column($saved['rows'],'url'),'report_hash'=>$saved['hash'],'current_hash'=>Links::content_hash($GLOBALS['functionalities_test_posts'][10])],JSON_PRETTY_PRINT),"\n"; +}finally{release_invoke($t,'tearDown');} diff --git a/tests/link-health-live.test.mjs b/tests/link-health-live.test.mjs index fb54e7f..3186230 100644 --- a/tests/link-health-live.test.mjs +++ b/tests/link-health-live.test.mjs @@ -113,3 +113,13 @@ test('live requests preserve applied filters and hold report rows while an edit await h.reply(2, progress('running', {phase: 'checking'}), 'Refreshed report'); assert.equal(h.report.innerHTML, 'Refreshed report'); }); + +test('AJAX controls use the live run instead of an old native form generation', async () => { + const h = harness(progress('completed', {run: 'scan-old'})); + h.forms.stop.fields.run = 'scan-old'; + await h.submit('start'); + await h.reply(0, progress('running', {run: 'scan-new'})); + await h.submit('stop'); + assert.equal(h.requests[1].fields.operation, 'stop'); + assert.equal(h.requests[1].fields.run, 'scan-new'); +});