diff --git a/.wp-env.json b/.wp-env.json index ecc59bbe2..59e766d46 100644 --- a/.wp-env.json +++ b/.wp-env.json @@ -17,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/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" diff --git a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php index 882241e0a..f36860194 100644 --- a/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php +++ b/packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php @@ -1226,6 +1226,111 @@ 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 ) + ); + } + + // 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' ); + $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, + '/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', + 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 cdfdf59e0..20b379015 100644 --- a/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php +++ b/plugins/otter-pro/inc/plugins/class-woocommerce-builder.php @@ -30,6 +30,65 @@ 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' ) ); + 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 ( ! 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; + } + + $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; + } + + /** + * 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 + * @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 ) ) ) { + return; + } + + wp_add_inline_script( + 'wp-edit-post', + 'window.wp && wp.data && wp.data.dispatch( "core/preferences" ).setDefaults( "core/edit-post", { metaBoxesMainIsOpen: true } );' + ); } /** 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..4ce4b3273 --- /dev/null +++ b/src/blocks/test/e2e/blocks/woocommerce-builder.spec.js @@ -0,0 +1,145 @@ +/** + * 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', () => { + // 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' ); + }); + + // 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(); + }; + + // 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 createProduct( otterUtils, { 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 createProduct( otterUtils, { 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 createProduct( otterUtils, { 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 createProduct( otterUtils, { 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..d51ac18b0 100644 --- a/src/blocks/test/e2e/fixtures.ts +++ b/src/blocks/test/e2e/fixtures.ts @@ -63,6 +63,18 @@ 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 }>; + + /** 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; + + /** 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 +103,11 @@ 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 }>, + deleteWooProducts: ( ids ) => call( 'woo/product/delete', { ids }), + 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({ 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' ); diff --git a/tests/test-woocommerce-builder.php b/tests/test-woocommerce-builder.php new file mode 100644 index 000000000..95fef5cae --- /dev/null +++ b/tests/test-woocommerce-builder.php @@ -0,0 +1,149 @@ + + */ + 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() { + /* + * 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(); + } + + /** + * 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 ) ); + } +}