From a04ae51263d64a5278854def26afac6a2b720258 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 6 Jan 2026 23:57:19 +0200 Subject: [PATCH 01/96] feat: add Admin Bar integration for Code Snippets with quick links and safe mode support --- src/php/class-admin-bar.php | 337 ++++++++++++++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 src/php/class-admin-bar.php diff --git a/src/php/class-admin-bar.php b/src/php/class-admin-bar.php new file mode 100644 index 00000000..ce6ff026 --- /dev/null +++ b/src/php/class-admin-bar.php @@ -0,0 +1,337 @@ + .ab-item, + #wpadminbar .code-snippets-safe-mode.ab-item { + background: #b32d2e; + color: #fff; + font-weight: 600; + } + #wpadminbar .code-snippets-safe-mode:hover > .ab-item, + #wpadminbar .code-snippets-safe-mode.hover > .ab-item { + background: #d63638; + color: #fff; + } + '; + + wp_add_inline_style( 'admin-bar', $css ); + } + + /** + * Register admin bar nodes. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + public function register_nodes( WP_Admin_Bar $wp_admin_bar ): void { + if ( ! is_admin_bar_showing() ) { + return; + } + + if ( ! apply_filters( 'code_snippets/admin_bar/enabled', true ) ) { + return; + } + + if ( ! code_snippets()->current_user_can() ) { + return; + } + + $title = sprintf( + '%s', + esc_html__( 'Snippets', 'code-snippets' ) + ); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID, + 'title' => wp_kses( $title, [ 'span' => [ 'class' => [] ] ] ), + 'href' => code_snippets()->get_menu_url( 'manage' ), + ] + ); + + $this->add_safe_mode_nodes( $wp_admin_bar ); + $this->add_quick_links( $wp_admin_bar ); + $this->add_snippet_listings( $wp_admin_bar ); + } + + /** + * Add menu items for safe mode, if active. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + private function add_safe_mode_nodes( WP_Admin_Bar $wp_admin_bar ): void { + if ( ! code_snippets()->evaluate_functions->is_safe_mode_active() ) { + return; + } + + $wp_admin_bar->add_node( + [ + 'id' => self::SAFE_MODE_NODE_ID, + 'title' => esc_html__( 'Snippets Safe Mode Active', 'code-snippets' ), + 'href' => 'https://help.codesnippets.pro/article/12-safe-mode', + 'parent' => 'top-secondary', + 'meta' => [ + 'class' => 'code-snippets-safe-mode', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ], + ] + ); + + $wp_admin_bar->add_node( + [ + 'id' => self::SAFE_MODE_NODE_ID . '-submenu', + 'title' => esc_html__( 'Safe Mode Active', 'code-snippets' ), + 'href' => 'https://help.codesnippets.pro/article/12-safe-mode', + 'parent' => self::ROOT_NODE_ID, + 'meta' => [ + 'class' => 'code-snippets-safe-mode', + 'target' => '_blank', + 'rel' => 'noopener noreferrer', + ], + ] + ); + } + + /** + * Add quick links to common Code Snippets screens. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + private function add_quick_links( WP_Admin_Bar $wp_admin_bar ): void { + $plugin = code_snippets(); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-manage', + 'title' => esc_html_x( 'Manage', 'snippets', 'code-snippets' ), + 'href' => $plugin->get_menu_url( 'manage' ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + $statuses = [ + 'all' => _x( 'All Snippets', 'snippets', 'code-snippets' ), + 'active' => _x( 'Active Snippets', 'snippets', 'code-snippets' ), + 'inactive' => _x( 'Inactive Snippets', 'snippets', 'code-snippets' ), + ]; + + foreach ( $statuses as $status => $label ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . "-status-$status", + 'title' => esc_html( $label ), + 'href' => esc_url( add_query_arg( 'status', $status, $plugin->get_menu_url( 'manage' ) ) ), + 'parent' => self::ROOT_NODE_ID . '-manage', + ] + ); + } + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-add', + 'title' => esc_html_x( 'Add New', 'snippet', 'code-snippets' ), + 'href' => $plugin->get_menu_url( 'add' ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + $types = [ + 'php' => _x( 'Function', 'snippet type', 'code-snippets' ), + 'html' => _x( 'Content', 'snippet type', 'code-snippets' ), + 'css' => _x( 'Style', 'snippet type', 'code-snippets' ), + 'js' => _x( 'Script', 'snippet type', 'code-snippets' ), + 'cond' => _x( 'Condition', 'snippet type', 'code-snippets' ), + ]; + + $types = array_intersect_key( $types, array_flip( Snippet::get_types() ) ); + + foreach ( $types as $type => $label ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . "-add-$type", + 'title' => esc_html( $label ), + 'href' => esc_url( add_query_arg( 'type', $type, $plugin->get_menu_url( 'add' ) ) ), + 'parent' => self::ROOT_NODE_ID . '-add', + ] + ); + } + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-import', + 'title' => esc_html_x( 'Import', 'snippets', 'code-snippets' ), + 'href' => $plugin->get_menu_url( 'import' ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + $settings_context = Settings\are_settings_unified() ? 'network' : 'admin'; + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-settings', + 'title' => esc_html_x( 'Settings', 'snippets', 'code-snippets' ), + 'href' => $plugin->get_menu_url( 'settings', $settings_context ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + } + + /** + * Add a list of snippets under the active and inactive statuses. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + private function add_snippet_listings( WP_Admin_Bar $wp_admin_bar ): void { + $max_items = (int) apply_filters( 'code_snippets/admin_bar/snippet_limit', 25 ); + if ( $max_items < 1 ) { + return; + } + + $plugin = code_snippets(); + + $snippets = array_filter( + get_snippets(), + static function ( Snippet $snippet ): bool { + return ! $snippet->is_trashed(); + } + ); + + $active_snippets = array_values( + array_filter( + $snippets, + static function ( Snippet $snippet ): bool { + return $snippet->active; + } + ) + ); + + $inactive_snippets = array_values( + array_filter( + $snippets, + static function ( Snippet $snippet ): bool { + return ! $snippet->active; + } + ) + ); + + usort( + $active_snippets, + static function ( Snippet $a, Snippet $b ): int { + return strcasecmp( $a->display_name, $b->display_name ); + } + ); + + usort( + $inactive_snippets, + static function ( Snippet $a, Snippet $b ): int { + return strcasecmp( $a->display_name, $b->display_name ); + } + ); + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-active-snippets', + 'title' => sprintf( + /* translators: %d: number of active snippets. */ + esc_html__( 'Active Snippets (%d)', 'code-snippets' ), + count( $active_snippets ) + ), + 'href' => esc_url( add_query_arg( 'status', 'active', $plugin->get_menu_url( 'manage' ) ) ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + foreach ( array_slice( $active_snippets, 0, $max_items ) as $snippet ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-snippet-' . $snippet->id, + 'title' => esc_html( $snippet->display_name ), + 'href' => esc_url( $plugin->get_snippet_edit_url( $snippet->id ) ), + 'parent' => self::ROOT_NODE_ID . '-active-snippets', + ] + ); + } + + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-inactive-snippets', + 'title' => sprintf( + /* translators: %d: number of inactive snippets. */ + esc_html__( 'Inactive Snippets (%d)', 'code-snippets' ), + count( $inactive_snippets ) + ), + 'href' => esc_url( add_query_arg( 'status', 'inactive', $plugin->get_menu_url( 'manage' ) ) ), + 'parent' => self::ROOT_NODE_ID, + ] + ); + + foreach ( array_slice( $inactive_snippets, 0, $max_items ) as $snippet ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-snippet-' . $snippet->id, + 'title' => esc_html( $snippet->display_name ), + 'href' => esc_url( $plugin->get_snippet_edit_url( $snippet->id ) ), + 'parent' => self::ROOT_NODE_ID . '-inactive-snippets', + ] + ); + } + } +} From d22434238f4ee61aabfd422ff7d7b93ef287f064 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 6 Jan 2026 23:57:23 +0200 Subject: [PATCH 02/96] feat: add Admin Bar integration to the Plugin class --- src/php/class-plugin.php | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index 7f5390c6..318aeb8b 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -84,6 +84,13 @@ class Plugin { */ public Snippet_Handler_Registry $snippet_handler_registry; + /** + * Admin bar integration class. + * + * @var Admin_Bar + */ + public Admin_Bar $admin_bar; + /** * Class constructor * @@ -121,6 +128,11 @@ public function load_plugin() { $this->evaluate_content = new Evaluate_Content( $this->db ); $this->evaluate_functions = new Evaluate_Functions( $this->db ); + // Admin bar integration. + require_once $includes_path . '/class-admin-bar.php'; + $this->admin_bar = new Admin_Bar(); + $this->admin_bar->register_hooks(); + // CodeMirror editor functions. require_once $includes_path . '/editor.php'; From e3da8f4e64c8403f3f27ebc3d2b5fcfbdca0b386 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 15:59:07 +0200 Subject: [PATCH 03/96] feat: update snippet type labels --- src/php/class-admin-bar.php | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/php/class-admin-bar.php b/src/php/class-admin-bar.php index ce6ff026..a0fab814 100644 --- a/src/php/class-admin-bar.php +++ b/src/php/class-admin-bar.php @@ -191,11 +191,11 @@ private function add_quick_links( WP_Admin_Bar $wp_admin_bar ): void { ); $types = [ - 'php' => _x( 'Function', 'snippet type', 'code-snippets' ), - 'html' => _x( 'Content', 'snippet type', 'code-snippets' ), - 'css' => _x( 'Style', 'snippet type', 'code-snippets' ), - 'js' => _x( 'Script', 'snippet type', 'code-snippets' ), - 'cond' => _x( 'Condition', 'snippet type', 'code-snippets' ), + 'php' => _x( 'Functions (PHP)', 'snippet type', 'code-snippets' ), + 'html' => _x( 'Content (HTML)', 'snippet type', 'code-snippets' ), + 'css' => _x( 'Style (CSS)', 'snippet type', 'code-snippets' ), + 'js' => _x( 'Script (JS)', 'snippet type', 'code-snippets' ), + 'cond' => _x( 'Condition (COND)', 'snippet type', 'code-snippets' ), ]; $types = array_intersect_key( $types, array_flip( Snippet::get_types() ) ); From 8f9813617e2c0d2ee858ba28bd898ef6dcfb4f2e Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 19:29:01 +0200 Subject: [PATCH 04/96] feat: add admin bar snippet limit setting --- src/php/settings/settings-fields.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/php/settings/settings-fields.php b/src/php/settings/settings-fields.php index 72262cc7..77466d79 100644 --- a/src/php/settings/settings-fields.php +++ b/src/php/settings/settings-fields.php @@ -29,6 +29,7 @@ function get_default_settings(): array { 'enable_description' => true, 'visual_editor_rows' => 5, 'list_order' => 'priority-asc', + 'admin_bar_snippet_limit' => 20, 'disable_prism' => false, 'hide_upgrade_menu' => false, 'complete_uninstall' => false, @@ -146,6 +147,14 @@ function get_settings_fields(): array { 'modified-asc' => __( 'Modified (oldest first)', 'code-snippets' ), ], ], + 'admin_bar_snippet_limit' => [ + 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'Number of snippets to show in the admin bar Active/Inactive menus before paginating.', 'code-snippets' ), + 'label' => __( 'snippets', 'code-snippets' ), + 'min' => 1, + 'max' => 100, + ], 'disable_prism' => [ 'name' => __( 'Disable Syntax Highlighter', 'code-snippets' ), 'type' => 'checkbox', From bc7776493534d59ed9d89b94918c07d629caf682 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 19:29:11 +0200 Subject: [PATCH 05/96] feat: enhance admin bar with pagination and snippet limit features --- src/php/class-admin-bar.php | 289 +++++++++++++++++++++++++++++++----- 1 file changed, 252 insertions(+), 37 deletions(-) diff --git a/src/php/class-admin-bar.php b/src/php/class-admin-bar.php index a0fab814..9bc6ae09 100644 --- a/src/php/class-admin-bar.php +++ b/src/php/class-admin-bar.php @@ -2,6 +2,7 @@ namespace Code_Snippets; +use Code_Snippets\REST_API\Snippets_REST_Controller; use WP_Admin_Bar; /** @@ -18,6 +19,20 @@ class Admin_Bar { */ private const ROOT_NODE_ID = 'code-snippets'; + /** + * Active snippets pagination query arg. + * + * @var string + */ + private const ACTIVE_PAGE_QUERY_ARG = 'code_snippets_ab_active_page'; + + /** + * Inactive snippets pagination query arg. + * + * @var string + */ + private const INACTIVE_PAGE_QUERY_ARG = 'code_snippets_ab_inactive_page'; + /** * Safe mode node ID. * @@ -25,6 +40,20 @@ class Admin_Bar { */ private const SAFE_MODE_NODE_ID = 'code-snippets-safe-mode'; + /** + * Script handle. + * + * @var string + */ + private const SCRIPT_HANDLE = 'code-snippets-admin-bar'; + + /** + * Stylesheet handle. + * + * @var string + */ + private const STYLE_HANDLE = 'code-snippets-admin-bar'; + /** * Register WordPress hooks. * @@ -33,37 +62,53 @@ class Admin_Bar { public function register_hooks(): void { add_action( 'admin_bar_menu', [ $this, 'register_nodes' ], 80 ); - add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_styles' ] ); - add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_styles' ] ); + add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); + add_action( 'wp_enqueue_scripts', [ $this, 'enqueue_assets' ] ); } /** - * Enqueue styles for admin bar nodes. + * Enqueue scripts and styles for admin bar nodes. * * @return void */ - public function enqueue_styles(): void { + public function enqueue_assets(): void { if ( ! is_admin_bar_showing() ) { return; } wp_enqueue_style( 'admin-bar' ); + wp_enqueue_style( 'dashicons' ); - $css = ' - #wpadminbar .code-snippets-safe-mode > .ab-item, - #wpadminbar .code-snippets-safe-mode.ab-item { - background: #b32d2e; - color: #fff; - font-weight: 600; - } - #wpadminbar .code-snippets-safe-mode:hover > .ab-item, - #wpadminbar .code-snippets-safe-mode.hover > .ab-item { - background: #d63638; - color: #fff; - } - '; + wp_enqueue_style( + self::STYLE_HANDLE, + plugins_url( 'dist/admin-bar.css', PLUGIN_FILE ), + [ 'admin-bar' ], + PLUGIN_VERSION + ); - wp_add_inline_style( 'admin-bar', $css ); + wp_enqueue_script( + self::SCRIPT_HANDLE, + plugins_url( 'dist/admin-bar.js', PLUGIN_FILE ), + [], + PLUGIN_VERSION, + true + ); + + wp_localize_script( + self::SCRIPT_HANDLE, + 'CODE_SNIPPETS_ADMIN_BAR', + [ + 'restUrl' => esc_url_raw( rest_url( Snippets_REST_Controller::get_base_route() ) ), + 'nonce' => wp_create_nonce( 'wp_rest' ), + 'perPage' => $this->get_snippet_limit(), + 'isNetwork' => is_network_admin(), + 'excludeTypes' => [ 'cond' ], + 'snippetPlaceholder' => esc_html__( 'Snippet #%d', 'code-snippets' ), + 'editUrlBase' => code_snippets()->get_menu_url( 'edit' ), + 'activeNodeId' => 'wp-admin-bar-' . self::ROOT_NODE_ID . '-active-snippets', + 'inactiveNodeId' => 'wp-admin-bar-' . self::ROOT_NODE_ID . '-inactive-snippets', + ] + ); } /** @@ -86,33 +131,48 @@ public function register_nodes( WP_Admin_Bar $wp_admin_bar ): void { return; } + $is_safe_mode_active = code_snippets()->evaluate_functions->is_safe_mode_active(); + + $icon = ''; $title = sprintf( - '%s', + '%s%s', + $icon, esc_html__( 'Snippets', 'code-snippets' ) ); $wp_admin_bar->add_node( [ 'id' => self::ROOT_NODE_ID, - 'title' => wp_kses( $title, [ 'span' => [ 'class' => [] ] ] ), + 'title' => $title, 'href' => code_snippets()->get_menu_url( 'manage' ), ] ); - $this->add_safe_mode_nodes( $wp_admin_bar ); + $this->add_safe_mode_nodes( $wp_admin_bar, $is_safe_mode_active ); $this->add_quick_links( $wp_admin_bar ); $this->add_snippet_listings( $wp_admin_bar ); + $this->add_safe_mode_link( $wp_admin_bar ); + } + + /** + * Retrieve the Code Snippets scissors SVG icon markup. + * + * @return string + */ + private function get_scissors_svg(): string { + return ''; } /** - * Add menu items for safe mode, if active. + * Add menu item for safe mode status. * * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * @param bool $is_safe_mode_active Whether safe mode is active. * * @return void */ - private function add_safe_mode_nodes( WP_Admin_Bar $wp_admin_bar ): void { - if ( ! code_snippets()->evaluate_functions->is_safe_mode_active() ) { + private function add_safe_mode_nodes( WP_Admin_Bar $wp_admin_bar, bool $is_safe_mode_active ): void { + if ( ! $is_safe_mode_active ) { return; } @@ -120,21 +180,35 @@ private function add_safe_mode_nodes( WP_Admin_Bar $wp_admin_bar ): void { [ 'id' => self::SAFE_MODE_NODE_ID, 'title' => esc_html__( 'Snippets Safe Mode Active', 'code-snippets' ), - 'href' => 'https://help.codesnippets.pro/article/12-safe-mode', + 'href' => 'https://snipco.de/safe-mode', 'parent' => 'top-secondary', 'meta' => [ - 'class' => 'code-snippets-safe-mode', + 'class' => 'code-snippets-safe-mode code-snippets-safe-mode-active', 'target' => '_blank', 'rel' => 'noopener noreferrer', ], ] ); + } + + /** + * Add a safe mode documentation link under the Code Snippets menu. + * + * @param WP_Admin_Bar $wp_admin_bar Admin bar instance. + * + * @return void + */ + private function add_safe_mode_link( WP_Admin_Bar $wp_admin_bar ): void { + $title = sprintf( + ' %s', + esc_html__( 'Safe Mode', 'code-snippets' ) + ); $wp_admin_bar->add_node( [ - 'id' => self::SAFE_MODE_NODE_ID . '-submenu', - 'title' => esc_html__( 'Safe Mode Active', 'code-snippets' ), - 'href' => 'https://help.codesnippets.pro/article/12-safe-mode', + 'id' => self::ROOT_NODE_ID . '-safe-mode-doc', + 'title' => $title, + 'href' => 'https://snipco.de/safe-mode', 'parent' => self::ROOT_NODE_ID, 'meta' => [ 'class' => 'code-snippets-safe-mode', @@ -154,6 +228,8 @@ private function add_safe_mode_nodes( WP_Admin_Bar $wp_admin_bar ): void { */ private function add_quick_links( WP_Admin_Bar $wp_admin_bar ): void { $plugin = code_snippets(); + $is_licensed = $plugin->licensing->is_licensed(); + $upgrade_url = self_admin_url( 'admin.php?page=code_snippets_upgrade' ); $wp_admin_bar->add_node( [ @@ -199,14 +275,22 @@ private function add_quick_links( WP_Admin_Bar $wp_admin_bar ): void { ]; $types = array_intersect_key( $types, array_flip( Snippet::get_types() ) ); + $pro_types = [ 'css', 'js', 'cond' ]; foreach ( $types as $type => $label ) { + $is_disabled = in_array( $type, $pro_types, true ) && ! $is_licensed; + + $url = $is_disabled ? + $upgrade_url : + add_query_arg( 'type', $type, $plugin->get_menu_url( 'add' ) ); + $wp_admin_bar->add_node( [ 'id' => self::ROOT_NODE_ID . "-add-$type", 'title' => esc_html( $label ), - 'href' => esc_url( add_query_arg( 'type', $type, $plugin->get_menu_url( 'add' ) ) ), + 'href' => esc_url( $url ), 'parent' => self::ROOT_NODE_ID . '-add', + 'meta' => $is_disabled ? [ 'class' => 'code-snippets-disabled' ] : [], ] ); } @@ -240,8 +324,8 @@ private function add_quick_links( WP_Admin_Bar $wp_admin_bar ): void { * @return void */ private function add_snippet_listings( WP_Admin_Bar $wp_admin_bar ): void { - $max_items = (int) apply_filters( 'code_snippets/admin_bar/snippet_limit', 25 ); - if ( $max_items < 1 ) { + $items_per_page = $this->get_snippet_limit(); + if ( $items_per_page < 1 ) { return; } @@ -250,7 +334,7 @@ private function add_snippet_listings( WP_Admin_Bar $wp_admin_bar ): void { $snippets = array_filter( get_snippets(), static function ( Snippet $snippet ): bool { - return ! $snippet->is_trashed(); + return ! $snippet->is_trashed() && ! $snippet->is_condition(); } ); @@ -286,6 +370,21 @@ static function ( Snippet $a, Snippet $b ): int { } ); + $active_page = isset( $_GET[ self::ACTIVE_PAGE_QUERY_ARG ] ) ? absint( $_GET[ self::ACTIVE_PAGE_QUERY_ARG ] ) : 1; + $inactive_page = isset( $_GET[ self::INACTIVE_PAGE_QUERY_ARG ] ) ? absint( $_GET[ self::INACTIVE_PAGE_QUERY_ARG ] ) : 1; + + $active_total_pages = max( 1, (int) ceil( count( $active_snippets ) / $items_per_page ) ); + $inactive_total_pages = max( 1, (int) ceil( count( $inactive_snippets ) / $items_per_page ) ); + + $active_page = max( 1, min( $active_page, $active_total_pages ) ); + $inactive_page = max( 1, min( $inactive_page, $inactive_total_pages ) ); + + $active_offset = ( $active_page - 1 ) * $items_per_page; + $inactive_offset = ( $inactive_page - 1 ) * $items_per_page; + + $active_page_snippets = array_slice( $active_snippets, $active_offset, $items_per_page ); + $inactive_page_snippets = array_slice( $inactive_snippets, $inactive_offset, $items_per_page ); + $wp_admin_bar->add_node( [ 'id' => self::ROOT_NODE_ID . '-active-snippets', @@ -299,13 +398,25 @@ static function ( Snippet $a, Snippet $b ): int { ] ); - foreach ( array_slice( $active_snippets, 0, $max_items ) as $snippet ) { + if ( $active_total_pages > 1 ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-active-pagination', + 'title' => $this->get_pagination_controls_html( 'active', $active_page, $active_total_pages, self::ACTIVE_PAGE_QUERY_ARG ), + 'parent' => self::ROOT_NODE_ID . '-active-snippets', + 'meta' => [ 'class' => 'code-snippets-pagination-node' ], + ] + ); + } + + foreach ( $active_page_snippets as $snippet ) { $wp_admin_bar->add_node( [ 'id' => self::ROOT_NODE_ID . '-snippet-' . $snippet->id, - 'title' => esc_html( $snippet->display_name ), + 'title' => esc_html( $this->format_snippet_title( $snippet ) ), 'href' => esc_url( $plugin->get_snippet_edit_url( $snippet->id ) ), 'parent' => self::ROOT_NODE_ID . '-active-snippets', + 'meta' => [ 'class' => 'code-snippets-snippet-item' ], ] ); } @@ -323,15 +434,119 @@ static function ( Snippet $a, Snippet $b ): int { ] ); - foreach ( array_slice( $inactive_snippets, 0, $max_items ) as $snippet ) { + if ( $inactive_total_pages > 1 ) { + $wp_admin_bar->add_node( + [ + 'id' => self::ROOT_NODE_ID . '-inactive-pagination', + 'title' => $this->get_pagination_controls_html( 'inactive', $inactive_page, $inactive_total_pages, self::INACTIVE_PAGE_QUERY_ARG ), + 'parent' => self::ROOT_NODE_ID . '-inactive-snippets', + 'meta' => [ 'class' => 'code-snippets-pagination-node' ], + ] + ); + } + + foreach ( $inactive_page_snippets as $snippet ) { $wp_admin_bar->add_node( [ 'id' => self::ROOT_NODE_ID . '-snippet-' . $snippet->id, - 'title' => esc_html( $snippet->display_name ), + 'title' => esc_html( $this->format_snippet_title( $snippet ) ), 'href' => esc_url( $plugin->get_snippet_edit_url( $snippet->id ) ), 'parent' => self::ROOT_NODE_ID . '-inactive-snippets', + 'meta' => [ 'class' => 'code-snippets-snippet-item' ], ] ); } } + + /** + * Build an admin bar snippet title including type prefix. + * + * @param Snippet $snippet Snippet object. + * + * @return string + */ + private function format_snippet_title( Snippet $snippet ): string { + return sprintf( '(%s) %s', strtoupper( $snippet->type ), $snippet->display_name ); + } + + /** + * Retrieve the number of snippets to show per page in the admin bar. + * + * @return int + */ + private function get_snippet_limit(): int { + $limit = (int) Settings\get_setting( 'general', 'admin_bar_snippet_limit' ); + + if ( $limit < 1 ) { + $limit = 20; + } + + return max( 1, (int) apply_filters( 'code_snippets/admin_bar/snippet_limit', $limit ) ); + } + + /** + * Build pagination controls HTML for a snippet listing submenu. + * + * @param string $status Snippet status: "active" or "inactive". + * @param int $page Current page. + * @param int $total_pages Total pages. + * @param string $page_query_arg Query arg used for progressive enhancement. + * + * @return string + */ + private function get_pagination_controls_html( string $status, int $page, int $total_pages, string $page_query_arg ): string { + $first_url = remove_query_arg( $page_query_arg ); + $prev_url = $page > 2 ? add_query_arg( $page_query_arg, $page - 1 ) : remove_query_arg( $page_query_arg ); + $next_url = add_query_arg( $page_query_arg, $page + 1 ); + $last_url = add_query_arg( $page_query_arg, $total_pages ); + + $disabled_first = $page <= 1 ? 'true' : 'false'; + $disabled_prev = $page <= 1 ? 'true' : 'false'; + $disabled_next = $page >= $total_pages ? 'true' : 'false'; + $disabled_last = $page >= $total_pages ? 'true' : 'false'; + + $html = sprintf( + '' . + '«' . + '‹ %6$s' . + '%7$s (%2$d/%3$d)' . + '%12$s ›' . + '»' . + '', + esc_attr( $status ), + $page, + $total_pages, + esc_url( $first_url ), + esc_url( $prev_url ), + esc_html__( 'Back', 'code-snippets' ), + esc_html__( 'Page', 'code-snippets' ), + esc_url( $next_url ), + $disabled_first, + $disabled_prev, + $disabled_next, + esc_html__( 'Next', 'code-snippets' ), + esc_url( $last_url ), + $disabled_last, + esc_attr( $page_query_arg ) + ); + + return wp_kses( + $html, + [ + 'span' => [ + 'class' => [], + 'data-status' => [], + 'data-page' => [], + 'data-total-pages' => [], + 'data-query-arg' => [], + ], + 'a' => [ + 'class' => [], + 'href' => [], + 'data-action' => [], + 'aria-disabled' => [], + ], + ] + ); + } } From ce40ffad6aece2b2f7e0fbd85d12e2799cfac35e Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 19:29:15 +0200 Subject: [PATCH 06/96] feat: add filtering and sorting options to snippets REST API endpoint --- .../class-snippets-rest-controller.php | 118 ++++++++++++++++-- 1 file changed, 110 insertions(+), 8 deletions(-) diff --git a/src/php/rest-api/class-snippets-rest-controller.php b/src/php/rest-api/class-snippets-rest-controller.php index bf37e60b..dac50a87 100644 --- a/src/php/rest-api/class-snippets-rest-controller.php +++ b/src/php/rest-api/class-snippets-rest-controller.php @@ -83,6 +83,42 @@ public function register_routes() { // Allow standard collection parameters (page, per_page, etc.) on the collection route. $collection_args = array_merge( $network_args, $this->get_collection_params() ); + $collection_args['status'] = [ + 'description' => esc_html__( 'Filter snippets by activation status.', 'code-snippets' ), + 'type' => 'string', + 'enum' => [ 'all', 'active', 'inactive' ], + 'default' => 'all', + 'sanitize_callback' => 'sanitize_key', + ]; + + $collection_args['exclude_types'] = [ + 'description' => esc_html__( 'List of snippet types to exclude from the response.', 'code-snippets' ), + 'type' => 'array', + 'items' => [ + 'type' => 'string', + ], + 'default' => [], + 'sanitize_callback' => static function ( $value ): array { + $values = is_array( $value ) ? $value : [ $value ]; + return array_values( array_filter( array_map( 'sanitize_key', $values ) ) ); + }, + ]; + + $collection_args['orderby'] = [ + 'description' => esc_html__( 'Sort collection by object attribute.', 'code-snippets' ), + 'type' => 'string', + 'enum' => [ 'id', 'name', 'display_name' ], + 'sanitize_callback' => 'sanitize_key', + ]; + + $collection_args['order'] = [ + 'description' => esc_html__( 'Sort direction.', 'code-snippets' ), + 'type' => 'string', + 'enum' => [ 'asc', 'desc' ], + 'default' => 'asc', + 'sanitize_callback' => 'sanitize_key', + ]; + register_rest_route( $this->namespace, $route, @@ -200,6 +236,72 @@ public function get_items( $request ): WP_REST_Response { $all_snippets = get_snippets( [], $network ); $all_snippets = $this->get_network_items( $all_snippets, $network ); + $status = sanitize_key( (string) $request->get_param( 'status' ) ); + + if ( in_array( $status, [ 'active', 'inactive' ], true ) ) { + $all_snippets = array_filter( + $all_snippets, + static function ( Snippet $snippet ): bool { + return ! $snippet->is_trashed(); + } + ); + } + + $exclude_types = $request->get_param( 'exclude_types' ); + $exclude_types = is_array( $exclude_types ) ? array_map( 'sanitize_key', $exclude_types ) : []; + + if ( $exclude_types ) { + $all_snippets = array_filter( + $all_snippets, + static function ( Snippet $snippet ) use ( $exclude_types ): bool { + return ! in_array( $snippet->type, $exclude_types, true ); + } + ); + } + + if ( 'active' === $status ) { + $all_snippets = array_filter( + $all_snippets, + static function ( Snippet $snippet ): bool { + return $snippet->active; + } + ); + } elseif ( 'inactive' === $status ) { + $all_snippets = array_filter( + $all_snippets, + static function ( Snippet $snippet ): bool { + return ! $snippet->active; + } + ); + } + + $orderby = sanitize_key( (string) $request->get_param( 'orderby' ) ); + $order = sanitize_key( (string) $request->get_param( 'order' ) ); + + if ( $orderby ) { + $direction = 'desc' === $order ? -1 : 1; + + usort( + $all_snippets, + static function ( Snippet $a, Snippet $b ) use ( $orderby, $direction ): int { + switch ( $orderby ) { + case 'display_name': + $cmp = strcasecmp( $a->display_name, $b->display_name ); + break; + case 'name': + $cmp = strcasecmp( (string) $a->name, (string) $b->name ); + break; + case 'id': + default: + $cmp = $a->id <=> $b->id; + break; + } + + return 0 === $cmp ? ( $a->id <=> $b->id ) * $direction : $cmp * $direction; + } + ); + } + $total_items = count( $all_snippets ); $query_params = $request->get_query_params(); @@ -559,14 +661,14 @@ public function get_item_schema(): array { 'description' => esc_html__( 'Descriptive title for the snippet.', 'code-snippets' ), 'type' => 'string', ], - 'desc' => [ - 'description' => esc_html__( 'Descriptive text associated with snippet.', 'code-snippets' ), - 'type' => 'string', - ], - 'code' => [ - 'description' => esc_html__( 'Executable snippet code.', 'code-snippets' ), - 'type' => 'string', - ], + 'desc' => [ + 'description' => esc_html__( 'Descriptive text associated with snippet.', 'code-snippets' ), + 'type' => 'string', + ], + 'code' => [ + 'description' => esc_html__( 'Executable snippet code.', 'code-snippets' ), + 'type' => 'string', + ], 'tags' => [ 'description' => esc_html__( 'List of tag categories the snippet belongs to.', 'code-snippets' ), 'type' => 'array', From 1a6bfc043cd5e1a101cb9f063bca80bd4758e8f0 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 19:29:25 +0200 Subject: [PATCH 07/96] feat: add admin bar pagination functionality with snippet fetching --- config/webpack-js.ts | 1 + src/js/admin-bar.ts | 288 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 289 insertions(+) create mode 100644 src/js/admin-bar.ts diff --git a/config/webpack-js.ts b/config/webpack-js.ts index 3e2a19ce..0ccacc43 100644 --- a/config/webpack-js.ts +++ b/config/webpack-js.ts @@ -24,6 +24,7 @@ const babelConfig = { export const jsWebpackConfig: Configuration = { entry: { + 'admin-bar': `${SOURCE_DIR}/admin-bar.ts`, edit: { import: `${SOURCE_DIR}/edit.tsx`, dependOn: 'editor' }, editor: `${SOURCE_DIR}/editor.ts`, import: `${SOURCE_DIR}/import.tsx`, diff --git a/src/js/admin-bar.ts b/src/js/admin-bar.ts new file mode 100644 index 00000000..a0e47f91 --- /dev/null +++ b/src/js/admin-bar.ts @@ -0,0 +1,288 @@ +type PaginationStatus = 'active' | 'inactive' +type PaginationAction = 'first' | 'prev' | 'next' | 'last' + +type SnippetResponseItem = { + id: number + type: string + name?: string +} + +type AdminBarConfig = { + restUrl: string + nonce: string + perPage: number + isNetwork: boolean + excludeTypes: string[] + snippetPlaceholder: string + editUrlBase: string + activeNodeId: string + inactiveNodeId: string +} + +declare const CODE_SNIPPETS_ADMIN_BAR: AdminBarConfig | undefined + +const config = typeof CODE_SNIPPETS_ADMIN_BAR !== 'undefined' ? CODE_SNIPPETS_ADMIN_BAR : undefined + +const getMenuNode = (status: PaginationStatus): HTMLElement | null => { + if (!config) return null + const nodeId = status === 'active' ? config.activeNodeId : config.inactiveNodeId + return document.getElementById(nodeId) +} + +const getPaginationControls = (status: PaginationStatus): HTMLElement | null => { + const menuNode = getMenuNode(status) + if (!menuNode) return null + + return menuNode.querySelector(`.code-snippets-pagination-controls[data-status="${status}"]`) +} + +const getPaginationState = (controls: HTMLElement): { page: number; totalPages: number } => { + const page = Number.parseInt(controls.dataset.page ?? '1', 10) + const totalPages = Number.parseInt(controls.dataset.totalPages ?? '1', 10) + + return { + page: Number.isFinite(page) && page > 0 ? page : 1, + totalPages: Number.isFinite(totalPages) && totalPages > 0 ? totalPages : 1 + } +} + +const setLoading = (controls: HTMLElement, loading: boolean) => { + controls.dataset.loading = loading ? 'true' : 'false' +} + +const buildRequestUrl = (status: PaginationStatus, page: number): string => { + if (!config) return '' + + const url = new URL(config.restUrl) + url.searchParams.set('status', status) + url.searchParams.set('page', String(page)) + url.searchParams.set('per_page', String(config.perPage)) + url.searchParams.set('orderby', 'display_name') + url.searchParams.set('order', 'asc') + + if (config.isNetwork) { + url.searchParams.set('network', '1') + } + + for (const excluded of config.excludeTypes ?? []) { + url.searchParams.append('exclude_types[]', excluded) + } + + return url.toString() +} + +const fetchSnippetsPage = async (status: PaginationStatus, page: number) => { + if (!config) { + throw new Error('Missing CODE_SNIPPETS_ADMIN_BAR config') + } + + const response = await fetch(buildRequestUrl(status, page), { + credentials: 'same-origin', + headers: { + 'X-WP-Nonce': config.nonce + } + }) + + if (!response.ok) { + throw new Error(`Failed to fetch snippets (${response.status})`) + } + + const totalPagesHeader = response.headers.get('X-WP-TotalPages') ?? '1' + const totalPages = Number.parseInt(totalPagesHeader, 10) || 1 + + const snippets = (await response.json()) as SnippetResponseItem[] + + return { snippets, totalPages } +} + +const buildEditUrl = (snippetId: number): string => { + if (!config) return '#' + + try { + const url = new URL(config.editUrlBase, window.location.href) + url.searchParams.set('id', String(snippetId)) + return url.toString() + } catch { + return '#' + } +} + +const buildSnippetPlaceholder = (snippetId: number): string => { + if (!config?.snippetPlaceholder) return `Snippet #${snippetId}` + + return config.snippetPlaceholder.replace(/%(\d+\$)?d/, String(snippetId)) +} + +const formatSnippetTitle = (snippet: SnippetResponseItem): string => { + const typeLabel = (snippet.type || '').toUpperCase() + const name = snippet.name?.trim() + const title = name ? name : buildSnippetPlaceholder(snippet.id) + return `(${typeLabel}) ${title}` +} + +const updatePaginationControls = (controls: HTMLElement, page: number, totalPages: number) => { + controls.dataset.page = String(page) + controls.dataset.totalPages = String(totalPages) + + const pageLabel = controls.querySelector('.code-snippets-pagination-page') + if (pageLabel) { + pageLabel.textContent = pageLabel.textContent?.replace(/\(\d+\/\d+\)/, `(${page}/${totalPages})`) ?? pageLabel.textContent + } + + const disableFirstPrev = page <= 1 + const disableNextLast = page >= totalPages + + const setDisabled = (action: PaginationAction, disabled: boolean) => { + const link = controls.querySelector(`a[data-action="${action}"]`) + if (link) { + link.setAttribute('aria-disabled', disabled ? 'true' : 'false') + } + } + + setDisabled('first', disableFirstPrev) + setDisabled('prev', disableFirstPrev) + setDisabled('next', disableNextLast) + setDisabled('last', disableNextLast) + + const queryArg = controls.dataset.queryArg + if (queryArg) { + const firstLink = controls.querySelector('a[data-action="first"]') + const baseHref = firstLink?.href + + if (baseHref) { + const buildHref = (targetPage: number) => { + const url = new URL(baseHref) + + if (targetPage <= 1) { + url.searchParams.delete(queryArg) + } else { + url.searchParams.set(queryArg, String(targetPage)) + } + + return url.toString() + } + + const getLink = (action: PaginationAction) => + controls.querySelector(`a[data-action="${action}"]`) + + const first = getLink('first') + if (first) first.href = buildHref(1) + + const prev = getLink('prev') + if (prev) prev.href = buildHref(Math.max(1, page - 1)) + + const next = getLink('next') + if (next) next.href = buildHref(Math.min(totalPages, page + 1)) + + const last = getLink('last') + if (last) last.href = buildHref(totalPages) + } + } +} + +const replaceSnippetItems = (status: PaginationStatus, snippets: SnippetResponseItem[]) => { + const menuNode = getMenuNode(status) + if (!menuNode) return + + const subMenu = menuNode.querySelector('ul.ab-submenu') + if (!subMenu) return + + subMenu.querySelectorAll('li.code-snippets-snippet-item').forEach(node => node.remove()) + + const insertAfterId = status === 'active' + ? 'wp-admin-bar-code-snippets-active-pagination' + : 'wp-admin-bar-code-snippets-inactive-pagination' + + const insertAfter = subMenu.querySelector(`#${insertAfterId}`) + const fragment = document.createDocumentFragment() + + for (const snippet of snippets) { + const li = document.createElement('li') + li.id = `wp-admin-bar-code-snippets-snippet-${snippet.id}` + li.className = 'code-snippets-snippet-item' + + const a = document.createElement('a') + a.className = 'ab-item' + a.href = buildEditUrl(snippet.id) + a.textContent = formatSnippetTitle(snippet) + + li.appendChild(a) + fragment.appendChild(li) + } + + if (insertAfter && insertAfter.parentNode === subMenu) { + subMenu.insertBefore(fragment, insertAfter.nextSibling) + } else { + subMenu.appendChild(fragment) + } +} + +const navigateToPage = async (status: PaginationStatus, targetPage: number) => { + const controls = getPaginationControls(status) + if (!controls) return + + const { totalPages: currentTotalPages } = getPaginationState(controls) + const page = Math.max(1, Math.min(targetPage, currentTotalPages)) + + setLoading(controls, true) + + try { + const { snippets, totalPages } = await fetchSnippetsPage(status, page) + updatePaginationControls(controls, page, totalPages) + replaceSnippetItems(status, snippets) + } catch (error) { + // eslint-disable-next-line no-console + console.error(error) + } finally { + setLoading(controls, false) + } +} + +const handlePaginationClick = (event: MouseEvent) => { + const target = event.target as Element | null + if (!target) return + + const link = target.closest('.code-snippets-pagination-controls a[data-action]') + if (!link) return + + const controls = link.closest('.code-snippets-pagination-controls') + if (!controls) return + if (controls.dataset.loading === 'true') return + + const status = controls.dataset.status as PaginationStatus | undefined + const action = link.dataset.action as PaginationAction | undefined + + if (!status || !action) return + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + const { page, totalPages } = getPaginationState(controls) + + let targetPage = page + switch (action) { + case 'first': + targetPage = 1 + break + case 'prev': + targetPage = page - 1 + break + case 'next': + targetPage = page + 1 + break + case 'last': + targetPage = totalPages + break + } + + if (targetPage === page || targetPage < 1 || targetPage > totalPages) { + return + } + + void navigateToPage(status, targetPage) +} + +if (config) { + document.addEventListener('click', handlePaginationClick, true) +} From ce45668476ef815648a9ae8f69a9f8fb886274a3 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 19:29:30 +0200 Subject: [PATCH 08/96] feat: add styles for admin bar code snippets pagination and safe mode --- src/css/admin-bar.scss | 129 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 src/css/admin-bar.scss diff --git a/src/css/admin-bar.scss b/src/css/admin-bar.scss new file mode 100644 index 00000000..566d12b7 --- /dev/null +++ b/src/css/admin-bar.scss @@ -0,0 +1,129 @@ +#wpadminbar { + #wp-admin-bar-code-snippets > .ab-item { + .code-snippets-admin-bar-icon { + display: inline-block; + top: 5px; + position: relative; + padding-right: 10px; + line-height: 1; + color: rgba(240, 245, 250, 0.6); + + svg { + width: 16px; + height: 16px; + display: block; + fill: currentColor; + } + } + + &:hover .code-snippets-admin-bar-icon, + &:focus .code-snippets-admin-bar-icon { + color: inherit; + } + } + + #wp-admin-bar-code-snippets.hover > .ab-item .code-snippets-admin-bar-icon { + color: inherit; + } + + .code-snippets-safe-mode-active { + > .ab-item, + &.ab-item { + background: #b32d2e; + color: #fff; + } + + &:hover > .ab-item, + &.hover > .ab-item { + background: #d63638; + color: #fff; + } + } + + .code-snippets-safe-mode.code-snippets-safe-mode-active > .ab-item, + .code-snippets-safe-mode.code-snippets-safe-mode-active.ab-item { + font-weight: 600; + } + + .code-snippets-safe-mode .code-snippets-external-icon { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 4px; + opacity: 0.8; + font-size: 14px; + width: 14px; + height: 14px; + line-height: 1; + } + + .code-snippets-safe-mode:hover .code-snippets-external-icon, + .code-snippets-safe-mode.hover .code-snippets-external-icon { + opacity: 1; + } + + #wp-admin-bar-code-snippets-active-snippets > .ab-sub-wrapper > .ab-submenu, + #wp-admin-bar-code-snippets-inactive-snippets > .ab-sub-wrapper > .ab-submenu { + padding-top: 0; + } + + .code-snippets-disabled > .ab-item { + opacity: 0.6; + } + + .code-snippets-disabled:hover > .ab-item, + .code-snippets-disabled.hover > .ab-item { + opacity: 1; + } + + .code-snippets-pagination-node > .ab-item { + padding: 0; + margin-bottom: 6px; + } + + .code-snippets-pagination-controls { + display: flex; + align-items: stretch; + width: 100%; + white-space: nowrap; + + .code-snippets-pagination-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 1 1 0; + min-height: 32px; + padding: 0 10px; + background: rgba(240, 245, 250, 0.08); + color: inherit; + text-decoration: none; + line-height: 1.2; + box-sizing: border-box; + + & + .code-snippets-pagination-button { + border-left: 1px solid rgba(240, 245, 250, 0.15); + } + + &[aria-disabled='true'] { + opacity: 0.4; + pointer-events: none; + } + } + + a.code-snippets-pagination-button:hover { + background: rgba(240, 245, 250, 0.16); + } + + a[data-action='first'], + a[data-action='last'] { + flex: 0 0 36px; + padding: 0; + } + + .code-snippets-pagination-page { + flex: 0 0 auto; + cursor: default; + opacity: 0.85; + } + } +} From d16bc87f886a13ea8f40d02875298c0ccfdb45aa Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 20:12:03 +0200 Subject: [PATCH 09/96] feat: refactor admin bar icon styles --- src/css/admin-bar.scss | 48 +++++++++++++++++------------------------- 1 file changed, 19 insertions(+), 29 deletions(-) diff --git a/src/css/admin-bar.scss b/src/css/admin-bar.scss index 566d12b7..3ace5391 100644 --- a/src/css/admin-bar.scss +++ b/src/css/admin-bar.scss @@ -1,31 +1,24 @@ #wpadminbar { #wp-admin-bar-code-snippets > .ab-item { .code-snippets-admin-bar-icon { - display: inline-block; - top: 5px; - position: relative; - padding-right: 10px; - line-height: 1; - color: rgba(240, 245, 250, 0.6); - - svg { + width: 16px; + height: 16px; + top: 5px; + + &::before { + content: ''; + display: block; width: 16px; height: 16px; - display: block; - fill: currentColor; + mask-image: url('../assets/menu-icon.svg'); + mask-repeat: no-repeat; + mask-position: center; + mask-size: contain; + background-color: rgba(240, 245, 250, 0.6); } } - - &:hover .code-snippets-admin-bar-icon, - &:focus .code-snippets-admin-bar-icon { - color: inherit; - } - } - - #wp-admin-bar-code-snippets.hover > .ab-item .code-snippets-admin-bar-icon { - color: inherit; } - + .code-snippets-safe-mode-active { > .ab-item, &.ab-item { @@ -46,15 +39,12 @@ } .code-snippets-safe-mode .code-snippets-external-icon { - display: inline-flex; - align-items: center; - justify-content: center; - margin-left: 4px; - opacity: 0.8; - font-size: 14px; - width: 14px; - height: 14px; - line-height: 1; + font-family: dashicons; + display: inline-block; + position: relative; + line-height: 1; + opacity: .8; + top: 6px; } .code-snippets-safe-mode:hover .code-snippets-external-icon, From fd419f4d50a760d45aaff6b42f86309ef4c69423 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 20:12:30 +0200 Subject: [PATCH 10/96] feat: update admin bar icon handling and remove SVG function --- src/php/class-admin-bar.php | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/src/php/class-admin-bar.php b/src/php/class-admin-bar.php index 9bc6ae09..d5ab458f 100644 --- a/src/php/class-admin-bar.php +++ b/src/php/class-admin-bar.php @@ -133,10 +133,8 @@ public function register_nodes( WP_Admin_Bar $wp_admin_bar ): void { $is_safe_mode_active = code_snippets()->evaluate_functions->is_safe_mode_active(); - $icon = ''; $title = sprintf( - '%s%s', - $icon, + '%s', esc_html__( 'Snippets', 'code-snippets' ) ); @@ -154,15 +152,6 @@ public function register_nodes( WP_Admin_Bar $wp_admin_bar ): void { $this->add_safe_mode_link( $wp_admin_bar ); } - /** - * Retrieve the Code Snippets scissors SVG icon markup. - * - * @return string - */ - private function get_scissors_svg(): string { - return ''; - } - /** * Add menu item for safe mode status. * @@ -200,7 +189,7 @@ private function add_safe_mode_nodes( WP_Admin_Bar $wp_admin_bar, bool $is_safe_ */ private function add_safe_mode_link( WP_Admin_Bar $wp_admin_bar ): void { $title = sprintf( - ' %s', + '%s ', esc_html__( 'Safe Mode', 'code-snippets' ) ); From 4caef68a7d93eb40e62261bce8558f2dcd8391c7 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 20:22:12 +0200 Subject: [PATCH 11/96] fix: update snippet response item type handling --- src/js/admin-bar.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/js/admin-bar.ts b/src/js/admin-bar.ts index a0e47f91..29e9e309 100644 --- a/src/js/admin-bar.ts +++ b/src/js/admin-bar.ts @@ -3,7 +3,7 @@ type PaginationAction = 'first' | 'prev' | 'next' | 'last' type SnippetResponseItem = { id: number - type: string + scope: string name?: string } @@ -113,8 +113,16 @@ const buildSnippetPlaceholder = (snippetId: number): string => { return config.snippetPlaceholder.replace(/%(\d+\$)?d/, String(snippetId)) } +const getTypeFromScope = (scope: string): string => { + if (scope.endsWith('-css')) return 'css' + if (scope.endsWith('-js')) return 'js' + if (scope.endsWith('content')) return 'html' + if (scope === 'condition') return 'cond' + return 'php' +} + const formatSnippetTitle = (snippet: SnippetResponseItem): string => { - const typeLabel = (snippet.type || '').toUpperCase() + const typeLabel = getTypeFromScope(snippet.scope).toUpperCase() const name = snippet.name?.trim() const title = name ? name : buildSnippetPlaceholder(snippet.id) return `(${typeLabel}) ${title}` From 1cf61b72dffcedeb6c7e2478176edae4265febb9 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 20:32:47 +0200 Subject: [PATCH 12/96] feat: enhance admin bar settings with enable option and snippet limit --- src/php/class-admin-bar.php | 21 +++++++++++------- src/php/settings/settings-fields.php | 33 +++++++++++++++++++--------- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/php/class-admin-bar.php b/src/php/class-admin-bar.php index d5ab458f..706a75e6 100644 --- a/src/php/class-admin-bar.php +++ b/src/php/class-admin-bar.php @@ -123,16 +123,22 @@ public function register_nodes( WP_Admin_Bar $wp_admin_bar ): void { return; } - if ( ! apply_filters( 'code_snippets/admin_bar/enabled', true ) ) { - return; - } - if ( ! code_snippets()->current_user_can() ) { return; } $is_safe_mode_active = code_snippets()->evaluate_functions->is_safe_mode_active(); + // Always show safe mode indicator regardless of setting. + $this->add_safe_mode_nodes( $wp_admin_bar, $is_safe_mode_active ); + + // Check if admin bar menu is enabled via settings. + $is_enabled = Settings\get_setting( 'general', 'enable_admin_bar' ); + + if ( ! $is_enabled && ! apply_filters( 'code_snippets/admin_bar/enabled', false ) ) { + return; + } + $title = sprintf( '%s', esc_html__( 'Snippets', 'code-snippets' ) @@ -146,7 +152,6 @@ public function register_nodes( WP_Admin_Bar $wp_admin_bar ): void { ] ); - $this->add_safe_mode_nodes( $wp_admin_bar, $is_safe_mode_active ); $this->add_quick_links( $wp_admin_bar ); $this->add_snippet_listings( $wp_admin_bar ); $this->add_safe_mode_link( $wp_admin_bar ); @@ -258,9 +263,9 @@ private function add_quick_links( WP_Admin_Bar $wp_admin_bar ): void { $types = [ 'php' => _x( 'Functions (PHP)', 'snippet type', 'code-snippets' ), 'html' => _x( 'Content (HTML)', 'snippet type', 'code-snippets' ), - 'css' => _x( 'Style (CSS)', 'snippet type', 'code-snippets' ), - 'js' => _x( 'Script (JS)', 'snippet type', 'code-snippets' ), - 'cond' => _x( 'Condition (COND)', 'snippet type', 'code-snippets' ), + 'css' => _x( 'Styles (CSS)', 'snippet type', 'code-snippets' ), + 'js' => _x( 'Scripts (JS)', 'snippet type', 'code-snippets' ), + 'cond' => _x( 'Conditions (COND)', 'snippet type', 'code-snippets' ), ]; $types = array_intersect_key( $types, array_flip( Snippet::get_types() ) ); diff --git a/src/php/settings/settings-fields.php b/src/php/settings/settings-fields.php index 77466d79..61b05f58 100644 --- a/src/php/settings/settings-fields.php +++ b/src/php/settings/settings-fields.php @@ -29,11 +29,12 @@ function get_default_settings(): array { 'enable_description' => true, 'visual_editor_rows' => 5, 'list_order' => 'priority-asc', - 'admin_bar_snippet_limit' => 20, 'disable_prism' => false, 'hide_upgrade_menu' => false, 'complete_uninstall' => false, - 'enable_flat_files' => false, + 'enable_flat_files' => false, + 'enable_admin_bar' => false, + 'admin_bar_snippet_limit' => 20, ], 'editor' => [ 'indent_with_tabs' => true, @@ -147,14 +148,7 @@ function get_settings_fields(): array { 'modified-asc' => __( 'Modified (oldest first)', 'code-snippets' ), ], ], - 'admin_bar_snippet_limit' => [ - 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), - 'type' => 'number', - 'desc' => __( 'Number of snippets to show in the admin bar Active/Inactive menus before paginating.', 'code-snippets' ), - 'label' => __( 'snippets', 'code-snippets' ), - 'min' => 1, - 'max' => 100, - ], + 'disable_prism' => [ 'name' => __( 'Disable Syntax Highlighter', 'code-snippets' ), 'type' => 'checkbox', @@ -268,5 +262,24 @@ function get_settings_fields(): array { $fields = apply_filters( 'code_snippets_settings_fields', $fields ); + // Add Admin Bar settings after plugin-provided fields (e.g., file-based execution). + $fields['general']['enable_admin_bar'] = [ + 'name' => __( 'Enable Admin Bar Menu', 'code-snippets' ), + 'type' => 'checkbox', + 'label' => __( 'Show a Snippets menu in the admin bar for quick access to snippets.', 'code-snippets' ), + ]; + + // Only show per-page control if admin bar menu is enabled in settings. + if ( get_setting( 'general', 'enable_admin_bar' ) ) { + $fields['general']['admin_bar_snippet_limit'] = [ + 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'Number of snippets to show in the admin bar Active/Inactive menus before paginating.', 'code-snippets' ), + 'label' => __( 'snippets', 'code-snippets' ), + 'min' => 1, + 'max' => 100, + ]; + } + return $fields; } From cd2167d51944ddb19c7f79ae1eb36f76c956cab4 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 8 Jan 2026 20:54:51 +0200 Subject: [PATCH 13/96] feat: add 'Conditions' tab --- src/php/class-plugin.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index 318aeb8b..eb8f9d96 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -406,6 +406,7 @@ public static function get_types(): array { 'html' => __( 'Content', 'code-snippets' ), 'css' => __( 'Styles', 'code-snippets' ), 'js' => __( 'Scripts', 'code-snippets' ), + 'cond' => __( 'Conditions', 'code-snippets' ), 'cloud' => __( 'Codevault', 'code-snippets' ), 'cloud_search' => __( 'Cloud Search', 'code-snippets' ), 'bundles' => __( 'Bundles', 'code-snippets' ), From 4e7485117e6531a52175562dfea9b72e17a11f3a Mon Sep 17 00:00:00 2001 From: Imants Date: Fri, 9 Jan 2026 15:21:22 +0200 Subject: [PATCH 14/96] feat: enable admin bar by default in settings --- src/php/settings/settings-fields.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/php/settings/settings-fields.php b/src/php/settings/settings-fields.php index 61b05f58..5802adfc 100644 --- a/src/php/settings/settings-fields.php +++ b/src/php/settings/settings-fields.php @@ -33,7 +33,7 @@ function get_default_settings(): array { 'hide_upgrade_menu' => false, 'complete_uninstall' => false, 'enable_flat_files' => false, - 'enable_admin_bar' => false, + 'enable_admin_bar' => true, 'admin_bar_snippet_limit' => 20, ], 'editor' => [ From f6929f1e4abbdfab465c05eae1508cbad401c992 Mon Sep 17 00:00:00 2001 From: Imants Date: Sat, 17 Jan 2026 23:45:54 +0200 Subject: [PATCH 15/96] fix: eslint @stylistic/quote-props --- config/webpack-js.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/config/webpack-js.ts b/config/webpack-js.ts index 0ccacc43..5d50210d 100644 --- a/config/webpack-js.ts +++ b/config/webpack-js.ts @@ -25,13 +25,13 @@ const babelConfig = { export const jsWebpackConfig: Configuration = { entry: { 'admin-bar': `${SOURCE_DIR}/admin-bar.ts`, - edit: { import: `${SOURCE_DIR}/edit.tsx`, dependOn: 'editor' }, - editor: `${SOURCE_DIR}/editor.ts`, - import: `${SOURCE_DIR}/import.tsx`, - manage: `${SOURCE_DIR}/manage.ts`, - mce: `${SOURCE_DIR}/mce.ts`, - prism: `${SOURCE_DIR}/prism.ts`, - settings: { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' } + 'edit': { import: `${SOURCE_DIR}/edit.tsx`, dependOn: 'editor' }, + 'editor': `${SOURCE_DIR}/editor.ts`, + 'import': `${SOURCE_DIR}/import.tsx`, + 'manage': `${SOURCE_DIR}/manage.ts`, + 'mce': `${SOURCE_DIR}/mce.ts`, + 'prism': `${SOURCE_DIR}/prism.ts`, + 'settings': { import: `${SOURCE_DIR}/settings.ts`, dependOn: 'editor' } }, output: { path: join(resolve(__dirname), '..', DEST_DIR), From b7e5a74b12186c28ec22a3d72135688dd3f98b73 Mon Sep 17 00:00:00 2001 From: Imants Date: Sat, 17 Jan 2026 23:50:48 +0200 Subject: [PATCH 16/96] fix: codestyle clean up and delint --- .../rest-api/class-snippets-rest-controller.php | 16 ++++++++-------- src/php/settings/settings-fields.php | 3 --- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/src/php/rest-api/class-snippets-rest-controller.php b/src/php/rest-api/class-snippets-rest-controller.php index dac50a87..f8478cb8 100644 --- a/src/php/rest-api/class-snippets-rest-controller.php +++ b/src/php/rest-api/class-snippets-rest-controller.php @@ -661,14 +661,14 @@ public function get_item_schema(): array { 'description' => esc_html__( 'Descriptive title for the snippet.', 'code-snippets' ), 'type' => 'string', ], - 'desc' => [ - 'description' => esc_html__( 'Descriptive text associated with snippet.', 'code-snippets' ), - 'type' => 'string', - ], - 'code' => [ - 'description' => esc_html__( 'Executable snippet code.', 'code-snippets' ), - 'type' => 'string', - ], + 'desc' => [ + 'description' => esc_html__( 'Descriptive text associated with snippet.', 'code-snippets' ), + 'type' => 'string', + ], + 'code' => [ + 'description' => esc_html__( 'Executable snippet code.', 'code-snippets' ), + 'type' => 'string', + ], 'tags' => [ 'description' => esc_html__( 'List of tag categories the snippet belongs to.', 'code-snippets' ), 'type' => 'array', diff --git a/src/php/settings/settings-fields.php b/src/php/settings/settings-fields.php index 5802adfc..06033d14 100644 --- a/src/php/settings/settings-fields.php +++ b/src/php/settings/settings-fields.php @@ -148,7 +148,6 @@ function get_settings_fields(): array { 'modified-asc' => __( 'Modified (oldest first)', 'code-snippets' ), ], ], - 'disable_prism' => [ 'name' => __( 'Disable Syntax Highlighter', 'code-snippets' ), 'type' => 'checkbox', @@ -262,14 +261,12 @@ function get_settings_fields(): array { $fields = apply_filters( 'code_snippets_settings_fields', $fields ); - // Add Admin Bar settings after plugin-provided fields (e.g., file-based execution). $fields['general']['enable_admin_bar'] = [ 'name' => __( 'Enable Admin Bar Menu', 'code-snippets' ), 'type' => 'checkbox', 'label' => __( 'Show a Snippets menu in the admin bar for quick access to snippets.', 'code-snippets' ), ]; - // Only show per-page control if admin bar menu is enabled in settings. if ( get_setting( 'general', 'enable_admin_bar' ) ) { $fields['general']['admin_bar_snippet_limit'] = [ 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), From be4ad85c1e4e11ed22181cb5d5bd85dfeb629b0a Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 22 Jan 2026 13:58:57 +0200 Subject: [PATCH 17/96] fix: simplify snippet filtering logic in get_items method ref: https://github.com/codesnippetspro/code-snippets/pull/328#discussion_r2711137193 --- .../class-snippets-rest-controller.php | 41 +++++++------------ 1 file changed, 14 insertions(+), 27 deletions(-) diff --git a/src/php/rest-api/class-snippets-rest-controller.php b/src/php/rest-api/class-snippets-rest-controller.php index f8478cb8..0c54efde 100644 --- a/src/php/rest-api/class-snippets-rest-controller.php +++ b/src/php/rest-api/class-snippets-rest-controller.php @@ -238,39 +238,26 @@ public function get_items( $request ): WP_REST_Response { $status = sanitize_key( (string) $request->get_param( 'status' ) ); - if ( in_array( $status, [ 'active', 'inactive' ], true ) ) { - $all_snippets = array_filter( - $all_snippets, - static function ( Snippet $snippet ): bool { - return ! $snippet->is_trashed(); - } - ); - } - $exclude_types = $request->get_param( 'exclude_types' ); $exclude_types = is_array( $exclude_types ) ? array_map( 'sanitize_key', $exclude_types ) : []; - if ( $exclude_types ) { + if ( $exclude_types || 'all' !== $status ) { $all_snippets = array_filter( $all_snippets, - static function ( Snippet $snippet ) use ( $exclude_types ): bool { - return ! in_array( $snippet->type, $exclude_types, true ); - } - ); - } + static function ( Snippet $snippet ) use ( $exclude_types, $status ): bool { + if ( $exclude_types && in_array( $snippet->type, $exclude_types, true ) ) { + return false; + } - if ( 'active' === $status ) { - $all_snippets = array_filter( - $all_snippets, - static function ( Snippet $snippet ): bool { - return $snippet->active; - } - ); - } elseif ( 'inactive' === $status ) { - $all_snippets = array_filter( - $all_snippets, - static function ( Snippet $snippet ): bool { - return ! $snippet->active; + if ( 'active' === $status ) { + return ! $snippet->is_trashed() && (bool) $snippet->active; + } + + if ( 'inactive' === $status ) { + return ! $snippet->is_trashed() && ! $snippet->active; + } + + return true; } ); } From 1641d7b1a5d5468b7602ab9cbcbc3fcfad916f85 Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 22 Jan 2026 14:06:09 +0200 Subject: [PATCH 18/96] fix: remove `require_once` to rely on composer autoloader ref: https://github.com/codesnippetspro/code-snippets/pull/328#discussion_r2711139828 --- src/php/class-plugin.php | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/php/class-plugin.php b/src/php/class-plugin.php index eb8f9d96..05053e30 100644 --- a/src/php/class-plugin.php +++ b/src/php/class-plugin.php @@ -129,9 +129,7 @@ public function load_plugin() { $this->evaluate_functions = new Evaluate_Functions( $this->db ); // Admin bar integration. - require_once $includes_path . '/class-admin-bar.php'; $this->admin_bar = new Admin_Bar(); - $this->admin_bar->register_hooks(); // CodeMirror editor functions. require_once $includes_path . '/editor.php'; From 87da1309cc16b976bf56c06e5b85250f7151704f Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 22 Jan 2026 14:07:15 +0200 Subject: [PATCH 19/96] fix: update `register_hooks` to class constructor to register WordPress hooks directly on load ref: https://github.com/codesnippetspro/code-snippets/pull/328#discussion_r2711141243 --- src/php/class-admin-bar.php | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/php/class-admin-bar.php b/src/php/class-admin-bar.php index 706a75e6..14792398 100644 --- a/src/php/class-admin-bar.php +++ b/src/php/class-admin-bar.php @@ -55,11 +55,9 @@ class Admin_Bar { private const STYLE_HANDLE = 'code-snippets-admin-bar'; /** - * Register WordPress hooks. - * - * @return void + * Class constructor. */ - public function register_hooks(): void { + public function __construct() { add_action( 'admin_bar_menu', [ $this, 'register_nodes' ], 80 ); add_action( 'admin_enqueue_scripts', [ $this, 'enqueue_assets' ] ); From 4e333b4dfef97079fada96b8b1b1289c7361837f Mon Sep 17 00:00:00 2001 From: Imants Date: Thu, 22 Jan 2026 19:40:12 +0200 Subject: [PATCH 20/96] fix: export types and interfaces, refactor `getTypeFromScope` to switch and fix lint issues --- src/js/admin-bar.ts | 130 ++++++++++++++++++++++++++++++-------------- 1 file changed, 88 insertions(+), 42 deletions(-) diff --git a/src/js/admin-bar.ts b/src/js/admin-bar.ts index 29e9e309..21bcde53 100644 --- a/src/js/admin-bar.ts +++ b/src/js/admin-bar.ts @@ -1,13 +1,13 @@ -type PaginationStatus = 'active' | 'inactive' -type PaginationAction = 'first' | 'prev' | 'next' | 'last' +export type PaginationStatus = 'active' | 'inactive' +export type PaginationAction = 'first' | 'prev' | 'next' | 'last' -type SnippetResponseItem = { +export interface SnippetResponseItem { id: number scope: string name?: string } -type AdminBarConfig = { +export interface AdminBarConfig { restUrl: string nonce: string perPage: number @@ -21,17 +21,22 @@ type AdminBarConfig = { declare const CODE_SNIPPETS_ADMIN_BAR: AdminBarConfig | undefined -const config = typeof CODE_SNIPPETS_ADMIN_BAR !== 'undefined' ? CODE_SNIPPETS_ADMIN_BAR : undefined +const config = 'undefined' === typeof CODE_SNIPPETS_ADMIN_BAR ? undefined : CODE_SNIPPETS_ADMIN_BAR const getMenuNode = (status: PaginationStatus): HTMLElement | null => { - if (!config) return null - const nodeId = status === 'active' ? config.activeNodeId : config.inactiveNodeId + if (!config) { + return null + } + + const nodeId = 'active' === status ? config.activeNodeId : config.inactiveNodeId return document.getElementById(nodeId) } const getPaginationControls = (status: PaginationStatus): HTMLElement | null => { const menuNode = getMenuNode(status) - if (!menuNode) return null + if (!menuNode) { + return null + } return menuNode.querySelector(`.code-snippets-pagination-controls[data-status="${status}"]`) } @@ -41,8 +46,8 @@ const getPaginationState = (controls: HTMLElement): { page: number; totalPages: const totalPages = Number.parseInt(controls.dataset.totalPages ?? '1', 10) return { - page: Number.isFinite(page) && page > 0 ? page : 1, - totalPages: Number.isFinite(totalPages) && totalPages > 0 ? totalPages : 1 + page: Number.isFinite(page) && 0 < page ? page : 1, + totalPages: Number.isFinite(totalPages) && 0 < totalPages ? totalPages : 1 } } @@ -51,7 +56,9 @@ const setLoading = (controls: HTMLElement, loading: boolean) => { } const buildRequestUrl = (status: PaginationStatus, page: number): string => { - if (!config) return '' + if (!config) { + return '' + } const url = new URL(config.restUrl) url.searchParams.set('status', status) @@ -64,7 +71,7 @@ const buildRequestUrl = (status: PaginationStatus, page: number): string => { url.searchParams.set('network', '1') } - for (const excluded of config.excludeTypes ?? []) { + for (const excluded of config.excludeTypes) { url.searchParams.append('exclude_types[]', excluded) } @@ -90,13 +97,15 @@ const fetchSnippetsPage = async (status: PaginationStatus, page: number) => { const totalPagesHeader = response.headers.get('X-WP-TotalPages') ?? '1' const totalPages = Number.parseInt(totalPagesHeader, 10) || 1 - const snippets = (await response.json()) as SnippetResponseItem[] + const snippets = await response.json() return { snippets, totalPages } } const buildEditUrl = (snippetId: number): string => { - if (!config) return '#' + if (!config) { + return '#' + } try { const url = new URL(config.editUrlBase, window.location.href) @@ -108,23 +117,36 @@ const buildEditUrl = (snippetId: number): string => { } const buildSnippetPlaceholder = (snippetId: number): string => { - if (!config?.snippetPlaceholder) return `Snippet #${snippetId}` + if (!config?.snippetPlaceholder) { + return `Snippet #${snippetId}` + } - return config.snippetPlaceholder.replace(/%(\d+\$)?d/, String(snippetId)) + return config.snippetPlaceholder.replace(/%(?:\d+\$)?d/, String(snippetId)) } const getTypeFromScope = (scope: string): string => { - if (scope.endsWith('-css')) return 'css' - if (scope.endsWith('-js')) return 'js' - if (scope.endsWith('content')) return 'html' - if (scope === 'condition') return 'cond' - return 'php' + switch (true) { + case scope.endsWith('-css'): + return 'css' + + case scope.endsWith('-js'): + return 'js' + + case scope.endsWith('content'): + return 'html' + + case 'condition' === scope: + return 'cond' + + default: + return 'php' + } } const formatSnippetTitle = (snippet: SnippetResponseItem): string => { const typeLabel = getTypeFromScope(snippet.scope).toUpperCase() const name = snippet.name?.trim() - const title = name ? name : buildSnippetPlaceholder(snippet.id) + const title = ('' === name ? undefined : name) ?? buildSnippetPlaceholder(snippet.id) return `(${typeLabel}) ${title}` } @@ -137,7 +159,7 @@ const updatePaginationControls = (controls: HTMLElement, page: number, totalPage pageLabel.textContent = pageLabel.textContent?.replace(/\(\d+\/\d+\)/, `(${page}/${totalPages})`) ?? pageLabel.textContent } - const disableFirstPrev = page <= 1 + const disableFirstPrev = 1 >= page const disableNextLast = page >= totalPages const setDisabled = (action: PaginationAction, disabled: boolean) => { @@ -161,7 +183,7 @@ const updatePaginationControls = (controls: HTMLElement, page: number, totalPage const buildHref = (targetPage: number) => { const url = new URL(baseHref) - if (targetPage <= 1) { + if (1 >= targetPage) { url.searchParams.delete(queryArg) } else { url.searchParams.set(queryArg, String(targetPage)) @@ -174,30 +196,42 @@ const updatePaginationControls = (controls: HTMLElement, page: number, totalPage controls.querySelector(`a[data-action="${action}"]`) const first = getLink('first') - if (first) first.href = buildHref(1) + if (first) { + first.href = buildHref(1) + } const prev = getLink('prev') - if (prev) prev.href = buildHref(Math.max(1, page - 1)) + if (prev) { + prev.href = buildHref(Math.max(1, page - 1)) + } const next = getLink('next') - if (next) next.href = buildHref(Math.min(totalPages, page + 1)) + if (next) { + next.href = buildHref(Math.min(totalPages, page + 1)) + } const last = getLink('last') - if (last) last.href = buildHref(totalPages) + if (last) { + last.href = buildHref(totalPages) + } } } } const replaceSnippetItems = (status: PaginationStatus, snippets: SnippetResponseItem[]) => { const menuNode = getMenuNode(status) - if (!menuNode) return + if (!menuNode) { + return + } const subMenu = menuNode.querySelector('ul.ab-submenu') - if (!subMenu) return + if (!subMenu) { + return + } subMenu.querySelectorAll('li.code-snippets-snippet-item').forEach(node => node.remove()) - const insertAfterId = status === 'active' + const insertAfterId = 'active' === status ? 'wp-admin-bar-code-snippets-active-pagination' : 'wp-admin-bar-code-snippets-inactive-pagination' @@ -227,7 +261,9 @@ const replaceSnippetItems = (status: PaginationStatus, snippets: SnippetResponse const navigateToPage = async (status: PaginationStatus, targetPage: number) => { const controls = getPaginationControls(status) - if (!controls) return + if (!controls) { + return + } const { totalPages: currentTotalPages } = getPaginationState(controls) const page = Math.max(1, Math.min(targetPage, currentTotalPages)) @@ -239,7 +275,6 @@ const navigateToPage = async (status: PaginationStatus, targetPage: number) => { updatePaginationControls(controls, page, totalPages) replaceSnippetItems(status, snippets) } catch (error) { - // eslint-disable-next-line no-console console.error(error) } finally { setLoading(controls, false) @@ -247,20 +282,31 @@ const navigateToPage = async (status: PaginationStatus, targetPage: number) => { } const handlePaginationClick = (event: MouseEvent) => { - const target = event.target as Element | null - if (!target) return + const target = event.target + if (!target) { + return + } const link = target.closest('.code-snippets-pagination-controls a[data-action]') - if (!link) return + if (!link) { + return + } const controls = link.closest('.code-snippets-pagination-controls') - if (!controls) return - if (controls.dataset.loading === 'true') return + if (!controls) { + return + } - const status = controls.dataset.status as PaginationStatus | undefined - const action = link.dataset.action as PaginationAction | undefined + if ('true' === controls.dataset.loading) { + return + } + + const status = controls.dataset.status + const action = link.dataset.action - if (!status || !action) return + if (!status || !action) { + return + } event.preventDefault() event.stopPropagation() @@ -284,7 +330,7 @@ const handlePaginationClick = (event: MouseEvent) => { break } - if (targetPage === page || targetPage < 1 || targetPage > totalPages) { + if (targetPage === page || 1 > targetPage || targetPage > totalPages) { return } From 74875f647748efa688de1661d92db026179c6b76 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 11:50:37 +0200 Subject: [PATCH 21/96] tests: Refactor PHPUnit workflow to use a matrix strategy for PHP versions and enhance caching for WordPress test suite --- .github/workflows/phpunit-test.yml | 33 +++++++++- .github/workflows/phpunit.yml | 98 ++++++++++++++++-------------- 2 files changed, 83 insertions(+), 48 deletions(-) diff --git a/.github/workflows/phpunit-test.yml b/.github/workflows/phpunit-test.yml index f6be916b..50295739 100644 --- a/.github/workflows/phpunit-test.yml +++ b/.github/workflows/phpunit-test.yml @@ -13,7 +13,10 @@ jobs: phpunit-test: name: PHPUnit tests (PHP ${{ inputs.php-version }}) runs-on: ubuntu-22.04 - + env: + WP_CORE_DIR: ${{ runner.temp }}/wordpress + WP_TESTS_DIR: ${{ runner.temp }}/wordpress-tests-lib + services: mysql: image: mysql:8.0 @@ -70,10 +73,38 @@ jobs: path: src/vendor key: ${{ runner.os }}-php-${{ inputs.php-version }}-composer-${{ steps.deps-hash.outputs.deps_hash }} + - name: Resolve WordPress version metadata + id: wp-meta + run: | + set -euo pipefail + wp_version=$(curl -s http://api.wordpress.org/core/version-check/1.7/ | grep -o '"version":"[^"]*' | head -1 | sed 's/"version":"//') + if [ -z "$wp_version" ]; then + wp_version="latest-unknown" + fi + echo "wp_version=$wp_version" >> "$GITHUB_OUTPUT" + + - name: Get WordPress test suite cache + id: wp-tests-cache + uses: actions/cache/restore@v4 + with: + path: | + ${{ env.WP_CORE_DIR }} + ${{ env.WP_TESTS_DIR }} + key: ${{ runner.os }}-php-${{ inputs.php-version }}-wp-tests-${{ hashFiles('tests/install-wp-tests.sh') }}-wp-${{ steps.wp-meta.outputs.wp_version }} + - name: Install WordPress test suite run: | bash tests/install-wp-tests.sh wordpress_test root root 127.0.0.1:3306 latest true + - name: Save WordPress test suite cache + if: steps.wp-tests-cache.outputs.cache-hit != 'true' + uses: actions/cache/save@v4 + with: + path: | + ${{ env.WP_CORE_DIR }} + ${{ env.WP_TESTS_DIR }} + key: ${{ steps.wp-tests-cache.outputs.cache-primary-key }} + - name: Run PHPUnit tests run: | cd src diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 45123d4b..4f9ad6e0 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -24,64 +24,68 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: - phpunit-php-7-4: + phpunit: if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') + strategy: + fail-fast: false + matrix: + php-version: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] uses: ./.github/workflows/phpunit-test.yml with: - php-version: '7.4' - - phpunit-php-8-0: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.0' - - phpunit-php-8-1: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.1' - - phpunit-php-8-2: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.2' - - phpunit-php-8-3: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.3' - - phpunit-php-8-4: - if: github.event_name != 'pull_request' || contains(github.event.pull_request.labels.*.name, 'run-tests') - uses: ./.github/workflows/phpunit-test.yml - with: - php-version: '8.4' + php-version: ${{ matrix.php-version }} test-result: - needs: [phpunit-php-7-4, phpunit-php-8-0, phpunit-php-8-1, phpunit-php-8-2, phpunit-php-8-3, phpunit-php-8-4] - if: always() && (needs.phpunit-php-7-4.result != 'skipped' || needs.phpunit-php-8-0.result != 'skipped' || needs.phpunit-php-8-1.result != 'skipped' || needs.phpunit-php-8-2.result != 'skipped' || needs.phpunit-php-8-3.result != 'skipped' || needs.phpunit-php-8-4.result != 'skipped') + needs: [phpunit] + if: always() && needs.phpunit.result != 'skipped' runs-on: ubuntu-22.04 name: PHPUnit - Test Results Summary + permissions: + pull-requests: write steps: - name: Test status summary run: | - echo "PHP 7.4: ${{ needs.phpunit-php-7-4.result }}" - echo "PHP 8.0: ${{ needs.phpunit-php-8-0.result }}" - echo "PHP 8.1: ${{ needs.phpunit-php-8-1.result }}" - echo "PHP 8.2: ${{ needs.phpunit-php-8-2.result }}" - echo "PHP 8.3: ${{ needs.phpunit-php-8-3.result }}" - echo "PHP 8.4: ${{ needs.phpunit-php-8-4.result }}" + echo "PHPUnit matrix result: ${{ needs.phpunit.result }}" + + - name: Post PR comment on failure + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + const body = [ + marker, + '## PHPUnit Test Failure', + '', + `One or more PHP version targets failed in [this workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + '', + 'Please review the failing jobs and fix the issues before merging.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const existing = comments.find(c => c.body.startsWith(marker)); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } - name: Check overall status - if: | - (needs.phpunit-php-7-4.result != 'success' && needs.phpunit-php-7-4.result != 'skipped') || - (needs.phpunit-php-8-0.result != 'success' && needs.phpunit-php-8-0.result != 'skipped') || - (needs.phpunit-php-8-1.result != 'success' && needs.phpunit-php-8-1.result != 'skipped') || - (needs.phpunit-php-8-2.result != 'success' && needs.phpunit-php-8-2.result != 'skipped') || - (needs.phpunit-php-8-3.result != 'success' && needs.phpunit-php-8-3.result != 'skipped') || - (needs.phpunit-php-8-4.result != 'success' && needs.phpunit-php-8-4.result != 'skipped') + if: needs.phpunit.result != 'success' run: exit 1 From ecfb2b560271acaf4c2784116c397e08e5962aac Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 11:54:04 +0200 Subject: [PATCH 22/96] fix: Update WordPress directory paths in PHPUnit workflow --- .github/workflows/phpunit-test.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phpunit-test.yml b/.github/workflows/phpunit-test.yml index 50295739..e0fe4f07 100644 --- a/.github/workflows/phpunit-test.yml +++ b/.github/workflows/phpunit-test.yml @@ -14,8 +14,8 @@ jobs: name: PHPUnit tests (PHP ${{ inputs.php-version }}) runs-on: ubuntu-22.04 env: - WP_CORE_DIR: ${{ runner.temp }}/wordpress - WP_TESTS_DIR: ${{ runner.temp }}/wordpress-tests-lib + WP_CORE_DIR: /tmp/wordpress + WP_TESTS_DIR: /tmp/wordpress-tests-lib services: mysql: From f3ffef29a8d653963db50d07631ed009833d86cf Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:06:23 +0200 Subject: [PATCH 23/96] fix: Simplify Composer install command in PHPUnit workflow --- .github/workflows/phpunit-test.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/phpunit-test.yml b/.github/workflows/phpunit-test.yml index e0fe4f07..7eb654dd 100644 --- a/.github/workflows/phpunit-test.yml +++ b/.github/workflows/phpunit-test.yml @@ -62,9 +62,7 @@ jobs: - name: Install Composer dependencies if: steps.composer-cache.outputs.cache-hit != 'true' - run: | - cd src - composer install --no-progress --prefer-dist --optimize-autoloader + run: composer install -d src --no-progress --prefer-dist --optimize-autoloader - name: Save Composer cache if: steps.composer-cache.outputs.cache-hit != 'true' From 559b635df09c07d1193a9d11c0443bff9d6768b1 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:06:35 +0200 Subject: [PATCH 24/96] fix: Update permissions and improve comment handling in PHPUnit workflow --- .github/workflows/phpunit.yml | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 4f9ad6e0..afc16fab 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -41,6 +41,7 @@ jobs: name: PHPUnit - Test Results Summary permissions: pull-requests: write + issues: write steps: - name: Test status summary run: | @@ -65,26 +66,26 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + per_page: 100, }); - const existing = comments.find(c => c.body.startsWith(marker)); + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); - if (existing) { - await github.rest.issues.updateComment({ + for (const comment of existing) { + await github.rest.issues.deleteComment({ owner: context.repo.owner, repo: context.repo.repo, - comment_id: existing.id, - body, - }); - } else { - await github.rest.issues.createComment({ - owner: context.repo.owner, - repo: context.repo.repo, - issue_number: context.issue.number, - body, + comment_id: comment.id, }); } + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + - name: Check overall status if: needs.phpunit.result != 'success' run: exit 1 From 16d3e09aa994a958208dcf4cc1aa8f59b9560b1e Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:11:24 +0200 Subject: [PATCH 25/96] fix: Correct namespace for REST API snippets test --- tests/test-rest-api-snippets.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test-rest-api-snippets.php b/tests/test-rest-api-snippets.php index fa7c5969..591e832d 100644 --- a/tests/test-rest-api-snippets.php +++ b/tests/test-rest-api-snippets.php @@ -1,6 +1,6 @@ Date: Tue, 17 Feb 2026 12:15:26 +0200 Subject: [PATCH 26/96] fix: Add step to delete previous PR failure comment on success in PHPUnit workflow --- .github/workflows/phpunit.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index afc16fab..127e4422 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -47,6 +47,30 @@ jobs: run: | echo "PHPUnit matrix result: ${{ needs.phpunit.result }}" + - name: Delete previous PR failure comment on success + if: github.event_name == 'pull_request' && needs.phpunit.result == 'success' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + - name: Post PR comment on failure if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' uses: actions/github-script@v7 From ee52a472acf2a63e5a14cbf030a0e786180d0520 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:22:06 +0200 Subject: [PATCH 27/96] feat: Enhance PHPUnit workflow with artifact download and error summary generation --- .github/workflows/phpunit.yml | 130 ++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 127e4422..707df933 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -71,18 +71,148 @@ jobs: }); } + - name: Download PHPUnit artifacts + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + uses: actions/download-artifact@v5 + with: + pattern: phpunit-test-results-php-* + path: phpunit-artifacts + merge-multiple: true + + - name: Build distinct error summary + if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' + run: | + set -euo pipefail + python3 - <<'PY' + import glob + import os + import re + import xml.etree.ElementTree as ET + from collections import defaultdict + + artifacts_dir = 'phpunit-artifacts' + + def version_from_path(path: str) -> str: + base = os.path.basename(path) + m = re.search(r'phpunit-([0-9]+\.[0-9]+)\.xml$', base) + if m: + return m.group(1) + m = re.search(r'phpunit-([0-9]+\.[0-9]+)\.log$', base) + if m: + return m.group(1) + return 'unknown' + + def add_entry(grouped, key, version, message): + if not message.strip(): + return + grouped[key]['versions'].add(version) + grouped[key]['message'] = message.strip() + + grouped = defaultdict(lambda: {'versions': set(), 'message': ''}) + versions_seen = set() + + # Prefer JUnit XML when present. + for xml_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.xml'), recursive=True)): + version = version_from_path(xml_path) + versions_seen.add(version) + try: + root = ET.parse(xml_path).getroot() + except Exception: + continue + + for testcase in root.iter('testcase'): + for tag in ('error', 'failure'): + for node in testcase.findall(tag): + etype = (node.attrib.get('type') or tag).strip() + msg = (node.attrib.get('message') or '').strip() + details = (node.text or '').strip() + combined = f"{etype}: {msg}".strip(': ') + if details: + combined = combined + "\n" + details + # Key by type+message first line to dedupe across versions. + key = (etype + "\n" + msg).strip() + if not key: + key = combined.splitlines()[0] if combined else 'unknown' + add_entry(grouped, key, version, combined) + + # Fallback: scan logs for fatals if XML missing. + fatal_re = re.compile(r'^(PHP\s+Fatal\s+error:.*|Fatal\s+error:.*)$', re.MULTILINE) + for log_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.log'), recursive=True)): + version = version_from_path(log_path) + versions_seen.add(version) + try: + log = open(log_path, 'r', encoding='utf-8', errors='replace').read() + except Exception: + continue + m = fatal_re.search(log) + if m: + msg = m.group(1).strip() + key = 'Fatal error\n' + msg + add_entry(grouped, key, version, msg) + + versions = sorted(v for v in versions_seen if v != 'unknown') + + def versions_label(affected): + affected = sorted(v for v in affected if v != 'unknown') + if versions and affected == versions: + return 'all' + return ', '.join(affected) if affected else 'unknown' + + blocks = [] + for key, info in sorted(grouped.items(), key=lambda kv: (-len(kv[1]['versions']), kv[0])): + affected = versions_label(info['versions']) + message = info['message'] + blocks.append( + "-----\n" + f"Affected PHP version: `{affected}` | `all`\n" + "```php\n" + f"{message}\n" + "```\n" + "-----" + ) + + if blocks: + details = "\n\n".join(blocks) + else: + details = "No distinct PHPUnit error details could be parsed from artifacts." + + md = "\n".join([ + "
", + "Distinct PHPUnit errors (click to expand)", + "", + details, + "", + "
", + "", + ]) + + with open('phpunit-errors.md', 'w', encoding='utf-8') as f: + f.write(md) + PY + - name: Post PR comment on failure if: github.event_name == 'pull_request' && needs.phpunit.result == 'failure' uses: actions/github-script@v7 with: script: | const marker = ''; + + const fs = require('fs'); + let details = ''; + try { + details = fs.readFileSync('phpunit-errors.md', 'utf8').trim(); + } catch (e) { + details = ''; + } + const body = [ marker, '## PHPUnit Test Failure', '', `One or more PHP version targets failed in [this workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, '', + details || '_No parsed error details found._', + '', 'Please review the failing jobs and fix the issues before merging.', ].join('\n'); From 5aa246a7ed0fb6db5d14c3c2e98066894a799fc5 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:22:10 +0200 Subject: [PATCH 28/96] feat: Enhance PHPUnit test execution with result logging and directory creation --- .github/workflows/phpunit-test.yml | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/.github/workflows/phpunit-test.yml b/.github/workflows/phpunit-test.yml index 7eb654dd..6f7c59df 100644 --- a/.github/workflows/phpunit-test.yml +++ b/.github/workflows/phpunit-test.yml @@ -105,15 +105,18 @@ jobs: - name: Run PHPUnit tests run: | + set -euo pipefail + mkdir -p test-results/phpunit cd src - vendor/bin/phpunit -c ../phpunit.xml --testdox + vendor/bin/phpunit -c ../phpunit.xml --testdox --log-junit ../test-results/phpunit/phpunit-${{ inputs.php-version }}.xml 2>&1 | tee ../test-results/phpunit/phpunit-${{ inputs.php-version }}.log - uses: actions/upload-artifact@v4 if: always() with: name: phpunit-test-results-php-${{ inputs.php-version }} path: | - .phpunit.result.cache + src/.phpunit.result.cache + test-results/phpunit/ if-no-files-found: ignore retention-days: 2 From 0bd4d6eb18427c766440e457c0443e9d70d6a291 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:24:12 +0200 Subject: [PATCH 29/96] feat: Add intentional failure tests for CI validation --- tests/test-ci-intentional-failures.php | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 tests/test-ci-intentional-failures.php diff --git a/tests/test-ci-intentional-failures.php b/tests/test-ci-intentional-failures.php new file mode 100644 index 00000000..8465fe8c --- /dev/null +++ b/tests/test-ci-intentional-failures.php @@ -0,0 +1,21 @@ +fail( 'CI demo: intentional failure on all PHP versions.' ); + } + + public function test_intentional_failure_php_8_3_and_8_4_only() { + if ( PHP_VERSION_ID >= 80300 && PHP_VERSION_ID < 80500 ) { + $this->fail( 'CI demo: intentional failure on PHP 8.3 and 8.4 only.' ); + } + + $this->assertTrue( true ); + } +} From b0096d79f776ed7fdcbba63149d7df9236027fc6 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:34:37 +0200 Subject: [PATCH 30/96] feat: Improve PHPUnit error message handling and deduplication logic --- .github/workflows/phpunit.yml | 40 ++++++++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 707df933..407421e2 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -106,7 +106,9 @@ jobs: if not message.strip(): return grouped[key]['versions'].add(version) - grouped[key]['message'] = message.strip() + # Keep the first representative message we see for this key. + if not grouped[key]['message']: + grouped[key]['message'] = message.strip() grouped = defaultdict(lambda: {'versions': set(), 'message': ''}) versions_seen = set() @@ -129,10 +131,32 @@ jobs: combined = f"{etype}: {msg}".strip(': ') if details: combined = combined + "\n" + details - # Key by type+message first line to dedupe across versions. - key = (etype + "\n" + msg).strip() - if not key: - key = combined.splitlines()[0] if combined else 'unknown' + + testcase_id = ( + (testcase.attrib.get('classname') or '').strip() + + '::' + + (testcase.attrib.get('name') or '').strip() + ).strip(':') + + extracted = '' + if not msg and details: + for line in details.splitlines(): + line = line.strip() + if line.startswith('CI demo:'): + extracted = line + break + if not extracted: + extracted = details.splitlines()[0].strip() + + # Dedupe key: prefer explicit message; otherwise use extracted details + testcase id. + key_parts = [etype] + if msg: + key_parts.append(msg) + elif extracted: + key_parts.append(extracted) + if testcase_id: + key_parts.append(testcase_id) + key = "\n".join([p for p in key_parts if p]).strip() or (combined.splitlines()[0] if combined else 'unknown') add_entry(grouped, key, version, combined) # Fallback: scan logs for fatals if XML missing. @@ -164,7 +188,7 @@ jobs: message = info['message'] blocks.append( "-----\n" - f"Affected PHP version: `{affected}` | `all`\n" + f"Affected PHP version: `{affected}`\n" "```php\n" f"{message}\n" "```\n" @@ -174,11 +198,11 @@ jobs: if blocks: details = "\n\n".join(blocks) else: - details = "No distinct PHPUnit error details could be parsed from artifacts." + details = "No PHPUnit error details could be parsed from artifacts." md = "\n".join([ "
", - "Distinct PHPUnit errors (click to expand)", + "See all PHPUnit errors (click to expand)", "", details, "", From e36aabd2a305e96c4ddaedef277f9f5b2af2fe4c Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:59:10 +0200 Subject: [PATCH 31/96] feat: Improve error summary formatting in PHPUnit results --- .github/workflows/phpunit.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 407421e2..a9fc67f7 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -182,18 +182,21 @@ jobs: return 'all' return ', '.join(affected) if affected else 'unknown' + items = sorted(grouped.items(), key=lambda kv: (-len(kv[1]['versions']), kv[0])) blocks = [] - for key, info in sorted(grouped.items(), key=lambda kv: (-len(kv[1]['versions']), kv[0])): + for idx, (key, info) in enumerate(items): affected = versions_label(info['versions']) message = info['message'] - blocks.append( + block = ( "-----\n" f"Affected PHP version: `{affected}`\n" "```php\n" f"{message}\n" - "```\n" - "-----" + "```" ) + if idx == len(items) - 1: + block += "\n-----" + blocks.append(block) if blocks: details = "\n\n".join(blocks) From 74600df05fedc25657360aaafd5c527c78192f1a Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 12:59:16 +0200 Subject: [PATCH 32/96] feat: Enhance Playwright workflow with error summary and report normalization --- .github/workflows/playwright-test.yml | 17 ++- .github/workflows/playwright.yml | 201 +++++++++++++++++++++++++- 2 files changed, 215 insertions(+), 3 deletions(-) diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index 59f5b4a9..c8cdda90 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -230,6 +230,21 @@ jobs: WP_E2E_WP_CONTAINER: ${{ job.services.wordpress.id }} WP_E2E_MULTISITE_MODE: ${{ inputs.multisite }} run: npm run test:playwright -- --project=${{ inputs.project-name }} + + - name: Normalize Playwright report filenames + if: always() + run: | + set -euo pipefail + mkdir -p test-results/ci + suffix="${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }}" + + if [ -f test-results/results.xml ]; then + mv test-results/results.xml "test-results/ci/results-${suffix}.xml" + fi + + if [ -f test-results/results.json ]; then + mv test-results/results.json "test-results/ci/results-${suffix}.json" + fi - name: Print WordPress logs on failure if: failure() @@ -241,7 +256,7 @@ jobs: - uses: actions/upload-artifact@v4 if: always() with: - name: playwright-test-results-${{ inputs.test-mode }} + name: playwright-test-results-${{ inputs.test-mode }}-${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }} path: test-results/ if-no-files-found: ignore retention-days: 2 diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 51f39bd4..29bce5a8 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -15,8 +15,8 @@ on: workflow_dispatch: permissions: - contents: write - pull-requests: write + contents: read + pull-requests: read actions: read concurrency: @@ -45,12 +45,209 @@ jobs: if: always() && (needs.playwright-default.result != 'skipped' || needs.playwright-file-based-execution.result != 'skipped') runs-on: ubuntu-22.04 name: Playwright - Test Results Summary + permissions: + pull-requests: write + issues: write steps: - name: Test status summary run: | echo "Default Mode: ${{ needs.playwright-default.result }}" echo "File-based Execution: ${{ needs.playwright-file-based-execution.result }}" + - name: Delete previous PR failure comment on success + if: github.event_name == 'pull_request' && needs.playwright-default.result == 'success' && needs.playwright-file-based-execution.result == 'success' + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + - name: Download Playwright artifacts + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + uses: actions/download-artifact@v5 + with: + pattern: playwright-test-results-* + path: playwright-artifacts + merge-multiple: true + + - name: Build distinct error summary + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + run: | + set -euo pipefail + python3 - <<'PY' + import glob + import os + import re + import xml.etree.ElementTree as ET + from collections import defaultdict + + artifacts_dir = 'playwright-artifacts' + + def target_from_path(path: str) -> str: + base = os.path.basename(path) + m = re.search(r'results-(.+)\.xml$', base) + if m: + return m.group(1) + return 'unknown' + + def add_entry(grouped, key, target, message): + if not message.strip(): + return + grouped[key]['targets'].add(target) + if not grouped[key]['message']: + grouped[key]['message'] = message.strip() + + grouped = defaultdict(lambda: {'targets': set(), 'message': ''}) + targets_seen = set() + + for xml_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.xml'), recursive=True)): + target = target_from_path(xml_path) + targets_seen.add(target) + try: + root = ET.parse(xml_path).getroot() + except Exception: + continue + + for testcase in root.iter('testcase'): + for tag in ('error', 'failure'): + for node in testcase.findall(tag): + etype = (node.attrib.get('type') or tag).strip() + msg = (node.attrib.get('message') or '').strip() + details = (node.text or '').strip() + + combined = f"{etype}: {msg}".strip(': ') + if details: + combined = combined + "\n" + details + + testcase_id = ( + (testcase.attrib.get('classname') or '').strip() + + '::' + + (testcase.attrib.get('name') or '').strip() + ).strip(':') + + extracted = '' + if not msg and details: + extracted = details.splitlines()[0].strip() + + key_parts = [etype] + if msg: + key_parts.append(msg) + elif extracted: + key_parts.append(extracted) + if testcase_id: + key_parts.append(testcase_id) + key = "\n".join([p for p in key_parts if p]).strip() or (combined.splitlines()[0] if combined else 'unknown') + + add_entry(grouped, key, target, combined) + + targets = sorted(t for t in targets_seen if t != 'unknown') + + def targets_label(affected): + affected = sorted(t for t in affected if t != 'unknown') + if targets and affected == targets: + return 'all' + return ', '.join(affected) if affected else 'unknown' + + items = sorted(grouped.items(), key=lambda kv: (-len(kv[1]['targets']), kv[0])) + blocks = [] + for idx, (key, info) in enumerate(items): + affected = targets_label(info['targets']) + message = info['message'] + block = ( + "-----\n" + f"Affected Playwright test: `{affected}`\n" + "```text\n" + f"{message}\n" + "```" + ) + if idx == len(items) - 1: + block += "\n-----" + blocks.append(block) + + if blocks: + details_md = "\n\n".join(blocks) + else: + details_md = "No distinct Playwright error details could be parsed from artifacts." + + md = "\n".join([ + "
", + "Distinct Playwright errors (click to expand)", + "", + details_md, + "", + "
", + "", + ]) + + with open('playwright-errors.md', 'w', encoding='utf-8') as f: + f.write(md) + PY + + - name: Post PR comment on failure + if: github.event_name == 'pull_request' && (needs.playwright-default.result == 'failure' || needs.playwright-file-based-execution.result == 'failure') + uses: actions/github-script@v7 + with: + script: | + const marker = ''; + + const fs = require('fs'); + let details = ''; + try { + details = fs.readFileSync('playwright-errors.md', 'utf8').trim(); + } catch (e) { + details = ''; + } + + const body = [ + marker, + '## Playwright Test Failure', + '', + `One or more Playwright targets failed in [this workflow run](${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}).`, + '', + details || '_No parsed error details found._', + '', + 'Please review the failing jobs and fix the issues before merging.', + ].join('\n'); + + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + per_page: 100, + }); + + const existing = comments.filter(c => typeof c.body === 'string' && c.body.startsWith(marker)); + for (const comment of existing) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: comment.id, + }); + } + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + - name: Check overall status if: ${{ (needs.playwright-default.result != 'success' && needs.playwright-default.result != 'skipped') || (needs.playwright-file-based-execution.result != 'success' && needs.playwright-file-based-execution.result != 'skipped') }} run: exit 1 From 116b3cc9b88151d14b51f74f3d4cd829a49b4503 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 13:41:20 +0200 Subject: [PATCH 33/96] feat: Enhance Playwright test logging and error reporting --- .github/workflows/playwright-test.yml | 6 +- .github/workflows/playwright.yml | 86 ++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index c8cdda90..505a0fec 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -229,7 +229,11 @@ jobs: WP_E2E_WPCLI_URL: http://localhost:8888 WP_E2E_WP_CONTAINER: ${{ job.services.wordpress.id }} WP_E2E_MULTISITE_MODE: ${{ inputs.multisite }} - run: npm run test:playwright -- --project=${{ inputs.project-name }} + run: | + set -euo pipefail + mkdir -p test-results/ci + suffix="${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }}" + npm run test:playwright -- --project=${{ inputs.project-name }} 2>&1 | tee "test-results/ci/playwright-${suffix}.log" - name: Normalize Playwright report filenames if: always() diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 29bce5a8..acd18c07 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -92,6 +92,7 @@ jobs: set -euo pipefail python3 - <<'PY' import glob + import json import os import re import xml.etree.ElementTree as ET @@ -101,7 +102,7 @@ jobs: def target_from_path(path: str) -> str: base = os.path.basename(path) - m = re.search(r'results-(.+)\.xml$', base) + m = re.search(r'(?:results|playwright)-(.+)\.(?:xml|json|log)$', base) if m: return m.group(1) return 'unknown' @@ -116,6 +117,63 @@ jobs: grouped = defaultdict(lambda: {'targets': set(), 'message': ''}) targets_seen = set() + def safe_str(val) -> str: + if val is None: + return '' + if isinstance(val, str): + return val + try: + return str(val) + except Exception: + return '' + + def walk_suite(suite, title_path): + # Playwright JSON reporter structure: suites -> suites/specs. + for child in suite.get('suites', []) or []: + walk_suite(child, title_path + [safe_str(child.get('title'))]) + + for spec in suite.get('specs', []) or []: + spec_title = safe_str(spec.get('title')) + for test in spec.get('tests', []) or []: + test_title = safe_str(test.get('title')) + for result in test.get('results', []) or []: + status = safe_str(result.get('status')) + err = result.get('error') or {} + err_msg = safe_str(err.get('message')) + err_stack = safe_str(err.get('stack')) + + if status != 'failed' and not err_msg and not err_stack: + continue + + name = ' > '.join([p for p in (title_path + [spec_title, test_title]) if p]) + combined = name + if err_msg: + combined += "\n" + err_msg + if err_stack: + combined += "\n" + err_stack + yield combined.strip() + + # 1) Parse JSON reporter output (most reliable for Playwright failures). + for json_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.json'), recursive=True)): + target = target_from_path(json_path) + targets_seen.add(target) + try: + data = json.loads(open(json_path, 'r', encoding='utf-8', errors='replace').read()) + except Exception: + continue + + suites = data.get('suites', []) if isinstance(data, dict) else [] + if not suites: + continue + + for suite in suites: + for combined in walk_suite(suite, [safe_str(suite.get('title'))]): + # Dedupe key: first line of error message + test name line. + first = combined.splitlines()[0].strip() if combined else 'unknown' + key = first + add_entry(grouped, key, target, combined) + + # 2) Parse JUnit XML (secondary; may be missing on some runner failures). for xml_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.xml'), recursive=True)): target = target_from_path(xml_path) targets_seen.add(target) @@ -156,6 +214,28 @@ jobs: add_entry(grouped, key, target, combined) + # 3) Fallback: scan logs if JSON/XML were not usable. + if not grouped: + # Try to extract something meaningful from Playwright logs. + error_line_re = re.compile(r'^(Error:.*|\s+at\s+.*|expect\(.*\).*)$', re.MULTILINE) + for log_path in sorted(glob.glob(os.path.join(artifacts_dir, '**', '*.log'), recursive=True)): + target = target_from_path(log_path) + targets_seen.add(target) + try: + text = open(log_path, 'r', encoding='utf-8', errors='replace').read() + except Exception: + continue + + # Prefer a compact tail if we can't find explicit error lines. + lines = [ln.rstrip() for ln in text.splitlines()] + tail = "\n".join(lines[-80:]) + + matches = error_line_re.findall(text) + extracted = "\n".join(matches[:80]).strip() if matches else tail.strip() + if extracted: + key = extracted.splitlines()[0][:200] + add_entry(grouped, key, target, extracted) + targets = sorted(t for t in targets_seen if t != 'unknown') def targets_label(affected): @@ -183,11 +263,11 @@ jobs: if blocks: details_md = "\n\n".join(blocks) else: - details_md = "No distinct Playwright error details could be parsed from artifacts." + details_md = "No Playwright error details could be parsed from artifacts." md = "\n".join([ "
", - "Distinct Playwright errors (click to expand)", + "See all Playwright errors (click to expand)", "", details_md, "", From 66dd59b8885fe02e2454435e447bfc77f4bcd6a0 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 14:07:56 +0200 Subject: [PATCH 34/96] feat: Remove intentional failure tests for CI validation --- tests/test-ci-intentional-failures.php | 21 --------------------- 1 file changed, 21 deletions(-) delete mode 100644 tests/test-ci-intentional-failures.php diff --git a/tests/test-ci-intentional-failures.php b/tests/test-ci-intentional-failures.php deleted file mode 100644 index 8465fe8c..00000000 --- a/tests/test-ci-intentional-failures.php +++ /dev/null @@ -1,21 +0,0 @@ -fail( 'CI demo: intentional failure on all PHP versions.' ); - } - - public function test_intentional_failure_php_8_3_and_8_4_only() { - if ( PHP_VERSION_ID >= 80300 && PHP_VERSION_ID < 80500 ) { - $this->fail( 'CI demo: intentional failure on PHP 8.3 and 8.4 only.' ); - } - - $this->assertTrue( true ); - } -} From 348a53a6812829546449eaf6512e7e8dd0798b14 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 14:23:07 +0200 Subject: [PATCH 35/96] feat: Update Playwright reporter output paths to use absolute paths --- tests/playwright/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 10c2e67f..27df7ad4 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -17,8 +17,8 @@ export default defineConfig({ workers: process.env.CI ? WORKERS : undefined, reporter: [ ['html'], - ['json', { outputFile: 'test-results/results.json' }], - ['junit', { outputFile: 'test-results/results.xml' }] + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] ], use: { baseURL: 'http://localhost:8888', From 49ed6f3e7d03220dcb47a2b425c1b188dd3ef175 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 14:23:14 +0200 Subject: [PATCH 36/96] feat: Increase WordPress memory limits and enhance Playwright test logging --- .github/workflows/playwright-test.yml | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index 505a0fec..70270254 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -45,6 +45,9 @@ jobs: WORDPRESS_DEBUG: 1 WORDPRESS_CONFIG_EXTRA: | define( 'FS_METHOD', 'direct' ); + define( 'WP_MEMORY_LIMIT', '256M' ); + define( 'WP_MAX_MEMORY_LIMIT', '512M' ); + @ini_set( 'memory_limit', '512M' ); define( 'WP_DEBUG_LOG', true ); define( 'WP_DEBUG_DISPLAY', false ); define( 'SCRIPT_DEBUG', true ); @@ -233,7 +236,8 @@ jobs: set -euo pipefail mkdir -p test-results/ci suffix="${{ inputs.project-name }}${{ inputs.multisite && '-multisite' || '' }}" - npm run test:playwright -- --project=${{ inputs.project-name }} 2>&1 | tee "test-results/ci/playwright-${suffix}.log" + : > "test-results/ci/playwright-${suffix}.log" + npm run test:playwright -- --project=${{ inputs.project-name }} 2>&1 | tee -a "test-results/ci/playwright-${suffix}.log" - name: Normalize Playwright report filenames if: always() @@ -244,10 +248,14 @@ jobs: if [ -f test-results/results.xml ]; then mv test-results/results.xml "test-results/ci/results-${suffix}.xml" + elif [ -f tests/playwright/test-results/results.xml ]; then + mv tests/playwright/test-results/results.xml "test-results/ci/results-${suffix}.xml" fi if [ -f test-results/results.json ]; then mv test-results/results.json "test-results/ci/results-${suffix}.json" + elif [ -f tests/playwright/test-results/results.json ]; then + mv tests/playwright/test-results/results.json "test-results/ci/results-${suffix}.json" fi - name: Print WordPress logs on failure From f14d80d3f419bef422a17a1d5241417fa269847c Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 14:44:56 +0200 Subject: [PATCH 37/96] feat: Enhance WordPress debugging settings in Playwright workflow --- .github/workflows/playwright-test.yml | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index 70270254..b80e3c10 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -45,13 +45,16 @@ jobs: WORDPRESS_DEBUG: 1 WORDPRESS_CONFIG_EXTRA: | define( 'FS_METHOD', 'direct' ); - define( 'WP_MEMORY_LIMIT', '256M' ); - define( 'WP_MAX_MEMORY_LIMIT', '512M' ); - @ini_set( 'memory_limit', '512M' ); + define( 'WP_DEBUG', true ); define( 'WP_DEBUG_LOG', true ); - define( 'WP_DEBUG_DISPLAY', false ); + define( 'WP_DEBUG_DISPLAY', true ); + define( 'WP_DISABLE_FATAL_ERROR_HANDLER', true ); define( 'SCRIPT_DEBUG', true ); define( 'WP_ENVIRONMENT_TYPE', 'local' ); + @ini_set( 'display_errors', '1' ); + @ini_set( 'display_startup_errors', '1' ); + @ini_set( 'log_errors', '1' ); + @ini_set( 'error_reporting', (string) E_ALL ); ports: - 8888:80 steps: From c0bfcb0474850f15d7718dcb0734003ccd077d45 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 14:53:03 +0200 Subject: [PATCH 38/96] feat: Add conditional display for admin bar snippets limit setting --- src/php/Settings/Settings_Fields.php | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/src/php/Settings/Settings_Fields.php b/src/php/Settings/Settings_Fields.php index 0e220955..b11b3e90 100644 --- a/src/php/Settings/Settings_Fields.php +++ b/src/php/Settings/Settings_Fields.php @@ -229,16 +229,19 @@ private function init_fields() { 'label' => __( 'Show a Snippets menu in the admin bar for quick access to snippets.', 'code-snippets' ), ]; - if ( get_setting( 'general', 'enable_admin_bar' ) ) { - $this->fields['general']['admin_bar_snippet_limit'] = [ - 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), - 'type' => 'number', - 'desc' => __( 'Number of snippets to show in the admin bar Active/Inactive menus before paginating.', 'code-snippets' ), - 'label' => __( 'snippets', 'code-snippets' ), - 'min' => 1, - 'max' => 100, - ]; - } + $this->fields['general']['admin_bar_snippet_limit'] = [ + 'name' => __( 'Admin Bar Snippets Per Page', 'code-snippets' ), + 'type' => 'number', + 'desc' => __( 'Number of snippets to show in the admin bar Active/Inactive menus before paginating.', 'code-snippets' ), + 'label' => __( 'snippets', 'code-snippets' ), + 'min' => 1, + 'max' => 100, + 'show_if' => [ + 'section' => 'general', + 'field' => 'enable_admin_bar', + 'value' => true, + ], + ]; $this->fields['editor'] = [ 'indent_with_tabs' => [ From 63a0348593f4932493e27d5b495532ebc2746d32 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 14:53:08 +0200 Subject: [PATCH 39/96] feat: Implement conditional rendering for settings fields based on current values --- src/php/Settings/settings.php | 59 +++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/src/php/Settings/settings.php b/src/php/Settings/settings.php index 350aa03e..d5a0c9d9 100644 --- a/src/php/Settings/settings.php +++ b/src/php/Settings/settings.php @@ -125,6 +125,8 @@ function register_plugin_settings() { add_self_option( are_settings_unified(), OPTION_NAME, Settings_Fields::get_default_values() ); } + $current_settings = get_settings_values(); + // Register the setting. register_setting( OPTION_GROUP, @@ -145,6 +147,10 @@ function register_plugin_settings() { } foreach ( $fields as $field_id => $field ) { + if ( ! should_render_setting_field( $field, $current_settings ) ) { + continue; + } + $field_object = new Setting_Field( $section_id, $field_id, $field ); add_settings_field( $field_id, $field['name'], [ $field_object, 'render' ], 'code-snippets', $section_id ); } @@ -166,6 +172,56 @@ function register_plugin_settings() { add_action( 'admin_init', __NAMESPACE__ . '\\register_plugin_settings' ); +/** + * Determine whether a setting field should be rendered. + * + * @param array $field Field definition. + * @param array> $settings Current settings values. + * @param array>|null $input Optional raw input values. + * + * @return bool + */ +function should_render_setting_field( array $field, array $settings, ?array $input = null ): bool { + if ( empty( $field['show_if'] ) || ! is_array( $field['show_if'] ) ) { + return true; + } + + $show_if = array_merge( + [ + 'section' => '', + 'field' => '', + 'value' => true, + ], + $field['show_if'] + ); + + $section = is_string( $show_if['section'] ) ? $show_if['section'] : ''; + $field_id = is_string( $show_if['field'] ) ? $show_if['field'] : ''; + $expected = $show_if['value']; + + if ( '' === $section || '' === $field_id ) { + return true; + } + + $actual = null; + + if ( is_array( $input ) && isset( $input[ $section ] ) && is_array( $input[ $section ] ) && array_key_exists( $field_id, $input[ $section ] ) ) { + $actual = $input[ $section ][ $field_id ]; + } elseif ( isset( $settings[ $section ] ) && array_key_exists( $field_id, $settings[ $section ] ) ) { + $actual = $settings[ $section ][ $field_id ]; + } + + if ( is_bool( $expected ) ) { + if ( is_bool( $actual ) ) { + return $actual === $expected; + } + + return ( 'on' === $actual ) === $expected; + } + + return $actual === $expected; +} + /** * Sanitize a single setting value. * @@ -286,6 +342,9 @@ function sanitize_settings( array $input ): array { // Don't directly loop through $input as it does not include as deselected checkboxes. foreach ( Settings_Fields::get_field_definitions() as $section_id => $fields ) { foreach ( $fields as $field_id => $field ) { + if ( ! should_render_setting_field( $field, $settings, $input ) ) { + continue; + } // Fetch the corresponding input value from the posted data. $input_value = $input[ $section_id ][ $field_id ] ?? null; From 0502a848dd6407b7bcf672b16471a78272a35f97 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 15:07:05 +0200 Subject: [PATCH 40/96] feat: Update Playwright reporter configuration for CI environment --- tests/playwright/playwright.config.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 27df7ad4..56cfeb79 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -15,11 +15,18 @@ export default defineConfig({ forbidOnly: !!process.env.CI, retries: process.env.CI ? RETRIES : 0, workers: process.env.CI ? WORKERS : undefined, - reporter: [ - ['html'], - ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], - ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] - ], + reporter: process.env.CI + ? [ + ['line'], + ['html'], + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] + ] + : [ + ['html'], + ['json', { outputFile: join(process.cwd(), 'test-results', 'results.json') }], + ['junit', { outputFile: join(process.cwd(), 'test-results', 'results.xml') }] + ], use: { baseURL: 'http://localhost:8888', trace: 'on-first-retry', From 714b93390ec734a84cc35d8f759436309bed12ec Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 15:13:12 +0200 Subject: [PATCH 41/96] feat: Update caching keys in Playwright workflow for improved dependency management --- .github/workflows/playwright-test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index b80e3c10..fa5d7509 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -96,9 +96,9 @@ jobs: path: | node_modules src/vendor - key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}-${{ github.run_id }}-${{ github.job }} + key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}- restore-keys: | - ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}- + ${{ runner.os }}-${{ inputs.test-mode }}-deps- - name: Install workflow dependencies if: steps.deps-cache.outputs.cache-matched-key == '' @@ -111,7 +111,7 @@ jobs: path: | src/vendor node_modules - key: ${{ steps.deps-cache.outputs.cache-primary-key }} + key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}-${{ github.run_id }}-${{ github.job }} - name: Wait for WordPress to be reachable run: | From 501d68a63e88c7f3fc961842720b3463a3310668 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 15:21:40 +0200 Subject: [PATCH 42/96] feat: Adjust Playwright configuration for retries and timeout settings --- tests/playwright/playwright.config.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 56cfeb79..56762137 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -2,7 +2,6 @@ import { join } from 'path' import { defineConfig, devices } from '@playwright/test' -const RETRIES = 2 const WORKERS = 1 /** @@ -13,7 +12,7 @@ export default defineConfig({ snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}-{platform}{ext}', fullyParallel: true, forbidOnly: !!process.env.CI, - retries: process.env.CI ? RETRIES : 0, + retries: 0, workers: process.env.CI ? WORKERS : undefined, reporter: process.env.CI ? [ @@ -29,7 +28,7 @@ export default defineConfig({ ], use: { baseURL: 'http://localhost:8888', - trace: 'on-first-retry', + trace: 'retain-on-failure', screenshot: 'only-on-failure', video: 'retain-on-failure' }, @@ -71,7 +70,7 @@ export default defineConfig({ } ], - timeout: 30000, + timeout: 5000, // expect: { timeout: 10000, From 2639901f30b2b2b44a726026009446419abc690f Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 16:20:43 +0200 Subject: [PATCH 43/96] fix: Update E2E test selectors and snippet type/location constants to align with recent UI changes. --- tests/e2e/helpers/constants.ts | 92 +++++++++++++++++----------------- 1 file changed, 46 insertions(+), 46 deletions(-) diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index 1686accf..843def44 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -1,65 +1,65 @@ -export const SELECTORS = { - WPBODY_CONTENT: '#wpbody-content, .wrap, #wpcontent', - PAGE_TITLE: 'h1, .page-title', - ADD_NEW_BUTTON: '.page-title-action', +export const SELECTORS = { + WPBODY_CONTENT: '#wpbody-content, .wrap, #wpcontent', + PAGE_TITLE: 'h1, .page-title', + ADD_NEW_BUTTON: '.create-snippet-button', - TITLE_INPUT: '#title', - CODE_MIRROR_TEXTAREA: '.CodeMirror textarea', - SNIPPET_TYPE_SELECT: '#snippet-type-select-input', - LOCATION_SELECT: '.code-snippets-select-location', + TITLE_INPUT: '#title', + CODE_MIRROR_TEXTAREA: '.CodeMirror textarea', + SNIPPET_TYPE_SELECT: '#snippet-type-select-input', + LOCATION_SELECT: '.code-snippets-select-location', - SUCCESS_MESSAGE: '#message.notice', - SUCCESS_MESSAGE_P: '#message.notice p', + SUCCESS_MESSAGE: '#message.notice', + SUCCESS_MESSAGE_P: '#message.notice p', - DELETE_CONFIRM_BUTTON: 'button.components-button.is-destructive.is-primary', + DELETE_CONFIRM_BUTTON: 'button.components-button.is-destructive.is-primary', - SNIPPETS_TABLE: '.wp-list-table', - SNIPPET_ROW: '.wp-list-table tbody tr', - SNIPPET_TOGGLE: '.snippet-activation-switch input[type="checkbox"]', - SNIPPET_NAME_LINK: '.row-title', + SNIPPETS_TABLE: '.wp-list-table', + SNIPPET_ROW: '.wp-list-table tbody tr', + SNIPPET_TOGGLE: '.switch', + SNIPPET_NAME_LINK: '.snippet-name', - EDIT_ACTION: '.row-actions .edit a', - CLONE_ACTION: '.row-actions .clone a', - DELETE_ACTION: '.row-actions .delete a', - EXPORT_ACTION: '.row-actions .export a', + EDIT_ACTION: '.row-actions a', + CLONE_ACTION: '.row-actions button:has-text("Clone")', + DELETE_ACTION: '.row-actions button.delete', + EXPORT_ACTION: '.row-actions button:has-text("Export")', - ADMIN_BAR: '#wpadminbar' + ADMIN_BAR: '#wpadminbar' } -export const TIMEOUTS = { - DEFAULT: 10000, - SHORT: 5000 +export const TIMEOUTS = { + DEFAULT: 10000, + SHORT: 5000 } -export const URLS = { - SNIPPETS_ADMIN: '/wp-admin/admin.php?page=snippets', - FRONTEND: '/' +export const URLS = { + SNIPPETS_ADMIN: '/wp-admin/admin.php?page=snippets', + FRONTEND: '/' } -export const MESSAGES = { - SNIPPET_CREATED: 'Snippet created', - SNIPPET_CREATED_AND_ACTIVATED: 'Snippet created and activated', - SNIPPET_UPDATED_AND_ACTIVATED: 'Snippet updated and activated', - SNIPPET_UPDATED_AND_DEACTIVATED: 'Snippet updated and deactivated' +export const MESSAGES = { + SNIPPET_CREATED: 'Snippet created', + SNIPPET_CREATED_AND_ACTIVATED: 'Snippet created and activated', + SNIPPET_UPDATED_AND_ACTIVATED: 'Snippet updated and activated', + SNIPPET_UPDATED_AND_DEACTIVATED: 'Snippet updated and deactivated' } -export const SNIPPET_TYPES = { - PHP: 'PHP', - HTML: 'HTML' +export const SNIPPET_TYPES = { + PHP: 'Functions', + HTML: 'Content' } -export const SNIPPET_LOCATIONS = { - SITE_FOOTER: 'In site footer', - SITE_HEADER: 'In site section', - IN_EDITOR: 'Where inserted in editor', - ADMIN_ONLY: 'Only run in administration area', - FRONTEND_ONLY: 'Only run on site front-end', - EVERYWHERE: 'Run everywhere' +export const SNIPPET_LOCATIONS = { + SITE_FOOTER: 'In site footer (end of )', + SITE_HEADER: 'In site section', + IN_EDITOR: 'Where inserted in editor', + ADMIN_ONLY: 'Only run in administration area', + FRONTEND_ONLY: 'Only run on site front-end', + EVERYWHERE: 'Run everywhere' } -export const BUTTONS = { - SAVE: 'text=Save Snippet', - SAVE_AND_ACTIVATE: 'text=Save and Activate', - SAVE_AND_DEACTIVATE: 'text=Save and Deactivate', - DELETE: 'text=Delete' +export const BUTTONS = { + SAVE: 'text=Save Snippet', + SAVE_AND_ACTIVATE: 'text=Save and Activate', + SAVE_AND_DEACTIVATE: 'text=Save and Deactivate', + DELETE: 'text=Delete' } From 185d55d9affdd7817ea1e8b31f7cd73fa68d9b34 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 16:20:50 +0200 Subject: [PATCH 44/96] refactor: replace hardcoded snippet activation switch selector with `SELECTORS.SNIPPET_TOGGLE` constant. --- tests/e2e/code-snippets-list.spec.ts | 132 +++++++++++++-------------- 1 file changed, 66 insertions(+), 66 deletions(-) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 07ecedf5..0b67953b 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -5,97 +5,97 @@ import { SELECTORS } from './helpers/constants' const TEST_SNIPPET_NAME = 'E2E List Test Snippet' test.describe('Code Snippets List Page Actions', () => { - let helper: SnippetsTestHelper + let helper: SnippetsTestHelper - test.beforeEach(async ({ page }) => { - helper = new SnippetsTestHelper(page) - await helper.navigateToSnippetsAdmin() + test.beforeEach(async ({ page }) => { + helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() - await helper.createAndActivateSnippet({ - name: TEST_SNIPPET_NAME, - code: "add_filter('show_admin_bar', '__return_false');" - }) - await helper.navigateToSnippetsAdmin() - }) + await helper.createAndActivateSnippet({ + name: TEST_SNIPPET_NAME, + code: "add_filter('show_admin_bar', '__return_false');" + }) + await helper.navigateToSnippetsAdmin() + }) - test.afterEach(async () => { - await helper.cleanupSnippet(TEST_SNIPPET_NAME) - }) + test.afterEach(async () => { + await helper.cleanupSnippet(TEST_SNIPPET_NAME) + }) - test('Can toggle snippet activation from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const toggleSwitch = snippetRow.locator('a.snippet-activation-switch') + test('Can toggle snippet activation from list page', async ({ page }) => { + const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const toggleSwitch = snippetRow.locator(SELECTORS.SNIPPET_TOGGLE) - await expect(toggleSwitch).toHaveAttribute('title', 'Deactivate') + await expect(toggleSwitch).toHaveAttribute('title', 'Deactivate') - await toggleSwitch.click() - await page.waitForLoadState('networkidle') + await toggleSwitch.click() + await page.waitForLoadState('networkidle') - const updatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const updatedToggle = updatedRow.locator('a.snippet-activation-switch') - await expect(updatedToggle).toHaveAttribute('title', 'Activate') + const updatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const updatedToggle = updatedRow.locator(SELECTORS.SNIPPET_TOGGLE) + await expect(updatedToggle).toHaveAttribute('title', 'Activate') - await updatedToggle.click() - await page.waitForLoadState('networkidle') + await updatedToggle.click() + await page.waitForLoadState('networkidle') - const reactivatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const reactivatedToggle = reactivatedRow.locator('a.snippet-activation-switch') - await expect(reactivatedToggle).toHaveAttribute('title', 'Deactivate') - }) + const reactivatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const reactivatedToggle = reactivatedRow.locator(SELECTORS.SNIPPET_TOGGLE) + await expect(reactivatedToggle).toHaveAttribute('title', 'Deactivate') + }) - test('Can access edit from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + test('Can access edit from list page', async ({ page }) => { + const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - await snippetRow.locator(SELECTORS.EDIT_ACTION).click() + await snippetRow.locator(SELECTORS.EDIT_ACTION).click() - await expect(page).toHaveURL(/page=edit-snippet/) - await expect(page.locator('#title')).toHaveValue(TEST_SNIPPET_NAME) - }) + await expect(page).toHaveURL(/page=edit-snippet/) + await expect(page.locator('#title')).toHaveValue(TEST_SNIPPET_NAME) + }) - test('Can clone snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + test('Can clone snippet from list page', async ({ page }) => { + const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - await snippetRow.locator(SELECTORS.CLONE_ACTION).click() - await page.waitForLoadState('networkidle') + await snippetRow.locator(SELECTORS.CLONE_ACTION).click() + await page.waitForLoadState('networkidle') - await expect(page).toHaveURL(/page=snippets/) + await expect(page).toHaveURL(/page=snippets/) - await helper.expectTextVisible(`${TEST_SNIPPET_NAME} [CLONE]`) + await helper.expectTextVisible(`${TEST_SNIPPET_NAME} [CLONE]`) - const clonedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME} [CLONE]")`) + const clonedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME} [CLONE]")`) - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) + page.on('dialog', async dialog => { + expect(dialog.type()).toBe('confirm') + await dialog.accept() + }) - await clonedRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') - }) + await clonedRow.locator(SELECTORS.DELETE_ACTION).click() + await page.waitForLoadState('networkidle') + }) - test('Can delete snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + test('Can delete snippet from list page', async ({ page }) => { + const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) + page.on('dialog', async dialog => { + expect(dialog.type()).toBe('confirm') + await dialog.accept() + }) - await snippetRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + await snippetRow.locator(SELECTORS.DELETE_ACTION).click() + await page.waitForLoadState('networkidle') - await expect(page).toHaveURL(/page=snippets/) - await helper.expectElementCount(`tr:has-text("${TEST_SNIPPET_NAME}")`, 0) - }) + await expect(page).toHaveURL(/page=snippets/) + await helper.expectElementCount(`tr:has-text("${TEST_SNIPPET_NAME}")`, 0) + }) - test('Can export snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + test('Can export snippet from list page', async ({ page }) => { + const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const downloadPromise = page.waitForEvent('download') + const downloadPromise = page.waitForEvent('download') - await snippetRow.locator(SELECTORS.EXPORT_ACTION).click() + await snippetRow.locator(SELECTORS.EXPORT_ACTION).click() - const download = await downloadPromise - expect(download.suggestedFilename()).toMatch(/\.json$/) - }) + const download = await downloadPromise + expect(download.suggestedFilename()).toMatch(/\.json$/) + }) }) From 0ff95b7b7a20def04dfdee45c0422ac8bee1f614 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 16:46:05 +0200 Subject: [PATCH 45/96] fix: Update Playwright test workflow to correctly handle cache keys and cache hit detection. --- .github/workflows/playwright-test.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/playwright-test.yml b/.github/workflows/playwright-test.yml index fa5d7509..35a49528 100644 --- a/.github/workflows/playwright-test.yml +++ b/.github/workflows/playwright-test.yml @@ -96,22 +96,22 @@ jobs: path: | node_modules src/vendor - key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}- + key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }} restore-keys: | ${{ runner.os }}-${{ inputs.test-mode }}-deps- - name: Install workflow dependencies - if: steps.deps-cache.outputs.cache-matched-key == '' + if: steps.deps-cache.outputs.cache-hit != 'true' run: npm run prepare-environment:ci && npm run bundle - name: Save vendor and node_modules cache - if: steps.deps-cache.outputs.cache-matched-key == '' + if: steps.deps-cache.outputs.cache-hit != 'true' uses: actions/cache/save@v4 with: path: | src/vendor node_modules - key: ${{ runner.os }}-${{ inputs.test-mode }}-deps-${{ steps.deps-hash.outputs.deps_hash }}-${{ github.run_id }}-${{ github.job }} + key: ${{ steps.deps-cache.outputs.cache-primary-key }} - name: Wait for WordPress to be reachable run: | From 26cd4a5eef66b4672fdf18738e3f8a499508338e Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 20:35:51 +0200 Subject: [PATCH 46/96] fix: Update navigation and selector logic in SnippetsTestHelper --- tests/e2e/helpers/SnippetsTestHelper.ts | 33 ++++++++++++++++--------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index d67b5e18..7107e460 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -25,8 +25,7 @@ export class SnippetsTestHelper { */ async navigateToSnippetsAdmin(): Promise { await this.page.goto(URLS.SNIPPETS_ADMIN) - await this.page.waitForLoadState('networkidle') - await this.page.waitForSelector(SELECTORS.WPBODY_CONTENT, { timeout: TIMEOUTS.DEFAULT }) + await this.page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) } /** @@ -34,16 +33,15 @@ export class SnippetsTestHelper { */ async navigateToFrontend(): Promise { await this.page.goto(URLS.FRONTEND) - await this.page.waitForLoadState('networkidle') + await this.page.waitForSelector('body', { timeout: TIMEOUTS.DEFAULT }) } /** * Click the "Add New" button to start creating a snippet */ async clickAddNewSnippet(): Promise { - await this.page.waitForSelector(SELECTORS.PAGE_TITLE, { timeout: TIMEOUTS.DEFAULT }) - await this.page.click(SELECTORS.ADD_NEW_BUTTON) - await this.page.waitForLoadState('networkidle') + await this.page.goto(URLS.ADD_SNIPPET) + await this.page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) } /** @@ -67,6 +65,8 @@ export class SnippetsTestHelper { await this.page.waitForSelector(`text=${SNIPPET_LOCATIONS[options.location]}`, { timeout: TIMEOUTS.SHORT }) await this.page.click(`text=${SNIPPET_LOCATIONS[options.location]}`, { force: true }) + + await expect(this.page.locator(SELECTORS.LOCATION_SELECT)).toContainText(SNIPPET_LOCATIONS[options.location]) } } @@ -101,9 +101,14 @@ export class SnippetsTestHelper { * Open an existing snippet by name */ async openSnippet(snippetName: string): Promise { - await this.page.waitForSelector(`text=${snippetName}`) - await this.page.click(`text=${snippetName}`) - await this.page.waitForLoadState('networkidle') + await this.page.goto(URLS.SNIPPETS_ADMIN) + await this.page.waitForSelector(SELECTORS.SNIPPETS_TABLE, { timeout: TIMEOUTS.DEFAULT }) + + const row = this.page.locator(SELECTORS.SNIPPET_ROW).filter({ hasText: snippetName }).first() + await expect(row).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + await row.locator(SELECTORS.SNIPPET_NAME_LINK).click() + await this.page.waitForSelector(SELECTORS.TITLE_INPUT, { timeout: TIMEOUTS.DEFAULT }) } /** @@ -111,7 +116,13 @@ export class SnippetsTestHelper { */ async deleteSnippet(): Promise { await this.page.click(BUTTONS.DELETE) - await this.page.click(SELECTORS.DELETE_CONFIRM_BUTTON) + const dialog = this.page.locator('[role="dialog"]').filter({ hasText: 'Delete?' }) + await expect(dialog).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + await dialog.locator('button:has-text("Delete")').click() + + await expect(this.page).toHaveURL(/page=snippets/) + await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) } /** @@ -140,7 +151,7 @@ export class SnippetsTestHelper { async expectToBeOnSnippetsAdminPage(): Promise { const currentUrl = this.page.url() expect(currentUrl).toContain('page=snippets') - await expect(this.page.locator(SELECTORS.PAGE_TITLE)).toBeVisible() + await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() } /** From 983a40cad2dcd68a7e44fd7e8e88a9b1888dea1f Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 20:36:22 +0200 Subject: [PATCH 47/96] fix: Clean up constants and update selector references --- tests/e2e/helpers/constants.ts | 55 +++++++++++++++------------------- 1 file changed, 24 insertions(+), 31 deletions(-) diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index 843def44..4c5705d7 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -1,65 +1,58 @@ export const SELECTORS = { - WPBODY_CONTENT: '#wpbody-content, .wrap, #wpcontent', - PAGE_TITLE: 'h1, .page-title', - ADD_NEW_BUTTON: '.create-snippet-button', - TITLE_INPUT: '#title', CODE_MIRROR_TEXTAREA: '.CodeMirror textarea', SNIPPET_TYPE_SELECT: '#snippet-type-select-input', LOCATION_SELECT: '.code-snippets-select-location', - SUCCESS_MESSAGE: '#message.notice', - SUCCESS_MESSAGE_P: '#message.notice p', - - DELETE_CONFIRM_BUTTON: 'button.components-button.is-destructive.is-primary', + SUCCESS_MESSAGE: '.snippet-editor-sidebar > #message.notice.updated', SNIPPETS_TABLE: '.wp-list-table', SNIPPET_ROW: '.wp-list-table tbody tr', - SNIPPET_TOGGLE: '.switch', + SNIPPET_TOGGLE: 'a.snippet-activation-switch', SNIPPET_NAME_LINK: '.snippet-name', - EDIT_ACTION: '.row-actions a', - CLONE_ACTION: '.row-actions button:has-text("Clone")', - DELETE_ACTION: '.row-actions button.delete', - EXPORT_ACTION: '.row-actions button:has-text("Export")', + CLONE_ACTION: '.row-actions a[href*="action=clone"]', + DELETE_ACTION: '.row-actions a.delete:has-text("Trash")', + EXPORT_ACTION: '.row-actions a[href*="action=export"]', ADMIN_BAR: '#wpadminbar' } export const TIMEOUTS = { - DEFAULT: 10000, - SHORT: 5000 + DEFAULT: 10000, + SHORT: 5000 } export const URLS = { SNIPPETS_ADMIN: '/wp-admin/admin.php?page=snippets', + ADD_SNIPPET: '/wp-admin/admin.php?page=add-snippet', FRONTEND: '/' } export const MESSAGES = { - SNIPPET_CREATED: 'Snippet created', - SNIPPET_CREATED_AND_ACTIVATED: 'Snippet created and activated', - SNIPPET_UPDATED_AND_ACTIVATED: 'Snippet updated and activated', - SNIPPET_UPDATED_AND_DEACTIVATED: 'Snippet updated and deactivated' + SNIPPET_CREATED: 'Snippet created', + SNIPPET_CREATED_AND_ACTIVATED: 'Snippet created and activated', + SNIPPET_UPDATED_AND_ACTIVATED: 'Snippet updated and activated', + SNIPPET_UPDATED_AND_DEACTIVATED: 'Snippet updated and deactivated' } export const SNIPPET_TYPES = { - PHP: 'Functions', - HTML: 'Content' + PHP: 'Functions', + HTML: 'Content' } export const SNIPPET_LOCATIONS = { - SITE_FOOTER: 'In site footer (end of )', - SITE_HEADER: 'In site section', - IN_EDITOR: 'Where inserted in editor', - ADMIN_ONLY: 'Only run in administration area', - FRONTEND_ONLY: 'Only run on site front-end', - EVERYWHERE: 'Run everywhere' + SITE_FOOTER: 'In site footer (end of )', + SITE_HEADER: 'In site section', + IN_EDITOR: 'Where inserted in editor', + ADMIN_ONLY: 'Only run in administration area', + FRONTEND_ONLY: 'Only run on site front-end', + EVERYWHERE: 'Run everywhere' } export const BUTTONS = { - SAVE: 'text=Save Snippet', - SAVE_AND_ACTIVATE: 'text=Save and Activate', - SAVE_AND_DEACTIVATE: 'text=Save and Deactivate', - DELETE: 'text=Delete' + SAVE: 'text=Save Snippet', + SAVE_AND_ACTIVATE: 'text=Save and Activate', + SAVE_AND_DEACTIVATE: 'text=Save and Deactivate', + DELETE: 'text=Delete' } From e3cb39874ff41c13a53f5917577451840a875013 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 20:36:35 +0200 Subject: [PATCH 48/96] fix: Remove redundant method for success message expectation --- tests/e2e/helpers/SnippetsTestHelper.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index 7107e460..9dd8f325 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -93,11 +93,6 @@ export class SnippetsTestHelper { /** * Expect a success message in paragraph element */ - async expectSuccessMessageInParagraph(expectedMessage: string): Promise { - await expect(this.page.locator(SELECTORS.SUCCESS_MESSAGE_P)).toContainText(expectedMessage) - } - - /** * Open an existing snippet by name */ async openSnippet(snippetName: string): Promise { From 493c090314d0f9f6e4e6ca0f34d120157a88274c Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 20:36:44 +0200 Subject: [PATCH 49/96] fix: Update success message expectations and improve snippet deletion verification --- tests/e2e/code-snippets-edit.spec.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index de1204b2..47fcb8a1 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -1,6 +1,6 @@ import { test } from '@playwright/test' import { SnippetsTestHelper } from './helpers/SnippetsTestHelper' -import { MESSAGES } from './helpers/constants' +import { MESSAGES, SELECTORS } from './helpers/constants' const TEST_SNIPPET_NAME = 'E2E Test Snippet' @@ -27,15 +27,15 @@ test.describe('Code Snippets Admin', () => { await helper.openSnippet(TEST_SNIPPET_NAME) await helper.saveSnippet('save_and_activate') - await helper.expectSuccessMessageInParagraph(MESSAGES.SNIPPET_UPDATED_AND_ACTIVATED) + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_ACTIVATED) await helper.saveSnippet('save_and_deactivate') - await helper.expectSuccessMessageInParagraph(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) }) test('Can delete a snippet', async () => { await helper.openSnippet(TEST_SNIPPET_NAME) await helper.deleteSnippet() - await helper.expectTextNotVisible(TEST_SNIPPET_NAME) + await helper.expectElementCount(`${SELECTORS.SNIPPETS_TABLE} tbody tr:has-text("${TEST_SNIPPET_NAME}")`, 0) }) }) From 0c2940513a8e1aec7c3530d829a8ac95320675fd Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 20:36:52 +0200 Subject: [PATCH 50/96] fix: Refactor createPageWithShortcode to accept page parameter and enhance error handling --- tests/e2e/code-snippets-evaluation.spec.ts | 60 ++++++++++++++++++++-- 1 file changed, 55 insertions(+), 5 deletions(-) diff --git a/tests/e2e/code-snippets-evaluation.spec.ts b/tests/e2e/code-snippets-evaluation.spec.ts index 3bccb588..93e5d3ba 100644 --- a/tests/e2e/code-snippets-evaluation.spec.ts +++ b/tests/e2e/code-snippets-evaluation.spec.ts @@ -32,7 +32,7 @@ const verifyShortcodeRendersCorrectly = async ( await helper.expectTextVisible('Page content after shortcode.') } -const createPageWithShortcode = async (snippetId: string): Promise => { +const createPageWithShortcode = async (page: Page, snippetId: string): Promise => { const shortcode = `[code_snippet id=${snippetId} format name="${TEST_SNIPPET_NAME}"]` const pageContent = `

