From 2c25fcdb3d9b001f562f5c38eec6bbcf4247d53c Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Mon, 27 Jul 2026 10:28:44 +0300 Subject: [PATCH 1/7] fix: keep Meta Boxes pane open by default for WooCommerce Builder products Since WP 6.7 the iframed post editor renders meta boxes in a bottom drawer that is collapsed unless the user previously opened it. Otter 3.2.0 bumped all blocks to apiVersion 3, which switched WooCommerce Builder product edit screens to the iframed canvas, hiding the WooCommerce Product data panel (price, inventory, etc.) behind the collapsed drawer. Default the drawer to open on builder-enabled product screens via the preferences setDefaults API, which never overrides an explicit user choice. Closes #2822 Co-Authored-By: Claude Fable 5 --- .../inc/plugins/class-woocommerce-builder.php | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php index cdfdf59e0..fcfe680d5 100644 --- a/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php +++ b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php @@ -30,6 +30,29 @@ public function init() { add_filter( 'wc_get_template_part', array( $this, 'wc_get_template_part' ), 1000, 3 ); add_action( 'otter_blocks_woocommerce_content', 'the_content' ); add_filter( 'body_class', array( $this, 'add_body_class' ), 1000, 1 ); + add_action( 'enqueue_block_editor_assets', array( $this, 'show_meta_boxes_pane' ) ); + } + + /** + * Keep the Meta Boxes pane open by default in the block editor. + * + * Since WP 6.7 the iframed post editor renders meta boxes inside a bottom + * drawer that is collapsed unless the user opened it before. On builder + * products that hides the WooCommerce Product data panel (price, inventory + * etc.), so default the drawer to open. An explicit user preference is not + * overridden, as setDefaults only applies to unset preferences. + * + * @access public + */ + public function show_meta_boxes_pane() { + if ( 'product' !== get_post_type() || ! boolval( get_post_meta( get_the_ID(), '_themeisle_gutenberg_woo_builder', true ) ) ) { + return; + } + + wp_add_inline_script( + 'wp-edit-post', + 'window.wp && wp.data && wp.data.dispatch( "core/preferences" ).setDefaults( "core/edit-post", { metaBoxesMainIsOpen: true } );' + ); } /** From 1ae8200e8af76ad2556799e8573bb9ba4a0bceb0 Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Mon, 27 Jul 2026 11:36:03 +0300 Subject: [PATCH 2/7] fix: rescue Product data metabox from the side column + e2e coverage The metabox Move-up/down arrows persist the order instantly and, at the edge of an area, relocate a box into the adjacent area. From the block editor (WooCommerce Builder products) one accidental click can strand woocommerce-product-data in 'side', where it renders inside the ~280px sidebar and its layout breaks. Correct it at read time via the get_user_option_meta-box-order_product filter. Adds Playwright coverage for the builder edit screen: drawer open by default, side-area rescue, classic-editor scoping for non-builder products, and explicit user preference winning over the new default. WooCommerce (latest stable) joins .wp-env.json; the e2e bootstrap gains product-creation and user-meta helpers. Co-Authored-By: Claude Fable 5 --- .wp-env.json | 3 +- bin/e2e-tests.sh | 3 +- .../mu-plugins/otter-e2e-bootstrap.php | 74 ++++++++++++ .../inc/plugins/class-woocommerce-builder.php | 31 +++++ .../e2e/blocks/woocommerce-builder.spec.js | 108 ++++++++++++++++++ src/blocks/test/e2e/fixtures.ts | 14 ++- src/blocks/test/e2e/playwright.config.js | 5 +- 7 files changed, 234 insertions(+), 4 deletions(-) create mode 100644 src/blocks/test/e2e/blocks/woocommerce-builder.spec.js diff --git a/.wp-env.json b/.wp-env.json index ecc59bbe2..716d78e5d 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -4,7 +4,8 @@ "testsEnvironment": false, "plugins": [ ".", - "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip" + "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip", + "https://downloads.wordpress.org/plugin/woocommerce.latest-stable.zip" ], "themes": [ "./test/emptytheme" ], "config": { diff --git a/bin/e2e-tests.sh b/bin/e2e-tests.sh index 687f9b6a0..0151218c9 100644 --- a/bin/e2e-tests.sh +++ b/bin/e2e-tests.sh @@ -2,4 +2,5 @@ # Set-up the `wp_env` environment. npm run wp-env run cli wp option set themeisle_open_ai_api_key "sk_XXXXXXXXXXXXXXXXXXXXXXXx" # Used by AI tools. -npm run wp-env run cli wp rewrite structure '/%postname%/' # Pretty permalinks: the e2e global-setup reads /wp-json/. \ No newline at end of file +npm run wp-env run cli wp rewrite structure '/%postname%/' # Pretty permalinks: the e2e global-setup reads /wp-json/ +npm run wp-env run cli wp transient delete _wc_activation_redirect # WooCommerce would hijack the first wp-admin visit with its setup wizard.. \ No newline at end of file diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php index 882241e0a..d2635966f 100644 --- a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php +++ b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php @@ -1226,6 +1226,80 @@ function () { ) ); + register_rest_route( + REST_NAMESPACE, + '/woo/product', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'permission_callback' => __NAMESPACE__ . '\\require_admin', + 'callback' => function ( \WP_REST_Request $request ) { + if ( ! class_exists( 'WC_Product_Simple' ) ) { + return new \WP_Error( + 'otter_e2e_no_woocommerce', + 'WooCommerce is not active in the environment.', + array( 'status' => 500 ) + ); + } + + $product = new \WC_Product_Simple(); + $product->set_name( $request->get_param( 'title' ) ? sanitize_text_field( $request->get_param( 'title' ) ) : 'E2E Product' ); + $product->set_regular_price( '49.99' ); + $product->set_status( 'publish' ); + $id = $product->save(); + + if ( rest_sanitize_boolean( $request->get_param( 'builder' ) ) ) { + update_post_meta( $id, '_themeisle_gutenberg_woo_builder', true ); + } + + return rest_ensure_response( array( 'id' => $id ) ); + }, + ) + ); + + register_rest_route( + REST_NAMESPACE, + '/user/meta-box-order', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'permission_callback' => __NAMESPACE__ . '\\require_admin', + 'callback' => function ( \WP_REST_Request $request ) { + $order = $request->get_param( 'order' ); + + if ( ! is_array( $order ) || empty( $order ) ) { + delete_user_meta( get_current_user_id(), 'meta-box-order_product' ); + } else { + update_user_meta( get_current_user_id(), 'meta-box-order_product', array_map( 'sanitize_text_field', $order ) ); + } + + return rest_ensure_response( array( 'ok' => true ) ); + }, + ) + ); + + register_rest_route( + REST_NAMESPACE, + '/user/meta-boxes-pane/reset', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'permission_callback' => __NAMESPACE__ . '\\require_admin', + 'callback' => function () { + $user_id = get_current_user_id(); + $meta_key = $GLOBALS['wpdb']->get_blog_prefix() . 'persisted_preferences'; + $preferences = get_user_meta( $user_id, $meta_key, true ); + + if ( is_array( $preferences ) && isset( $preferences['core/edit-post'] ) ) { + unset( + $preferences['core/edit-post']['metaBoxesMainIsOpen'], + $preferences['core/edit-post']['metaBoxesMainOpenHeight'] + ); + update_user_meta( $user_id, $meta_key, $preferences ); + } + + return rest_ensure_response( array( 'ok' => true ) ); + }, + ) + ); + register_rest_route( REST_NAMESPACE, '/reset', diff --git a/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php index fcfe680d5..0fca9bd17 100644 --- a/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php +++ b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php @@ -31,6 +31,37 @@ public function init() { add_action( 'otter_blocks_woocommerce_content', 'the_content' ); add_filter( 'body_class', array( $this, 'add_body_class' ), 1000, 1 ); add_action( 'enqueue_block_editor_assets', array( $this, 'show_meta_boxes_pane' ) ); + add_filter( 'get_user_option_meta-box-order_product', array( $this, 'restore_product_data_location' ) ); + } + + /** + * Keep the Product data metabox out of the narrow side column. + * + * The metabox "Move up/down" arrows persist the order instantly and, at the + * edge of an area, relocate a box into the adjacent area. From the block + * editor (used by WooCommerce Builder products) one accidental click can + * move WooCommerce's Product data box into "side", where it renders inside + * the ~280px sidebar and its layout breaks. Correct it at read time; the + * stored user option is left untouched. + * + * @param mixed $order Saved metabox order for the product screen. + * + * @access public + * @return mixed + */ + public function restore_product_data_location( $order ) { + if ( ! is_array( $order ) || ! isset( $order['side'] ) || false === strpos( $order['side'], 'woocommerce-product-data' ) ) { + return $order; + } + + $side = array_diff( explode( ',', $order['side'] ), array( 'woocommerce-product-data' ) ); + $normal = empty( $order['normal'] ) ? array() : explode( ',', $order['normal'] ); + array_unshift( $normal, 'woocommerce-product-data' ); + + $order['side'] = implode( ',', $side ); + $order['normal'] = implode( ',', array_unique( $normal ) ); + + return $order; } /** diff --git a/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js b/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js new file mode 100644 index 000000000..4661a5ff1 --- /dev/null +++ b/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js @@ -0,0 +1,108 @@ +/** + * Internal dependencies + */ +import { test, expect } from '../fixtures'; + +/** + * WooCommerce Builder forces the block editor for enabled products. Since + * WP 6.7 the iframed post editor renders meta boxes in a bottom drawer, so + * these tests pin the two guarantees that keep WooCommerce's Product data + * panel (price, inventory, …) reachable there: + * + * 1. the drawer defaults to open (an explicit user preference still wins); + * 2. a `meta-box-order_product` that strands the Product data box in the + * "side" area (one accidental click on the metabox move arrows persists + * that) is corrected back into the main area at render time. + */ + +const PRODUCT_DATA = '#woocommerce-product-data'; +const META_BOXES_DRAWER = '.edit-post-meta-boxes-main'; + +/** + * The Otter welcome tour pops on fresh profiles; close it if it appears. + * + * @param {import('@playwright/test').Page} page Playwright page. + */ +const dismissOtterTour = async( page ) => { + const guide = page.locator( '.components-guide' ); + if ( await guide.isVisible({ timeout: 2000 }).catch( () => false ) ) { + await page.keyboard.press( 'Escape' ); + await expect( guide ).toBeHidden(); + } +}; + +test.describe( 'WooCommerce Builder product editor', () => { + test.afterEach( async({ otterUtils }) => { + await otterUtils.setProductMetaBoxOrder( null ); + + // The collapse test persists a drawer preference for the shared admin + // user; clear it server-side so every test starts as a fresh user. + await otterUtils.resetMetaBoxesPane(); + }); + + test( 'builder products open in the block editor with the Product data panel visible', async({ admin, page, otterUtils }) => { + const { id } = await otterUtils.createWooProduct({ builder: true }); + + await admin.editPost( id ); + await dismissOtterTour( page ); + + // Block editor is active for the builder product… + await expect( page.locator( 'body.block-editor-page' ) ).toHaveCount( 1 ); + + // …and the Product data metabox is visible without any interaction: + // the meta boxes drawer defaults to open on builder products. + await expect( page.locator( PRODUCT_DATA ) ).toBeVisible(); + await expect( page.locator( `${ PRODUCT_DATA } input[name="_regular_price"]` ) ).toHaveValue( '49.99' ); + }); + + test( 'Product data stranded in the side area is rescued into the drawer', async({ admin, page, otterUtils }) => { + const { id } = await otterUtils.createWooProduct({ builder: true }); + + // The corrupted layout one metabox arrow click can persist: Product + // data serialized into "side", which renders inside the ~280px + // sidebar where its layout breaks. + await otterUtils.setProductMetaBoxOrder({ + normal: '', + advanced: 'commentsdiv,postexcerpt', + side: 'woocommerce-product-data,otter_woo_builder,woocommerce-product-images' + }); + + await admin.editPost( id ); + await dismissOtterTour( page ); + + await expect( page.locator( `${ META_BOXES_DRAWER } ${ PRODUCT_DATA }` ) ).toBeVisible(); + await expect( page.locator( `.interface-complementary-area ${ PRODUCT_DATA }` ) ).toHaveCount( 0 ); + }); + + test( 'products without the builder keep the classic editor', async({ admin, page, otterUtils }) => { + const { id } = await otterUtils.createWooProduct({ builder: false }); + + await admin.visitAdminPage( 'post.php', `post=${ id }&action=edit` ); + + await expect( page.locator( 'body.block-editor-page' ) ).toHaveCount( 0 ); + await expect( page.locator( PRODUCT_DATA ) ).toBeVisible(); + }); + + test( 'an explicitly collapsed drawer stays collapsed (user preference wins)', async({ admin, page, otterUtils }) => { + const { id } = await otterUtils.createWooProduct({ builder: true }); + + await admin.editPost( id ); + await dismissOtterTour( page ); + await expect( page.locator( PRODUCT_DATA ) ).toBeVisible(); + + // Collapse the drawer — this persists the user preference, which + // setDefaults() must not override on the next load. Keyboard: the + // toggle's click point is covered by its drag-resize separator. The + // preference write is debounced, so hold for the REST flush before + // reloading. + await Promise.all([ + page.waitForResponse( ( response ) => response.url().includes( '/wp/v2/users/me' ) ), + page.getByRole( 'button', { name: 'Meta Boxes' }).press( 'Enter' ) + ]); + await expect( page.locator( PRODUCT_DATA ) ).toBeHidden(); + + await admin.editPost( id ); + await expect( page.locator( META_BOXES_DRAWER ) ).toBeVisible(); + await expect( page.locator( PRODUCT_DATA ) ).toBeHidden(); + }); +}); diff --git a/src/blocks/test/e2e/fixtures.ts b/src/blocks/test/e2e/fixtures.ts index d9e2f5a2e..68a6ab89c 100644 --- a/src/blocks/test/e2e/fixtures.ts +++ b/src/blocks/test/e2e/fixtures.ts @@ -63,6 +63,15 @@ export type OtterUtils = { /** Hard-delete all Submission Records. */ cleanupFormRecords: () => Promise; + + /** Create a published simple WooCommerce product (price 49.99); `builder` also enables WooCommerce Builder on it. */ + createWooProduct: ( args?: { title?: string; builder?: boolean } ) => Promise<{ id: number }>; + + /** Set the current user's product metabox order (`meta-box-order_product`); null/empty resets it. */ + setProductMetaBoxOrder: ( order: Record | null ) => Promise; + + /** Remove the current user's persisted meta-boxes-pane preferences (open state and height). */ + resetMetaBoxesPane: () => Promise; }; export const test = base.extend<{ otterUtils: OtterUtils }>({ @@ -91,7 +100,10 @@ export const test = base.extend<{ otterUtils: OtterUtils }>({ return response.nonce; }, getFormRecords: () => call( 'form/records' ) as Promise, - cleanupFormRecords: () => call( 'form/records/cleanup' ) + cleanupFormRecords: () => call( 'form/records/cleanup' ), + createWooProduct: ( args ) => call( 'woo/product', args ?? {}) as Promise<{ id: number }>, + setProductMetaBoxOrder: ( order ) => call( 'user/meta-box-order', { order }), + resetMetaBoxesPane: () => call( 'user/meta-boxes-pane/reset' ) }); } }); diff --git a/src/blocks/test/e2e/playwright.config.js b/src/blocks/test/e2e/playwright.config.js index cac9dd4c7..49b4559f9 100644 --- a/src/blocks/test/e2e/playwright.config.js +++ b/src/blocks/test/e2e/playwright.config.js @@ -57,7 +57,10 @@ const SERIAL_SPECS = [ '**/blocks/design-library.spec.js', // Flips the site-wide atomic-wind blocks option. - '**/blocks/atomic-wind-list-view.spec.js' + '**/blocks/atomic-wind-list-view.spec.js', + + // Mutates the shared admin user's metabox order and editor preferences. + '**/blocks/woocommerce-builder.spec.js' ]; const config = defineConfig({ From 3bc2a95b56a625fa5b49d3f4ee4334ce84777cbb Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Mon, 27 Jul 2026 12:36:52 +0300 Subject: [PATCH 3/7] fix: address review, scope the rescue, and keep WooCommerce out of other suites - Add the missing void return type that failed PHPStan. - Scope restore_product_data_location() to builder-enabled products, so a layout saved on a classic product is left as the user arranged it. - Add PHPUnit coverage for both guards: the inline meta-boxes-pane default (present for builder products, absent for plain products and non-product posts) and the metabox-order rescue. - Mount WooCommerce as an inactive wp-env mapping from the copy composer already installs, and activate it only around the WooCommerce spec. Activating it for the whole environment changed editor load behavior and broke the performance suite. - Reset the shared admin user's drawer preference and metabox order in beforeEach as well as afterEach, so the first test is not skewed by state left behind by an earlier run. Co-Authored-By: Claude Opus 5 (1M context) --- .wp-env.json | 6 +- bin/e2e-tests.sh | 3 +- .../mu-plugins/otter-e2e-bootstrap.php | 4 + .../inc/plugins/class-woocommerce-builder.php | 5 + .../e2e/blocks/woocommerce-builder.spec.js | 26 +++- tests/test-woocommerce-builder.php | 142 ++++++++++++++++++ 6 files changed, 177 insertions(+), 9 deletions(-) create mode 100644 tests/test-woocommerce-builder.php diff --git a/.wp-env.json b/.wp-env.json index 716d78e5d..59e766d46 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -4,8 +4,7 @@ "testsEnvironment": false, "plugins": [ ".", - "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip", - "https://downloads.wordpress.org/plugin/woocommerce.latest-stable.zip" + "https://downloads.wordpress.org/plugin/ai-provider-for-openai.zip" ], "themes": [ "./test/emptytheme" ], "config": { @@ -18,7 +17,8 @@ }, "mappings": { "wp-content/mu-plugins": "./packages/e2e-tests/mu-plugins", - "wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip" + "wp-content/themes/raft": "https://downloads.wordpress.org/theme/raft.zip", + "wp-content/plugins/woocommerce": "./vendor/wp-content/plugins/woocommerce" }, "lifecycleScripts": { "afterStart": "bash bin/e2e-tests.sh" diff --git a/bin/e2e-tests.sh b/bin/e2e-tests.sh index 0151218c9..687f9b6a0 100644 --- a/bin/e2e-tests.sh +++ b/bin/e2e-tests.sh @@ -2,5 +2,4 @@ # Set-up the `wp_env` environment. npm run wp-env run cli wp option set themeisle_open_ai_api_key "sk_XXXXXXXXXXXXXXXXXXXXXXXx" # Used by AI tools. -npm run wp-env run cli wp rewrite structure '/%postname%/' # Pretty permalinks: the e2e global-setup reads /wp-json/ -npm run wp-env run cli wp transient delete _wc_activation_redirect # WooCommerce would hijack the first wp-admin visit with its setup wizard.. \ No newline at end of file +npm run wp-env run cli wp rewrite structure '/%postname%/' # Pretty permalinks: the e2e global-setup reads /wp-json/. \ No newline at end of file diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php index d2635966f..92bf31f8f 100644 --- a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php +++ b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php @@ -1241,6 +1241,10 @@ function () { ); } + // The spec activates WooCommerce right before this call; drop + // the redirect it schedules so it cannot hijack admin visits. + delete_transient( '_wc_activation_redirect' ); + $product = new \WC_Product_Simple(); $product->set_name( $request->get_param( 'title' ) ? sanitize_text_field( $request->get_param( 'title' ) ) : 'E2E Product' ); $product->set_regular_price( '49.99' ); diff --git a/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php index 0fca9bd17..20b379015 100644 --- a/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php +++ b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php @@ -50,6 +50,10 @@ public function init() { * @return mixed */ public function restore_product_data_location( $order ) { + if ( ! boolval( get_post_meta( get_the_ID(), '_themeisle_gutenberg_woo_builder', true ) ) ) { + return $order; + } + if ( ! is_array( $order ) || ! isset( $order['side'] ) || false === strpos( $order['side'], 'woocommerce-product-data' ) ) { return $order; } @@ -74,6 +78,7 @@ public function restore_product_data_location( $order ) { * overridden, as setDefaults only applies to unset preferences. * * @access public + * @return void */ public function show_meta_boxes_pane() { if ( 'product' !== get_post_type() || ! boolval( get_post_meta( get_the_ID(), '_themeisle_gutenberg_woo_builder', true ) ) ) { diff --git a/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js b/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js index 4661a5ff1..422219d96 100644 --- a/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js +++ b/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js @@ -32,12 +32,30 @@ const dismissOtterTour = async( page ) => { }; test.describe( 'WooCommerce Builder product editor', () => { - test.afterEach( async({ otterUtils }) => { - await otterUtils.setProductMetaBoxOrder( null ); + // WooCommerce is mounted by wp-env but only activated around this spec — + // its editor integrations would change load behavior (and performance + // numbers) for every other suite. Serial project only. + test.beforeAll( async({ requestUtils }) => { + await requestUtils.activatePlugin( 'woocommerce' ); + }); + + test.afterAll( async({ requestUtils }) => { + await requestUtils.deactivatePlugin( 'woocommerce' ); + }); - // The collapse test persists a drawer preference for the shared admin - // user; clear it server-side so every test starts as a fresh user. + // A drawer preference or metabox order left by an earlier run would skew + // the first test as much as a later one, so reset on both sides. + const resetSharedUserState = async( otterUtils ) => { + await otterUtils.setProductMetaBoxOrder( null ); await otterUtils.resetMetaBoxesPane(); + }; + + test.beforeEach( async({ otterUtils }) => { + await resetSharedUserState( otterUtils ); + }); + + test.afterEach( async({ otterUtils }) => { + await resetSharedUserState( otterUtils ); }); test( 'builder products open in the block editor with the Product data panel visible', async({ admin, page, otterUtils }) => { diff --git a/tests/test-woocommerce-builder.php b/tests/test-woocommerce-builder.php new file mode 100644 index 000000000..ba4d32a4c --- /dev/null +++ b/tests/test-woocommerce-builder.php @@ -0,0 +1,142 @@ + + */ + protected $stranded_order = array( + 'normal' => '', + 'advanced' => 'commentsdiv,postexcerpt', + 'side' => 'woocommerce-product-data,otter_woo_builder,woocommerce-product-images', + ); + + /** + * Set up. + */ + public function set_up() { + parent::set_up(); + + $this->woo_builder = new WooCommerce_Builder(); + + // WooCommerce is loaded by tests/bootstrap.php and registers `product`. + // Register the editor script fresh so inline-data assertions are isolated. + wp_scripts()->remove( 'wp-edit-post' ); + wp_scripts()->add( 'wp-edit-post', false ); + + $this->builder_product_id = self::factory()->post->create( array( 'post_type' => 'product' ) ); + update_post_meta( $this->builder_product_id, '_themeisle_gutenberg_woo_builder', true ); + + $this->plain_product_id = self::factory()->post->create( array( 'post_type' => 'product' ) ); + } + + /** + * Tear down. + */ + public function tear_down() { + wp_scripts()->remove( 'wp-edit-post' ); + + parent::tear_down(); + } + + /** + * The meta-boxes pane default is added for builder products only. + */ + public function test_meta_boxes_pane_default_added_for_builder_products() { + $GLOBALS['post'] = get_post( $this->builder_product_id ); + + $this->woo_builder->show_meta_boxes_pane(); + + $inline = wp_scripts()->get_data( 'wp-edit-post', 'after' ); + + $this->assertNotEmpty( $inline ); + $this->assertStringContainsString( 'metaBoxesMainIsOpen', implode( '', $inline ) ); + } + + /** + * No inline default for products without the builder or non-product posts. + */ + public function test_meta_boxes_pane_default_skipped_otherwise() { + $GLOBALS['post'] = get_post( $this->plain_product_id ); + $this->woo_builder->show_meta_boxes_pane(); + + $GLOBALS['post'] = get_post( self::factory()->post->create() ); + $this->woo_builder->show_meta_boxes_pane(); + + $this->assertFalse( wp_scripts()->get_data( 'wp-edit-post', 'after' ) ); + } + + /** + * On builder products, Product data stranded in "side" moves back to "normal". + */ + public function test_product_data_rescued_from_side_on_builder_products() { + $GLOBALS['post'] = get_post( $this->builder_product_id ); + + $order = $this->woo_builder->restore_product_data_location( $this->stranded_order ); + + $this->assertStringNotContainsString( 'woocommerce-product-data', $order['side'] ); + $this->assertSame( 'woocommerce-product-data', explode( ',', $order['normal'] )[0] ); + $this->assertSame( 'otter_woo_builder,woocommerce-product-images', $order['side'] ); + } + + /** + * Non-builder products keep whatever layout the user saved. + */ + public function test_saved_order_untouched_without_builder() { + $GLOBALS['post'] = get_post( $this->plain_product_id ); + + $this->assertSame( $this->stranded_order, $this->woo_builder->restore_product_data_location( $this->stranded_order ) ); + } + + /** + * Orders that never stranded Product data pass through unchanged. + */ + public function test_clean_order_passes_through() { + $GLOBALS['post'] = get_post( $this->builder_product_id ); + + $clean = array( + 'normal' => 'woocommerce-product-data', + 'side' => 'otter_woo_builder', + ); + + $this->assertSame( $clean, $this->woo_builder->restore_product_data_location( $clean ) ); + $this->assertFalse( $this->woo_builder->restore_product_data_location( false ) ); + } +} From f247f06d499379b129f6c4997f406c8ba36b8a2b Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Mon, 27 Jul 2026 12:55:33 +0300 Subject: [PATCH 4/7] fix: load a single WooCommerce copy in the PHPUnit bootstrap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounting WooCommerce at wp-content/plugins/woocommerce made the existing activate_plugin() call in the bootstrap resolve — it had been a silent no-op while that directory did not exist. activate_plugin() includes the plugin file through plugin_sandbox_scrape(), so WooCommerce was loaded via two different paths and PHP fataled on redeclaring the unguarded WC() and wc_get_container() functions, failing the suite with exit 255 before any test ran. Load the mounted copy when present so both includes resolve to the same path, keeping the vendor copy as a fallback. Co-Authored-By: Claude Opus 5 (1M context) --- tests/bootstrap.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 51701404f..b5d4edc0a 100644 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -20,7 +20,17 @@ function _manually_load_plugin() { require dirname( dirname( __FILE__ ) ) . '/otter-blocks.php'; - require dirname( dirname( __FILE__ ) ) . '/vendor/wp-content/plugins/woocommerce/woocommerce.php'; + + /* + * wp-env also mounts WooCommerce at wp-content/plugins/woocommerce (see + * .wp-env.json). Prefer that copy so the activate_plugin() call below + * resolves to the same file: woocommerce.php declares WC() and + * wc_get_container() unguarded, so including it through two different + * paths is a fatal redeclaration. + */ + $woocommerce = WP_PLUGIN_DIR . '/woocommerce/woocommerce.php'; + + require file_exists( $woocommerce ) ? $woocommerce : dirname( dirname( __FILE__ ) ) . '/vendor/wp-content/plugins/woocommerce/woocommerce.php'; } tests_add_filter( 'muplugins_loaded', '_manually_load_plugin' ); From 6fd230726f703ca2983abf9d315e06e59a134103 Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Mon, 27 Jul 2026 13:57:31 +0300 Subject: [PATCH 5/7] fix: delete the products the WooCommerce spec creates Each test published a product and teardown only reset user metadata, so a reused wp-env gained four products per run and later product suites could run against that residue. Track the ids the spec creates and hard-delete exactly those in afterEach, while WooCommerce is still active so its product lookup tables are cleaned too. The endpoint ignores ids that are not products, so a stale id cannot remove unrelated content. Co-Authored-By: Claude Opus 5 (1M context) --- .../mu-plugins/otter-e2e-bootstrap.php | 27 +++++++++++++++++++ .../e2e/blocks/woocommerce-builder.spec.js | 27 ++++++++++++++++--- src/blocks/test/e2e/fixtures.ts | 4 +++ 3 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php index 92bf31f8f..f36860194 100644 --- a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php +++ b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php @@ -1260,6 +1260,33 @@ function () { ) ); + register_rest_route( + REST_NAMESPACE, + '/woo/product/delete', + array( + 'methods' => \WP_REST_Server::CREATABLE, + 'permission_callback' => __NAMESPACE__ . '\\require_admin', + 'callback' => function ( \WP_REST_Request $request ) { + $deleted = array(); + + foreach ( (array) $request->get_param( 'ids' ) as $id ) { + $id = absint( $id ); + + // Only ever remove products, so a stale id from a spec + // cannot delete unrelated content in a reused env. + if ( 0 === $id || 'product' !== get_post_type( $id ) ) { + continue; + } + + wp_delete_post( $id, true ); + $deleted[] = $id; + } + + return rest_ensure_response( array( 'deleted' => $deleted ) ); + }, + ) + ); + register_rest_route( REST_NAMESPACE, '/user/meta-box-order', diff --git a/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js b/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js index 422219d96..4ce4b3273 100644 --- a/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js +++ b/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js @@ -50,16 +50,35 @@ test.describe( 'WooCommerce Builder product editor', () => { await otterUtils.resetMetaBoxesPane(); }; + // wp-env is reused between runs, so every product a test publishes has to + // be removed again or they pile up and skew later product suites. + let createdProductIds = []; + + const createProduct = async( otterUtils, args ) => { + const { id } = await otterUtils.createWooProduct( args ); + + createdProductIds.push( id ); + + return id; + }; + test.beforeEach( async({ otterUtils }) => { await resetSharedUserState( otterUtils ); }); + // Runs while WooCommerce is still active (afterAll deactivates it), so the + // deletes also clear its product lookup tables. test.afterEach( async({ otterUtils }) => { await resetSharedUserState( otterUtils ); + + if ( createdProductIds.length ) { + await otterUtils.deleteWooProducts( createdProductIds ); + createdProductIds = []; + } }); test( 'builder products open in the block editor with the Product data panel visible', async({ admin, page, otterUtils }) => { - const { id } = await otterUtils.createWooProduct({ builder: true }); + const id = await createProduct( otterUtils, { builder: true }); await admin.editPost( id ); await dismissOtterTour( page ); @@ -74,7 +93,7 @@ test.describe( 'WooCommerce Builder product editor', () => { }); test( 'Product data stranded in the side area is rescued into the drawer', async({ admin, page, otterUtils }) => { - const { id } = await otterUtils.createWooProduct({ builder: true }); + const id = await createProduct( otterUtils, { builder: true }); // The corrupted layout one metabox arrow click can persist: Product // data serialized into "side", which renders inside the ~280px @@ -93,7 +112,7 @@ test.describe( 'WooCommerce Builder product editor', () => { }); test( 'products without the builder keep the classic editor', async({ admin, page, otterUtils }) => { - const { id } = await otterUtils.createWooProduct({ builder: false }); + const id = await createProduct( otterUtils, { builder: false }); await admin.visitAdminPage( 'post.php', `post=${ id }&action=edit` ); @@ -102,7 +121,7 @@ test.describe( 'WooCommerce Builder product editor', () => { }); test( 'an explicitly collapsed drawer stays collapsed (user preference wins)', async({ admin, page, otterUtils }) => { - const { id } = await otterUtils.createWooProduct({ builder: true }); + const id = await createProduct( otterUtils, { builder: true }); await admin.editPost( id ); await dismissOtterTour( page ); diff --git a/src/blocks/test/e2e/fixtures.ts b/src/blocks/test/e2e/fixtures.ts index 68a6ab89c..d51ac18b0 100644 --- a/src/blocks/test/e2e/fixtures.ts +++ b/src/blocks/test/e2e/fixtures.ts @@ -67,6 +67,9 @@ export type OtterUtils = { /** Create a published simple WooCommerce product (price 49.99); `builder` also enables WooCommerce Builder on it. */ createWooProduct: ( args?: { title?: string; builder?: boolean } ) => Promise<{ id: number }>; + /** Hard-delete products created by a spec. Ids that are not products are ignored. */ + deleteWooProducts: ( ids: number[] ) => Promise; + /** Set the current user's product metabox order (`meta-box-order_product`); null/empty resets it. */ setProductMetaBoxOrder: ( order: Record | null ) => Promise; @@ -102,6 +105,7 @@ export const test = base.extend<{ otterUtils: OtterUtils }>({ getFormRecords: () => call( 'form/records' ) as Promise, cleanupFormRecords: () => call( 'form/records/cleanup' ), createWooProduct: ( args ) => call( 'woo/product', args ?? {}) as Promise<{ id: number }>, + deleteWooProducts: ( ids ) => call( 'woo/product/delete', { ids }), setProductMetaBoxOrder: ( order ) => call( 'user/meta-box-order', { order }), resetMetaBoxesPane: () => call( 'user/meta-boxes-pane/reset' ) }); From 6c74b2a6371dd5130df566c8eb33906d3681fdaa Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Mon, 27 Jul 2026 14:08:02 +0300 Subject: [PATCH 6/7] fix: restore the script registry after the WooCommerce Builder test Removing the dummy wp-edit-post handle in teardown left the shared $wp_scripts registry without WordPress's own registration, so later tests could fail depending on suite order. Discard the registry instead, which makes the next wp_scripts() call rebuild every default registration. Co-Authored-By: Claude Opus 5 (1M context) --- tests/test-woocommerce-builder.php | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/tests/test-woocommerce-builder.php b/tests/test-woocommerce-builder.php index ba4d32a4c..95fef5cae 100644 --- a/tests/test-woocommerce-builder.php +++ b/tests/test-woocommerce-builder.php @@ -71,7 +71,14 @@ public function set_up() { * Tear down. */ public function tear_down() { - wp_scripts()->remove( 'wp-edit-post' ); + /* + * Discard the whole registry instead of just the dummy handle: it is + * shared between tests, so removing the handle would leave it without + * WordPress's own wp-edit-post registration and break later tests + * depending on suite order. Nulling it makes the next wp_scripts() + * call rebuild every default registration. + */ + $GLOBALS['wp_scripts'] = null; parent::tear_down(); } From 9a2a00ea8d60f872deb7d3948a4a271c2f2f12c7 Mon Sep 17 00:00:00 2001 From: Soare Robert-Daniel Date: Mon, 27 Jul 2026 14:27:15 +0300 Subject: [PATCH 7/7] fix: run the suites against current WooCommerce The composer constraint is open but the lock still pinned 10.1.2 from a year ago, so mounting the Composer copy meant neither the new regression suite nor the PHPUnit bootstrap exercised current WooCommerce, and the pin also contradicted the php-stubs/woocommerce-stubs ^10.8 requirement. Refresh the lock to 10.9.4, the version the regression was reproduced on. Co-Authored-By: Claude Opus 5 (1M context) --- composer.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/composer.lock b/composer.lock index 0d8f0c84d..fabe7394d 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b41cfd1bb81a9d4f8ed32b2381a22d65", + "content-hash": "556f279ce85f25f708f60329f1f7067c", "packages": [ { "name": "codeinwp/themeisle-sdk", @@ -3113,15 +3113,15 @@ }, { "name": "wpackagist-plugin/woocommerce", - "version": "10.1.2", + "version": "10.9.4", "source": { "type": "svn", "url": "https://plugins.svn.wordpress.org/woocommerce/", - "reference": "tags/10.1.2" + "reference": "tags/10.9.4" }, "dist": { "type": "zip", - "url": "https://downloads.wordpress.org/plugin/woocommerce.10.1.2.zip" + "url": "https://downloads.wordpress.org/plugin/woocommerce.10.9.4.zip" }, "require": { "composer/installers": "^1.0 || ^2.0"