Skip to content
3 changes: 2 additions & 1 deletion .wp-env.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
},
"lifecycleScripts": {
"afterStart": "bash bin/e2e-tests.sh"
Expand Down
105 changes: 105 additions & 0 deletions packages/e2e-tests/mu-plugins/otter-e2e-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
59 changes: 59 additions & 0 deletions plugins/otter-pro/inc/plugins/class-woocommerce-builder.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' ) ) {
Comment thread
Soare-Robert-Daniel marked this conversation as resolved.
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 ) ) ) {
Comment on lines +83 to +84

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in 3bc2a95tests/test-woocommerce-builder.php creates the post contexts and inspects the wp-edit-post inline data: the default is present for a builder-enabled product and absent for both a product without the builder and a regular post.

return;
}

wp_add_inline_script(
'wp-edit-post',
'window.wp && wp.data && wp.data.dispatch( "core/preferences" ).setDefaults( "core/edit-post", { metaBoxesMainIsOpen: true } );'
);
}

/**
Expand Down
145 changes: 145 additions & 0 deletions src/blocks/test/e2e/blocks/woocommerce-builder.spec.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
18 changes: 17 additions & 1 deletion src/blocks/test/e2e/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ export type OtterUtils = {

/** Hard-delete all Submission Records. */
cleanupFormRecords: () => Promise<unknown>;

/** 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<unknown>;

/** Set the current user's product metabox order (`meta-box-order_product`); null/empty resets it. */
setProductMetaBoxOrder: ( order: Record<string, string> | null ) => Promise<unknown>;

/** Remove the current user's persisted meta-boxes-pane preferences (open state and height). */
resetMetaBoxesPane: () => Promise<unknown>;
};

export const test = base.extend<{ otterUtils: OtterUtils }>({
Expand Down Expand Up @@ -91,7 +103,11 @@ export const test = base.extend<{ otterUtils: OtterUtils }>({
return response.nonce;
},
getFormRecords: () => call( 'form/records' ) as Promise<FormRecord[]>,
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' )
});
}
});
Expand Down
5 changes: 4 additions & 1 deletion src/blocks/test/e2e/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
12 changes: 11 additions & 1 deletion tests/bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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' );
Expand Down
Loading
Loading