Page content before shortcode.

\n\n${shortcode}\n\n

Page content after shortcode.

` @@ -50,8 +50,59 @@ const createPageWithShortcode = async (snippetId: string): Promise => { const pageUrl = (await wpCli(['post', 'url', pageId])).trim() return pageUrl } catch (error) { - console.error('Failed to create page via WP-CLI:', error) - throw error + console.error('Failed to create page via WP-CLI. Falling back to UI/API creation.', error) + + try { + // Fallback: create a published page via WP REST API from within WP Admin (uses nonce + cookies). + // This avoids direct Gutenberg UI interactions while still exercising shortcode rendering on the front-end. + const pageUrl = await page.evaluate( + async ({ title, content }) => { + const apiFetch = (window as any)?.wp?.apiFetch + const nonce = (window as any)?.wpApiSettings?.nonce + + if (apiFetch) { + const created = await apiFetch({ + path: '/wp/v2/pages', + method: 'POST', + data: { title, content, status: 'publish' } + }) + return created?.link ?? '' + } + + if (!nonce) { + throw new Error('Missing wpApiSettings.nonce for REST fallback.') + } + + const response = await fetch('/wp-json/wp/v2/pages', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'X-WP-Nonce': nonce + }, + body: JSON.stringify({ title, content, status: 'publish' }) + }) + + if (!response.ok) { + const text = await response.text().catch(() => '') + throw new Error(`REST create page failed: ${response.status} ${response.statusText} ${text}`) + } + + const created = await response.json() + return created?.link ?? '' + }, + { title: 'Test Page for Snippet Shortcode', content: pageContent } + ) + + if (!pageUrl) { + throw new Error('REST fallback returned empty page URL.') + } + + return pageUrl + } catch (fallbackError) { + console.error('Failed to create page via REST fallback:', fallbackError) + throw error + } } } @@ -85,7 +136,6 @@ test.describe('Code Snippets Evaluation', () => { }) await helper.navigateToFrontend() - await helper.expectElementNotVisible(SELECTORS.ADMIN_BAR) await helper.expectElementCount(SELECTORS.ADMIN_BAR, 0) }) @@ -159,7 +209,7 @@ test.describe('Code Snippets Evaluation', () => { test('HTML snippet works with shortcode in editor', async ({ page }) => { const snippetId = await createHtmlSnippetForEditor(helper, page) - const pageUrl = await createPageWithShortcode(snippetId) + const pageUrl = await createPageWithShortcode(page, snippetId) await verifyShortcodeRendersCorrectly(helper, page, pageUrl) }) From 92e45413681369fc385de6dd495dd8382065c95c Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 20:37:02 +0200 Subject: [PATCH 51/96] fix: Refactor snippet row locators --- tests/e2e/code-snippets-list.spec.ts | 76 ++++++++++++++++++---------- 1 file changed, 50 insertions(+), 26 deletions(-) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 0b67953b..12a73b39 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -23,73 +23,97 @@ test.describe('Code Snippets List Page Actions', () => { }) test('Can toggle snippet activation from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const toggleSwitch = snippetRow.locator(SELECTORS.SNIPPET_TOGGLE) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) + .first() + const toggleSwitch = snippetRow.locator(SELECTORS.SNIPPET_TOGGLE).first() await expect(toggleSwitch).toHaveAttribute('title', 'Deactivate') await toggleSwitch.click() - await page.waitForLoadState('networkidle') - const updatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const updatedToggle = updatedRow.locator(SELECTORS.SNIPPET_TOGGLE) + const updatedRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) + .first() + const updatedToggle = updatedRow.locator(SELECTORS.SNIPPET_TOGGLE).first() await expect(updatedToggle).toHaveAttribute('title', 'Activate') await updatedToggle.click() - await page.waitForLoadState('networkidle') - const reactivatedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) - const reactivatedToggle = reactivatedRow.locator(SELECTORS.SNIPPET_TOGGLE) + const reactivatedRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) + .first() + const reactivatedToggle = reactivatedRow.locator(SELECTORS.SNIPPET_TOGGLE).first() await expect(reactivatedToggle).toHaveAttribute('title', 'Deactivate') }) test('Can access edit from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) + .first() - await snippetRow.locator(SELECTORS.EDIT_ACTION).click() + await snippetRow.locator(SELECTORS.SNIPPET_NAME_LINK).first().click() await expect(page).toHaveURL(/page=edit-snippet/) await expect(page.locator('#title')).toHaveValue(TEST_SNIPPET_NAME) }) test('Can clone snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) + .first() await snippetRow.locator(SELECTORS.CLONE_ACTION).click() - await page.waitForLoadState('networkidle') await expect(page).toHaveURL(/page=snippets/) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() await helper.expectTextVisible(`${TEST_SNIPPET_NAME} [CLONE]`) const clonedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME} [CLONE]")`) - - page.on('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - await dialog.accept() - }) - await clonedRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() }) test('Can delete snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) + .first() + + await snippetRow.locator(SELECTORS.DELETE_ACTION).click() - page.on('dialog', async dialog => { + await expect(page).toHaveURL(/page=snippets/) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + const trashedLink = page.locator('ul.subsubsub li.trashed a').first() + await expect(trashedLink).toBeVisible() + await trashedLink.click() + + await expect(page).toHaveURL(/status=trashed/) + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() + + const trashedRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) + .first() + await expect(trashedRow).toBeVisible() + + page.once('dialog', async dialog => { expect(dialog.type()).toBe('confirm') + expect(dialog.message()).toContain('permanently delete') await dialog.accept() }) - await snippetRow.locator(SELECTORS.DELETE_ACTION).click() - await page.waitForLoadState('networkidle') + await trashedRow.locator('.delete_permanently a.delete').click() + await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() - await expect(page).toHaveURL(/page=snippets/) - await helper.expectElementCount(`tr:has-text("${TEST_SNIPPET_NAME}")`, 0) + const remainingCount = await page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`).count() + expect(remainingCount).toBe(0) }) test('Can export snippet from list page', async ({ page }) => { - const snippetRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME}")`) + const snippetRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) + .first() const downloadPromise = page.waitForEvent('download') From 415d6580dba2979ee03ec2bac89aa2e43e712b08 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 20:41:40 +0200 Subject: [PATCH 52/96] fix: Remove outdated comment --- tests/e2e/helpers/SnippetsTestHelper.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index 9dd8f325..cf34eb66 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -91,8 +91,6 @@ export class SnippetsTestHelper { } /** - * Expect a success message in paragraph element - */ * Open an existing snippet by name */ async openSnippet(snippetName: string): Promise { From e8bff9eb96d52dd0abb5c32cb7de134f71648805 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 23:40:55 +0200 Subject: [PATCH 53/96] test: Improve Playwright selectors and test reliability for UI interactions, including increased timeouts and updated element targeting. --- tests/e2e/helpers/constants.ts | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/tests/e2e/helpers/constants.ts b/tests/e2e/helpers/constants.ts index 4c5705d7..158b6482 100644 --- a/tests/e2e/helpers/constants.ts +++ b/tests/e2e/helpers/constants.ts @@ -1,21 +1,21 @@ export const SELECTORS = { - TITLE_INPUT: '#title', - CODE_MIRROR_TEXTAREA: '.CodeMirror textarea', - SNIPPET_TYPE_SELECT: '#snippet-type-select-input', - LOCATION_SELECT: '.code-snippets-select-location', + TITLE_INPUT: '#title', + CODE_MIRROR_TEXTAREA: '.CodeMirror textarea', + SNIPPET_TYPE_SELECT: '#snippet-type-select-input', + LOCATION_SELECT: '.code-snippets-select-location', - SUCCESS_MESSAGE: '.snippet-editor-sidebar > #message.notice.updated', + SUCCESS_MESSAGE: '.snippet-editor-sidebar .notice.updated', - SNIPPETS_TABLE: '.wp-list-table', - SNIPPET_ROW: '.wp-list-table tbody tr', - SNIPPET_TOGGLE: 'a.snippet-activation-switch', - SNIPPET_NAME_LINK: '.snippet-name', + SNIPPETS_TABLE: '.wp-list-table', + SNIPPET_ROW: '.wp-list-table tbody tr', + SNIPPET_TOGGLE: 'input.switch', + SNIPPET_NAME_LINK: '.snippet-name', - CLONE_ACTION: '.row-actions a[href*="action=clone"]', - DELETE_ACTION: '.row-actions a.delete:has-text("Trash")', - EXPORT_ACTION: '.row-actions a[href*="action=export"]', + CLONE_ACTION: '.row-actions button:has-text("Clone")', + DELETE_ACTION: '.row-actions button:has-text("Clone")', + EXPORT_ACTION: '.row-actions button:has-text("Export")', - ADMIN_BAR: '#wpadminbar' + ADMIN_BAR: '#wpadminbar' } export const TIMEOUTS = { @@ -24,9 +24,9 @@ export const TIMEOUTS = { } export const URLS = { - SNIPPETS_ADMIN: '/wp-admin/admin.php?page=snippets', - ADD_SNIPPET: '/wp-admin/admin.php?page=add-snippet', - FRONTEND: '/' + SNIPPETS_ADMIN: '/wp-admin/admin.php?page=snippets', + ADD_SNIPPET: '/wp-admin/admin.php?page=add-snippet', + FRONTEND: '/' } export const MESSAGES = { @@ -54,5 +54,5 @@ export const BUTTONS = { SAVE: 'text=Save Snippet', SAVE_AND_ACTIVATE: 'text=Save and Activate', SAVE_AND_DEACTIVATE: 'text=Save and Deactivate', - DELETE: 'text=Delete' + DELETE: 'button.delete-button:has-text("Trash")' } From fb9a4e366c0c7791e5d36971a21a4bb6f3b19e47 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 23:41:05 +0200 Subject: [PATCH 54/96] refactor: Increase Playwright test timeouts and enhance selector robustness for dropdowns, saving settings, and deleting snippets. --- tests/playwright/playwright.config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/playwright/playwright.config.ts b/tests/playwright/playwright.config.ts index 56762137..996d7fe7 100644 --- a/tests/playwright/playwright.config.ts +++ b/tests/playwright/playwright.config.ts @@ -70,10 +70,10 @@ export default defineConfig({ } ], - timeout: 5000, // + timeout: 10000, // 10 seconds per test expect: { - timeout: 10000, + timeout: 10000, // 10 seconds for each expect assertion toHaveScreenshot: { maxDiffPixels: 100 } } }) From efa9c3af4ad0f6cf3b83642db20374efc03b6c88 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 23:41:17 +0200 Subject: [PATCH 55/96] refactor: Improve Playwright E2E test reliability by using `getByRole` for React Select options, regular expressions for text matching, and `Promise.all` for navigation waits. --- tests/e2e/helpers/SnippetsTestHelper.ts | 38 ++++++++++++++++++------- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/tests/e2e/helpers/SnippetsTestHelper.ts b/tests/e2e/helpers/SnippetsTestHelper.ts index cf34eb66..f3bf4ac3 100644 --- a/tests/e2e/helpers/SnippetsTestHelper.ts +++ b/tests/e2e/helpers/SnippetsTestHelper.ts @@ -10,6 +10,8 @@ import { } from './constants' import type { Page} from '@playwright/test' +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + export interface SnippetFormOptions { name: string; code: string; @@ -52,8 +54,16 @@ export class SnippetsTestHelper { await this.page.fill(SELECTORS.TITLE_INPUT, options.name) if (options.type && 'PHP' !== options.type) { - await this.page.click(SELECTORS.SNIPPET_TYPE_SELECT) - await this.page.click(`text=${SNIPPET_TYPES[options.type]}`) + const snippetTypeInput = this.page.locator(SELECTORS.SNIPPET_TYPE_SELECT) + await snippetTypeInput.click() + + // React Select renders options in a listbox; scope the click to options to avoid matching + // other UI strings like "Skip to main content". + const listboxId = await snippetTypeInput.getAttribute('aria-controls') + const listbox = listboxId ? this.page.locator(`#${listboxId}`) : this.page.getByRole('listbox') + const optionLabel = SNIPPET_TYPES[options.type] + + await listbox.getByRole('option', { name: new RegExp(escapeRegExp(optionLabel), 'i') }).click() } await this.page.waitForSelector(SELECTORS.CODE_MIRROR_TEXTAREA) @@ -61,10 +71,14 @@ export class SnippetsTestHelper { if (options.location) { await this.page.waitForSelector(SELECTORS.LOCATION_SELECT, { timeout: TIMEOUTS.SHORT }) + // Await this.page.click(SELECTORS.LOCATION_SELECT) await this.page.click(SELECTORS.LOCATION_SELECT) - - await this.page.waitForSelector(`text=${SNIPPET_LOCATIONS[options.location]}`, { timeout: TIMEOUTS.SHORT }) - await this.page.click(`text=${SNIPPET_LOCATIONS[options.location]}`, { force: true }) + + const locationLabel = SNIPPET_LOCATIONS[options.location] + await this.page.getByRole('option', { name: new RegExp(escapeRegExp(locationLabel), 'i') }).click() + + // Await this.page.waitForSelector(`text=${SNIPPET_LOCATIONS[options.location]}`, { timeout: TIMEOUTS.SHORT }) + // await this.page.click(`text=${SNIPPET_LOCATIONS[options.location]}`, { force: true }) await expect(this.page.locator(SELECTORS.LOCATION_SELECT)).toContainText(SNIPPET_LOCATIONS[options.location]) } @@ -108,11 +122,15 @@ export class SnippetsTestHelper { * Delete a snippet (assumes you're already on the snippet edit page) */ async deleteSnippet(): Promise { - await this.page.click(BUTTONS.DELETE) - const dialog = this.page.locator('[role="dialog"]').filter({ hasText: 'Delete?' }) - await expect(dialog).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) - - await dialog.locator('button:has-text("Delete")').click() + // Await this.page.click(BUTTONS.DELETE) + // const dialog = this.page.locator('[role="dialog"]').filter({ hasText: 'Delete?' }) + // await expect(dialog).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) + + // await dialog.locator('button:has-text("Delete")').click() + await Promise.all([ + this.page.waitForURL(/page=snippets/, { timeout: TIMEOUTS.DEFAULT }), + this.page.click(BUTTONS.DELETE) + ]) await expect(this.page).toHaveURL(/page=snippets/) await expect(this.page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible({ timeout: TIMEOUTS.DEFAULT }) From cd391faf02cd20ba73032425297ec36e40bab572 Mon Sep 17 00:00:00 2001 From: Imants Date: Tue, 17 Feb 2026 23:41:25 +0200 Subject: [PATCH 56/96] refactor: Update E2E test to use role-based button click and URL assertion for saving settings instead of generic submit and success notice. --- tests/e2e/flat-files.setup.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/e2e/flat-files.setup.ts b/tests/e2e/flat-files.setup.ts index 2fe4be1d..91ac8477 100644 --- a/tests/e2e/flat-files.setup.ts +++ b/tests/e2e/flat-files.setup.ts @@ -7,7 +7,7 @@ setup('enable flat files', async ({ page }) => { await page.goto(`${wpAdminbase}/admin.php?page=snippets-settings`) await page.waitForSelector('#wpbody-content') - await page.waitForSelector('form') + // await page.waitForSelector('form') const flatFilesCheckbox = page.locator('input[name="code_snippets_settings[general][enable_flat_files]"]') await expect(flatFilesCheckbox).toBeVisible() @@ -17,10 +17,17 @@ setup('enable flat files', async ({ page }) => { await flatFilesCheckbox.check() } - await page.click('input[type="submit"][name="submit"]') + // await page.click('input[type="submit"][name="submit"]') - await page.waitForSelector('.notice-success', { timeout: 10000 }) - await expect(page.locator('.notice-success')).toContainText('Settings saved') + // await page.waitForSelector('.notice-success', { timeout: 10000 }) + // await expect(page.locator('.notice-success')).toContainText('Settings saved') + const saveButton = page.getByRole('button', { name: 'Save Changes' }) + + await Promise.all([ + page.waitForURL(/settings-updated=true/, { timeout: 10000 }), + saveButton.click() + ]) + await page.reload() await page.waitForSelector('input[name="code_snippets_settings[general][enable_flat_files]"]') From 3e772cdd03b5ba03d6f4a635fb27e21c77dc97c0 Mon Sep 17 00:00:00 2001 From: Imants Date: Wed, 18 Feb 2026 00:34:13 +0200 Subject: [PATCH 57/96] refactor: Enhance snippet activation/deactivation test with visibility checks and improved logic --- tests/e2e/code-snippets-edit.spec.ts | 79 ++++++++++++++++------------ 1 file changed, 46 insertions(+), 33 deletions(-) diff --git a/tests/e2e/code-snippets-edit.spec.ts b/tests/e2e/code-snippets-edit.spec.ts index 47fcb8a1..a10f9b2c 100644 --- a/tests/e2e/code-snippets-edit.spec.ts +++ b/tests/e2e/code-snippets-edit.spec.ts @@ -5,37 +5,50 @@ import { MESSAGES, SELECTORS } from './helpers/constants' const TEST_SNIPPET_NAME = 'E2E Test Snippet' test.describe('Code Snippets Admin', () => { - let helper: SnippetsTestHelper - - test.beforeEach(async ({ page }) => { - helper = new SnippetsTestHelper(page) - await helper.navigateToSnippetsAdmin() - }) - - test('Can access snippets admin page', async () => { - await helper.expectToBeOnSnippetsAdminPage() - }) - - test('Can add a new snippet', async () => { - await helper.createSnippet({ - name: TEST_SNIPPET_NAME, - code: "add_filter('show_admin_bar', '__return_false');" - }) - }) - - test('Can activate and deactivate a snippet', async () => { - await helper.openSnippet(TEST_SNIPPET_NAME) - - await helper.saveSnippet('save_and_activate') - await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_ACTIVATED) - - await helper.saveSnippet('save_and_deactivate') - await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) - }) - - test('Can delete a snippet', async () => { - await helper.openSnippet(TEST_SNIPPET_NAME) - await helper.deleteSnippet() - await helper.expectElementCount(`${SELECTORS.SNIPPETS_TABLE} tbody tr:has-text("${TEST_SNIPPET_NAME}")`, 0) - }) + let helper: SnippetsTestHelper + + test.beforeEach(async ({ page }) => { + helper = new SnippetsTestHelper(page) + await helper.navigateToSnippetsAdmin() + }) + + test('Can access snippets admin page', async () => { + await helper.expectToBeOnSnippetsAdminPage() + }) + + test('Can add a new snippet', async () => { + await helper.createSnippet({ + name: TEST_SNIPPET_NAME, + code: "add_filter('show_admin_bar', '__return_false');" + }) + }) + + test('Can activate and deactivate a snippet', async ({ page }) => { + await helper.openSnippet(TEST_SNIPPET_NAME) + + // Check the current state by seeing which buttons are visible + const saveAndDeactivateButton = page.locator('text=Save and Deactivate') + + const isAlreadyActive = await saveAndDeactivateButton.isVisible().catch(() => false) + + if (isAlreadyActive) { + // If already active, deactivate first + await helper.saveSnippet('save_and_deactivate') + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) + } + + // Now the snippet should be inactive — activate it + await helper.saveSnippet('save_and_activate') + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_ACTIVATED) + + // Now deactivate it + await helper.saveSnippet('save_and_deactivate') + await helper.expectSuccessMessage(MESSAGES.SNIPPET_UPDATED_AND_DEACTIVATED) + }) + + test('Can delete a snippet', async () => { + await helper.openSnippet(TEST_SNIPPET_NAME) + await helper.deleteSnippet() + await helper.expectElementCount(`${SELECTORS.SNIPPETS_TABLE} tbody tr:has-text("${TEST_SNIPPET_NAME}")`, 0) + }) }) From 68182f921f9293b89d2f44f995a32b21d68b9334 Mon Sep 17 00:00:00 2001 From: Imants Date: Wed, 18 Feb 2026 00:34:18 +0200 Subject: [PATCH 58/96] refactor: Update snippet cloning and deletion tests for new React UI interactions --- tests/e2e/code-snippets-list.spec.ts | 25 ++++++++++++++++--------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/tests/e2e/code-snippets-list.spec.ts b/tests/e2e/code-snippets-list.spec.ts index 12a73b39..1c981bc6 100644 --- a/tests/e2e/code-snippets-list.spec.ts +++ b/tests/e2e/code-snippets-list.spec.ts @@ -68,9 +68,13 @@ test.describe('Code Snippets List Page Actions', () => { await expect(page).toHaveURL(/page=snippets/) await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() - await helper.expectTextVisible(`${TEST_SNIPPET_NAME} [CLONE]`) + // Verify that a cloned snippet exists in the table (use table-scoped check to avoid admin bar matches) + const clonedRow = page + .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME} [CLONE]"))`) + .first() + await expect(clonedRow).toBeVisible() - const clonedRow = page.locator(`tr:has-text("${TEST_SNIPPET_NAME} [CLONE]")`) + // Clean up the clone by trashing it await clonedRow.locator(SELECTORS.DELETE_ACTION).click() await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() }) @@ -80,12 +84,14 @@ test.describe('Code Snippets List Page Actions', () => { .locator(`${SELECTORS.SNIPPET_ROW}:has(a${SELECTORS.SNIPPET_NAME_LINK}:has-text("${TEST_SNIPPET_NAME}"))`) .first() + // Click "Trash" in row actions — in the new React UI, this moves to trash immediately (no dialog) await snippetRow.locator(SELECTORS.DELETE_ACTION).click() await expect(page).toHaveURL(/page=snippets/) await expect(page.locator(SELECTORS.SNIPPETS_TABLE)).toBeVisible() - const trashedLink = page.locator('ul.subsubsub li.trashed a').first() + // Navigate to the trash view using the new filter link format + const trashedLink = page.locator('a[href*="status=trashed"]').first() await expect(trashedLink).toBeVisible() await trashedLink.click() @@ -97,13 +103,14 @@ test.describe('Code Snippets List Page Actions', () => { .first() await expect(trashedRow).toBeVisible() - page.once('dialog', async dialog => { - expect(dialog.type()).toBe('confirm') - expect(dialog.message()).toContain('permanently delete') - await dialog.accept() - }) + // Click "Delete Permanently" — now a