Commit 3eee7538c0a for woocommerce

commit 3eee7538c0a4b11c8d45df19a1abb134fd1d1878
Author: theAverageDev (Luca Tumedei) <luca.tumedei@automattic.com>
Date:   Wed Jun 10 22:48:44 2026 +0200

    Migrate Blocks e2e leaf utils and content-templates into Core suite (#65628)

diff --git a/plugins/woocommerce/changelog/qao-403-blocks-e2e-leaf-utils-content-templates b/plugins/woocommerce/changelog/qao-403-blocks-e2e-leaf-utils-content-templates
new file mode 100644
index 00000000000..47fa6e467a4
--- /dev/null
+++ b/plugins/woocommerce/changelog/qao-403-blocks-e2e-leaf-utils-content-templates
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Migrate Blocks e2e leaf utils (constants, test, types, wp-cli, get-test-translation, request-utils, navigation) and content-templates into the Core e2e suite under a blocks/ namespace; test-only, no changes to shipped code.
diff --git a/plugins/woocommerce/client/blocks/docs/contributors/e2e-guidelines.md b/plugins/woocommerce/client/blocks/docs/contributors/e2e-guidelines.md
index 2dc86742d47..c735ca55764 100644
--- a/plugins/woocommerce/client/blocks/docs/contributors/e2e-guidelines.md
+++ b/plugins/woocommerce/client/blocks/docs/contributors/e2e-guidelines.md
@@ -223,7 +223,7 @@ export class Editor extends CoreEditor {

 ### Content Templates

-We have created `RequestUtils.createPostFromFile()` and `RequestUtils.createTemplateFromFile()` utilities that enable creating complex content testing scenarios with Handlebars templates. The template files are kept in the [content-templates](../../tests/e2e/content-templates/) folder, so you can head there for some inspiration.
+We have created `RequestUtils.createPostFromFile()` and `RequestUtils.createTemplateFromFile()` utilities that enable creating complex content testing scenarios with Handlebars templates. The template files are kept in the [content-templates](../../../../tests/e2e-pw/content-templates/blocks/) folder, so you can head there for some inspiration.

 > [!IMPORTANT]
 > The Handlebars template filenames must be prefixed with the entity type. For posts, an example filename would be `post_with-filters.handlebars`, and for templates `template_archive-product_with-filters.handlebars`. Notice that the latter contains the slug of the template (`archive-product`) before the name (`with-filters`), separated with an underscore - it's necessary for the template to be properly loaded and created.
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/constants.ts b/plugins/woocommerce/client/blocks/tests/e2e/utils/constants.ts
index 1a214960832..6264ea0d396 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/constants.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/utils/constants.ts
@@ -1,39 +1,5 @@
-/**
- * External dependencies
- */
-import path from 'path';
-
-export const BLOCK_THEME_WITH_TEMPLATES_SLUG = 'theme-with-woo-templates';
-export const BLOCK_THEME_WITH_TEMPLATES_NAME = 'Theme with Woo Templates';
-export const BLOCK_THEME_SLUG = 'twentytwentyfour';
-export const BLOCK_THEME_NAME = 'Twenty Twenty-Four';
-export const BLOCK_CHILD_THEME_WITH_BLOCK_NOTICES_FILTER_SLUG = `${ BLOCK_THEME_SLUG }-child__block-notices-filter`;
-export const BLOCK_CHILD_THEME_WITH_BLOCK_NOTICES_TEMPLATE_SLUG = `${ BLOCK_THEME_SLUG }-child__block-notices-template`;
-export const BLOCK_CHILD_THEME_WITH_CLASSIC_NOTICES_TEMPLATE_SLUG = `${ BLOCK_THEME_SLUG }-child__classic-notices-template`;
-export const CLASSIC_THEME_SLUG = 'storefront';
-export const CLASSIC_THEME_NAME = 'Storefront';
-export const CLASSIC_CHILD_THEME_WITH_BLOCK_NOTICES_FILTER_SLUG = `${ CLASSIC_THEME_SLUG }-child__block-notices-filter`;
-export const CLASSIC_CHILD_THEME_WITH_BLOCK_NOTICES_TEMPLATE_SLUG = `${ CLASSIC_THEME_SLUG }-child__block-notices-template`;
-export const CLASSIC_CHILD_THEME_WITH_CLASSIC_NOTICES_TEMPLATE_SLUG = `${ CLASSIC_THEME_SLUG }-child__classic-notices-template`;
-export const CLASSIC_CHILD_THEME_WITH_BLOCK_TEMPLATE_PARTS_SLUG = `${ CLASSIC_THEME_SLUG }-child__with-block-template-part`;
-export const CLASSIC_CHILD_THEME_WITH_BLOCK_TEMPLATE_PARTS_SUPPORT_SLUG = `${ CLASSIC_THEME_SLUG }-child__with-block-template-part-support`;
-export const BASE_URL =
-	'http://localhost:' + ( process.env.WP_ENV_TESTS_PORT || '8889' );
-
-export const WP_ARTIFACTS_PATH =
-	process.env.WP_ARTIFACTS_PATH ||
-	path.join( process.cwd(), 'tests/e2e/artifacts' );
-
-export const STORAGE_STATE_PATH =
-	process.env.STORAGE_STATE_PATH ||
-	path.join( WP_ARTIFACTS_PATH, 'storage-states/admin.json' );
-
-// User roles storage states
-export const adminFile = STORAGE_STATE_PATH;
-export const customerFile = path.join(
-	path.dirname( STORAGE_STATE_PATH ),
-	'customer.json'
-);
-export const guestFile = { cookies: [], origins: [] };
-
-export const DB_EXPORT_FILE = 'blocks_e2e.sql';
+// Compatibility shim: the real module moved to tests/e2e-pw/utils/blocks
+// during the QAO-185 e2e merge. The re-export keeps the old import path (used
+// by the blocks e2e utils barrel) working. Removed in QAO-407 (#6) with the
+// rest of this tree.
+export * from '../../../../../tests/e2e-pw/utils/blocks/constants';
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/get-test-translation.js b/plugins/woocommerce/client/blocks/tests/e2e/utils/get-test-translation.js
index a1b3b8a0f4b..d14a27c23f1 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/get-test-translation.js
+++ b/plugins/woocommerce/client/blocks/tests/e2e/utils/get-test-translation.js
@@ -1,9 +1,5 @@
-/* Return a test translated string.
- *
- * @param {string} string The string to be checked.
- *
- * @return {string} Test translated string.
- */
-const getTestTranslation = ( string ) => `Translated ${ string }`;
-
-module.exports = { getTestTranslation };
+// Compatibility shim: the real module moved to tests/e2e-pw/utils/blocks
+// during the QAO-185 e2e merge. Kept as CommonJS because
+// bin/generate-test-translations.js require()s it via plain node; an ESM
+// re-export would throw. Removed in QAO-407 (#6) with the rest of this tree.
+module.exports = require( '../../../../../tests/e2e-pw/utils/blocks/get-test-translation.js' );
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/navigation/navigation.ts b/plugins/woocommerce/client/blocks/tests/e2e/utils/navigation/navigation.ts
index 731c56d9213..fb595ad4493 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/navigation/navigation.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/utils/navigation/navigation.ts
@@ -1,40 +1,4 @@
-/**
- * External dependencies
- */
-import { Page, PlaywrightTestArgs } from '@playwright/test';
-import { BlockData } from '@woocommerce/e2e-utils';
-
-/**
- * Closes any modals in the editor if they are open.
- */
-export const closeModalIfExists = async ( page: Page ) => {
-	// The modal close button can have different aria-labels, depending on the version of Gutenberg/WP.
-	// Newer versions (WP >=6.2) use `Close`, while older versions (WP <6.1) use `Close dialog`.
-	const closeButton = page.getByLabel( /^Close$|^Close dialog$/ );
-	if ( ( await closeButton.count() ) > 0 ) {
-		await closeButton.click();
-	}
-};
-
-/**
- * Goes to the edit page of a specified block.
- */
-export const editBlockPage = async (
-	page: PlaywrightTestArgs[ 'page' ],
-	{ name, selectors }: BlockData
-) => {
-	const {
-		editor: { block: blockSelector },
-	} = selectors;
-	await page.goto(
-		`/wp-admin/edit.php?post_type=page&s=${ encodeURIComponent( name ) }`
-	);
-
-	// This is the link to the edit page of the block, this is the page's title.
-	await page
-		.getByRole( 'link', { name: `“${ name } block” (Edit)` } )
-		.click();
-
-	await page.locator( blockSelector as string ).waitFor();
-	await closeModalIfExists( page );
-};
+// Compatibility shim: the real module moved to tests/e2e-pw/utils/blocks
+// during the QAO-185 e2e merge. The re-export keeps the old import path
+// working. Removed in QAO-407 (#6) with the rest of this tree.
+export * from '../../../../../../tests/e2e-pw/utils/blocks/navigation/navigation';
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/index.ts b/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/index.ts
index b3b473eeb56..2521e24caac 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/index.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/index.ts
@@ -1,35 +1,5 @@
-/**
- * External dependencies
- */
-import { RequestUtils as CoreRequestUtils } from '@wordpress/e2e-test-utils-playwright';
-
-/**
- * Internal dependencies
- */
-import { createPostFromFile, PostCompiler } from './posts';
-import {
-	getTemplates,
-	revertTemplate,
-	createTemplateFromFile,
-	TemplateCompiler,
-} from './templates';
-import { resetFeatureFlag, setFeatureFlag } from './feature-flag';
-
-export class RequestUtils extends CoreRequestUtils {
-	/** @borrows getTemplates as this.getTemplates */
-	getTemplates: typeof getTemplates = getTemplates.bind( this );
-	/** @borrows revertTemplate as this.revertTemplate */
-	revertTemplate: typeof revertTemplate = revertTemplate.bind( this );
-	/** @borrows createPostFromFile as this.createPostFromFile */
-	createPostFromFile: typeof createPostFromFile =
-		createPostFromFile.bind( this );
-	/** @borrows createTemplateFromFile as this.createTemplateFromFile */
-	createTemplateFromFile: typeof createTemplateFromFile =
-		createTemplateFromFile.bind( this );
-	/** @borrows setFeatureFlag as this.setFeatureFlag */
-	setFeatureFlag: typeof setFeatureFlag = setFeatureFlag.bind( this );
-	/** @borrows resetFeatureFlag as this.resetFeatureFlag */
-	resetFeatureFlag: typeof resetFeatureFlag = resetFeatureFlag.bind( this );
-}
-
-export { TemplateCompiler, PostCompiler };
+// Compatibility shim: the real module moved to tests/e2e-pw/utils/blocks
+// during the QAO-185 e2e merge. The re-export keeps the old import path (used
+// by the blocks e2e utils barrel) working. Removed in QAO-407 (#6) with the
+// rest of this tree.
+export * from '../../../../../../tests/e2e-pw/utils/blocks/request-utils';
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/test.ts b/plugins/woocommerce/client/blocks/tests/e2e/utils/test.ts
index 95ad62c09a5..9a8cdc46b18 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/test.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/utils/test.ts
@@ -1,200 +1,5 @@
-/* eslint-disable rulesdir/no-raw-playwright-test-import */
-/**
- * External dependencies
- */
-import { test as base, expect, ConsoleMessage } from '@playwright/test';
-import {
-	STORAGE_STATE_PATH,
-	DB_EXPORT_FILE,
-	wpCLI,
-	Admin,
-	Editor,
-	FrontendUtils,
-	LocalPickupUtils,
-	MiniCartUtils,
-	PageUtils,
-	PerformanceUtils,
-	RequestUtils,
-	ShippingUtils,
-} from '@woocommerce/e2e-utils';
-
-/**
- * Set of console logging types observed to protect against unexpected yet
- * handled (i.e. not catastrophic) errors or warnings. Each key corresponds
- * to the Playwright ConsoleMessage type, its value the corresponding function
- * on the console global object.
- */
-const OBSERVED_CONSOLE_MESSAGE_TYPES = [ 'warn', 'error' ] as const;
-
-/**
- * Adds a page event handler to emit uncaught exception to process if one of
- * the observed console logging types is encountered.
- *
- * @param message The console message.
- */
-function observeConsoleLogging( message: ConsoleMessage ) {
-	const type = message.type();
-	if (
-		! OBSERVED_CONSOLE_MESSAGE_TYPES.includes(
-			type as ( typeof OBSERVED_CONSOLE_MESSAGE_TYPES )[ number ]
-		)
-	) {
-		return;
-	}
-
-	const text = message.text();
-
-	// An exception is made for _blanket_ deprecation warnings: Those
-	// which log regardless of whether a deprecated feature is in use.
-	if ( text.includes( 'This is a global warning' ) ) {
-		return;
-	}
-
-	// A chrome advisory warning about SameSite cookies is informational
-	// about future changes, tracked separately for improvement in core.
-	//
-	// See: https://core.trac.wordpress.org/ticket/37000
-	// See: https://www.chromestatus.com/feature/5088147346030592
-	// See: https://www.chromestatus.com/feature/5633521622188032
-	if ( text.includes( 'A cookie associated with a cross-site resource' ) ) {
-		return;
-	}
-
-	// Viewing posts on the front end can result in this error, which
-	// has nothing to do with Gutenberg.
-	if ( text.includes( 'net::ERR_UNKNOWN_URL_SCHEME' ) ) {
-		return;
-	}
-
-	// Not implemented yet.
-	// Network errors are ignored only if we are intentionally testing
-	// offline mode.
-	// if (
-	// 	text.includes( 'net::ERR_INTERNET_DISCONNECTED' ) &&
-	// 	isOfflineMode()
-	// ) {
-	// 	return;
-	// }
-
-	// As of WordPress 5.3.2 in Chrome 79, navigating to the block editor
-	// (Posts > Add New) will display a console warning about
-	// non - unique IDs.
-	// See: https://core.trac.wordpress.org/ticket/23165
-	if ( text.includes( 'elements with non-unique id #_wpnonce' ) ) {
-		return;
-	}
-
-	// Ignore all JQMIGRATE (jQuery migrate) deprecation warnings.
-	if ( text.includes( 'JQMIGRATE' ) ) {
-		return;
-	}
-
-	const logFunction =
-		type as ( typeof OBSERVED_CONSOLE_MESSAGE_TYPES )[ number ];
-
-	// Disable reason: We intentionally bubble up the console message
-	// which, unless the test explicitly anticipates the logging via
-	// @wordpress/jest-console matchers, will cause the intended test
-	// failure.
-
-	// eslint-disable-next-line no-console
-	console[ logFunction ]( text );
-}
-
-const test = base.extend<
-	{
-		admin: Admin;
-		editor: Editor;
-		pageUtils: PageUtils;
-		frontendUtils: FrontendUtils;
-		performanceUtils: PerformanceUtils;
-		snapshotConfig: void;
-		shippingUtils: ShippingUtils;
-		localPickupUtils: LocalPickupUtils;
-		miniCartUtils: MiniCartUtils;
-	},
-	{
-		requestUtils: RequestUtils;
-		wpCoreVersion: number;
-	}
->( {
-	admin: async ( { page, pageUtils, editor, wpCoreVersion }, use ) => {
-		await use( new Admin( { page, pageUtils, editor, wpCoreVersion } ) );
-	},
-	editor: async ( { page, wpCoreVersion }, use ) => {
-		await use( new Editor( { page, wpCoreVersion } ) );
-	},
-	page: async ( { page }, use ) => {
-		page.on( 'console', observeConsoleLogging );
-
-		await use( page );
-
-		// Clear local storage after each test.
-		try {
-			await page.evaluate( () => {
-				window.localStorage.clear();
-			} );
-		} catch ( error ) {
-			// Ignore errors if page is already closed/navigated away
-			// eslint-disable-next-line no-console
-			console.log( 'Failed to clear localStorage:', error.message );
-		}
-
-		// Dispose the current APIRequestContext to free up resources.
-		await page.request.dispose();
-
-		await wpCLI( `db reset --yes` );
-		// Reset the database to the initial state via snapshot import.
-		await wpCLI( `db import ${ DB_EXPORT_FILE }` );
-	},
-	pageUtils: async ( { page }, use ) => {
-		await use( new PageUtils( { page } ) );
-	},
-	frontendUtils: async ( { page, requestUtils }, use ) => {
-		await use( new FrontendUtils( page, requestUtils ) );
-	},
-	performanceUtils: async ( { page }, use ) => {
-		await use( new PerformanceUtils( page ) );
-	},
-	shippingUtils: async ( { page, admin }, use ) => {
-		await use( new ShippingUtils( page, admin ) );
-	},
-	localPickupUtils: async ( { page, admin }, use ) => {
-		await use( new LocalPickupUtils( page, admin ) );
-	},
-	miniCartUtils: async ( { page, frontendUtils }, use ) => {
-		await use( new MiniCartUtils( page, frontendUtils ) );
-	},
-	requestUtils: [
-		async ( {}, use, workerInfo ) => {
-			const requestUtils = await RequestUtils.setup( {
-				baseURL: workerInfo.project.use.baseURL as string,
-				storageStatePath: STORAGE_STATE_PATH,
-			} );
-
-			await use( requestUtils );
-		},
-		{ scope: 'worker', auto: true },
-	],
-	wpCoreVersion: [
-		async ( {}, use ) => {
-			const output = await wpCLI( 'core version' );
-			const version = output.stdout.trim().split( '\n' ).at( -1 ) ?? '';
-
-			// We can parse this as a float because WP never updates the minor
-			// version over x.9.x. E.g., after 6.9.x, it will be 7.0.x.
-			const parsedVersion = Number.parseFloat( version );
-
-			if ( Number.isNaN( parsedVersion ) ) {
-				throw new Error(
-					`Failed to parse WordPress version: ${ version }`
-				);
-			}
-
-			await use( parsedVersion );
-		},
-		{ scope: 'worker' },
-	],
-} );
-
-export { test, expect };
+// Compatibility shim: the real module moved to tests/e2e-pw/utils/blocks
+// during the QAO-185 e2e merge. The re-export keeps the old import path (used
+// by the blocks e2e utils barrel) working. Removed in QAO-407 (#6) with the
+// rest of this tree.
+export * from '../../../../../tests/e2e-pw/utils/blocks/test';
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/types.ts b/plugins/woocommerce/client/blocks/tests/e2e/utils/types.ts
index f80df3679a2..5c5c526dffb 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/types.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/utils/types.ts
@@ -1,6 +1,5 @@
-export type BlockData< T = unknown > = {
-	name: string;
-	slug: string;
-	mainClass: string;
-	selectors: Record< 'editor' | 'frontend', Record< string, unknown > >;
-} & ( T extends undefined ? Record< string, never > : T );
+// Compatibility shim: the real module moved to tests/e2e-pw/utils/blocks
+// during the QAO-185 e2e merge. The re-export keeps the old import path (used
+// by the blocks e2e utils barrel) working. Removed in QAO-407 (#6) with the
+// rest of this tree.
+export * from '../../../../../tests/e2e-pw/utils/blocks/types';
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/wp-cli.ts b/plugins/woocommerce/client/blocks/tests/e2e/utils/wp-cli.ts
index 214841520fb..8b0969efc54 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/wp-cli.ts
+++ b/plugins/woocommerce/client/blocks/tests/e2e/utils/wp-cli.ts
@@ -1,16 +1,5 @@
-/**
- * External dependencies
- */
-import { promisify } from 'util';
-import { exec } from 'child_process';
-
-const execPromisified = promisify( exec );
-
-/**
- * Runs a WP-CLI command inside the tests-cli container.
- */
-export async function wpCLI( command: string ) {
-	return await execPromisified(
-		'npm run wp-env run tests-cli -- wp ' + command
-	);
-}
+// Compatibility shim: the real module moved to tests/e2e-pw/utils/blocks
+// during the QAO-185 e2e merge. The re-export keeps the old import path (used
+// by the blocks e2e utils barrel) working. Removed in QAO-407 (#6) with the
+// rest of this tree.
+export * from '../../../../../tests/e2e-pw/utils/blocks/wp-cli';
diff --git a/plugins/woocommerce/package.json b/plugins/woocommerce/package.json
index 8109efae023..c4009469ade 100644
--- a/plugins/woocommerce/package.json
+++ b/plugins/woocommerce/package.json
@@ -946,6 +946,7 @@
 		"eslint-config-wpcalypso": "5.0.0",
 		"eslint-plugin-jest": "23.20.0",
 		"eslint-plugin-playwright": "1.6.0",
+		"handlebars": "^4.7.9",
 		"jest": "29.5.x",
 		"playwright-ctrf-json-reporter": "0.0.27",
 		"prettier": "npm:wp-prettier@^2.8.5",
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/content-templates/post_filters-with-all-products.handlebars b/plugins/woocommerce/tests/e2e-pw/content-templates/blocks/post_filters-with-all-products.handlebars
similarity index 100%
rename from plugins/woocommerce/client/blocks/tests/e2e/content-templates/post_filters-with-all-products.handlebars
rename to plugins/woocommerce/tests/e2e-pw/content-templates/blocks/post_filters-with-all-products.handlebars
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_active-filters.handlebars b/plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_active-filters.handlebars
similarity index 100%
rename from plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_active-filters.handlebars
rename to plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_active-filters.handlebars
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_attribute-filter.handlebars b/plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_attribute-filter.handlebars
similarity index 100%
rename from plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_attribute-filter.handlebars
rename to plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_attribute-filter.handlebars
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_filters-with-product-collection.handlebars b/plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_filters-with-product-collection.handlebars
similarity index 100%
rename from plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_filters-with-product-collection.handlebars
rename to plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_filters-with-product-collection.handlebars
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_price-filter.handlebars b/plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_price-filter.handlebars
similarity index 100%
rename from plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_price-filter.handlebars
rename to plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_price-filter.handlebars
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_rating-filter.handlebars b/plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_rating-filter.handlebars
similarity index 100%
rename from plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_rating-filter.handlebars
rename to plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_rating-filter.handlebars
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_stock-status.handlebars b/plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_stock-status.handlebars
similarity index 100%
rename from plugins/woocommerce/client/blocks/tests/e2e/content-templates/template_archive-product_stock-status.handlebars
rename to plugins/woocommerce/tests/e2e-pw/content-templates/blocks/template_archive-product_stock-status.handlebars
diff --git a/plugins/woocommerce/tests/e2e-pw/utils/blocks/constants.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/constants.ts
new file mode 100644
index 00000000000..1a214960832
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/constants.ts
@@ -0,0 +1,39 @@
+/**
+ * External dependencies
+ */
+import path from 'path';
+
+export const BLOCK_THEME_WITH_TEMPLATES_SLUG = 'theme-with-woo-templates';
+export const BLOCK_THEME_WITH_TEMPLATES_NAME = 'Theme with Woo Templates';
+export const BLOCK_THEME_SLUG = 'twentytwentyfour';
+export const BLOCK_THEME_NAME = 'Twenty Twenty-Four';
+export const BLOCK_CHILD_THEME_WITH_BLOCK_NOTICES_FILTER_SLUG = `${ BLOCK_THEME_SLUG }-child__block-notices-filter`;
+export const BLOCK_CHILD_THEME_WITH_BLOCK_NOTICES_TEMPLATE_SLUG = `${ BLOCK_THEME_SLUG }-child__block-notices-template`;
+export const BLOCK_CHILD_THEME_WITH_CLASSIC_NOTICES_TEMPLATE_SLUG = `${ BLOCK_THEME_SLUG }-child__classic-notices-template`;
+export const CLASSIC_THEME_SLUG = 'storefront';
+export const CLASSIC_THEME_NAME = 'Storefront';
+export const CLASSIC_CHILD_THEME_WITH_BLOCK_NOTICES_FILTER_SLUG = `${ CLASSIC_THEME_SLUG }-child__block-notices-filter`;
+export const CLASSIC_CHILD_THEME_WITH_BLOCK_NOTICES_TEMPLATE_SLUG = `${ CLASSIC_THEME_SLUG }-child__block-notices-template`;
+export const CLASSIC_CHILD_THEME_WITH_CLASSIC_NOTICES_TEMPLATE_SLUG = `${ CLASSIC_THEME_SLUG }-child__classic-notices-template`;
+export const CLASSIC_CHILD_THEME_WITH_BLOCK_TEMPLATE_PARTS_SLUG = `${ CLASSIC_THEME_SLUG }-child__with-block-template-part`;
+export const CLASSIC_CHILD_THEME_WITH_BLOCK_TEMPLATE_PARTS_SUPPORT_SLUG = `${ CLASSIC_THEME_SLUG }-child__with-block-template-part-support`;
+export const BASE_URL =
+	'http://localhost:' + ( process.env.WP_ENV_TESTS_PORT || '8889' );
+
+export const WP_ARTIFACTS_PATH =
+	process.env.WP_ARTIFACTS_PATH ||
+	path.join( process.cwd(), 'tests/e2e/artifacts' );
+
+export const STORAGE_STATE_PATH =
+	process.env.STORAGE_STATE_PATH ||
+	path.join( WP_ARTIFACTS_PATH, 'storage-states/admin.json' );
+
+// User roles storage states
+export const adminFile = STORAGE_STATE_PATH;
+export const customerFile = path.join(
+	path.dirname( STORAGE_STATE_PATH ),
+	'customer.json'
+);
+export const guestFile = { cookies: [], origins: [] };
+
+export const DB_EXPORT_FILE = 'blocks_e2e.sql';
diff --git a/plugins/woocommerce/tests/e2e-pw/utils/blocks/get-test-translation.js b/plugins/woocommerce/tests/e2e-pw/utils/blocks/get-test-translation.js
new file mode 100644
index 00000000000..a1b3b8a0f4b
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/get-test-translation.js
@@ -0,0 +1,9 @@
+/* Return a test translated string.
+ *
+ * @param {string} string The string to be checked.
+ *
+ * @return {string} Test translated string.
+ */
+const getTestTranslation = ( string ) => `Translated ${ string }`;
+
+module.exports = { getTestTranslation };
diff --git a/plugins/woocommerce/tests/e2e-pw/utils/blocks/navigation/navigation.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/navigation/navigation.ts
new file mode 100644
index 00000000000..731c56d9213
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/navigation/navigation.ts
@@ -0,0 +1,40 @@
+/**
+ * External dependencies
+ */
+import { Page, PlaywrightTestArgs } from '@playwright/test';
+import { BlockData } from '@woocommerce/e2e-utils';
+
+/**
+ * Closes any modals in the editor if they are open.
+ */
+export const closeModalIfExists = async ( page: Page ) => {
+	// The modal close button can have different aria-labels, depending on the version of Gutenberg/WP.
+	// Newer versions (WP >=6.2) use `Close`, while older versions (WP <6.1) use `Close dialog`.
+	const closeButton = page.getByLabel( /^Close$|^Close dialog$/ );
+	if ( ( await closeButton.count() ) > 0 ) {
+		await closeButton.click();
+	}
+};
+
+/**
+ * Goes to the edit page of a specified block.
+ */
+export const editBlockPage = async (
+	page: PlaywrightTestArgs[ 'page' ],
+	{ name, selectors }: BlockData
+) => {
+	const {
+		editor: { block: blockSelector },
+	} = selectors;
+	await page.goto(
+		`/wp-admin/edit.php?post_type=page&s=${ encodeURIComponent( name ) }`
+	);
+
+	// This is the link to the edit page of the block, this is the page's title.
+	await page
+		.getByRole( 'link', { name: `“${ name } block” (Edit)` } )
+		.click();
+
+	await page.locator( blockSelector as string ).waitFor();
+	await closeModalIfExists( page );
+};
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/feature-flag.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/feature-flag.ts
similarity index 100%
rename from plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/feature-flag.ts
rename to plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/feature-flag.ts
diff --git a/plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/index.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/index.ts
new file mode 100644
index 00000000000..b3b473eeb56
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/index.ts
@@ -0,0 +1,35 @@
+/**
+ * External dependencies
+ */
+import { RequestUtils as CoreRequestUtils } from '@wordpress/e2e-test-utils-playwright';
+
+/**
+ * Internal dependencies
+ */
+import { createPostFromFile, PostCompiler } from './posts';
+import {
+	getTemplates,
+	revertTemplate,
+	createTemplateFromFile,
+	TemplateCompiler,
+} from './templates';
+import { resetFeatureFlag, setFeatureFlag } from './feature-flag';
+
+export class RequestUtils extends CoreRequestUtils {
+	/** @borrows getTemplates as this.getTemplates */
+	getTemplates: typeof getTemplates = getTemplates.bind( this );
+	/** @borrows revertTemplate as this.revertTemplate */
+	revertTemplate: typeof revertTemplate = revertTemplate.bind( this );
+	/** @borrows createPostFromFile as this.createPostFromFile */
+	createPostFromFile: typeof createPostFromFile =
+		createPostFromFile.bind( this );
+	/** @borrows createTemplateFromFile as this.createTemplateFromFile */
+	createTemplateFromFile: typeof createTemplateFromFile =
+		createTemplateFromFile.bind( this );
+	/** @borrows setFeatureFlag as this.setFeatureFlag */
+	setFeatureFlag: typeof setFeatureFlag = setFeatureFlag.bind( this );
+	/** @borrows resetFeatureFlag as this.resetFeatureFlag */
+	resetFeatureFlag: typeof resetFeatureFlag = resetFeatureFlag.bind( this );
+}
+
+export { TemplateCompiler, PostCompiler };
diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/posts.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/posts.ts
similarity index 94%
rename from plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/posts.ts
rename to plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/posts.ts
index d7bcc51bfd7..3dca50f3c19 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/posts.ts
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/posts.ts
@@ -17,13 +17,13 @@ export interface PostCompiler {

 /**
  * Creates a post from a Handlebars template file located in the
- * tests/e2e/content-templates directory.
+ * tests/e2e-pw/content-templates/blocks directory.
  */
 export async function createPostFromFile( this: RequestUtils, name: string ) {
 	const filePrefix = 'post';
 	const filePath = path.resolve(
 		__dirname,
-		'../../content-templates',
+		'../../../content-templates/blocks',
 		`${ filePrefix }_${ name }.handlebars` // e.g. post_with-custom-filters.handlebars
 	);

diff --git a/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/templates.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/templates.ts
similarity index 96%
rename from plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/templates.ts
rename to plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/templates.ts
index 4a5fbd8c060..c13e6a21ef2 100644
--- a/plugins/woocommerce/client/blocks/tests/e2e/utils/request-utils/templates.ts
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/request-utils/templates.ts
@@ -62,7 +62,7 @@ export async function revertTemplate( this: RequestUtils, slug: string ) {

 /**
  * Creates a WP template from a Handlebars template file located in the
- * tests/e2e/content-templates directory.
+ * tests/e2e-pw/content-templates/blocks directory.
  */
 export async function createTemplateFromFile(
 	this: RequestUtils,
@@ -76,7 +76,7 @@ export async function createTemplateFromFile(
 	const filePrefix = 'template';
 	const filePath = path.resolve(
 		__dirname,
-		'../../content-templates',
+		'../../../content-templates/blocks',
 		`${ filePrefix }_${ name }.handlebars` // e.g. template_product-archive_with-custom-filters.handlebars
 	);

diff --git a/plugins/woocommerce/tests/e2e-pw/utils/blocks/test.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/test.ts
new file mode 100644
index 00000000000..95ad62c09a5
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/test.ts
@@ -0,0 +1,200 @@
+/* eslint-disable rulesdir/no-raw-playwright-test-import */
+/**
+ * External dependencies
+ */
+import { test as base, expect, ConsoleMessage } from '@playwright/test';
+import {
+	STORAGE_STATE_PATH,
+	DB_EXPORT_FILE,
+	wpCLI,
+	Admin,
+	Editor,
+	FrontendUtils,
+	LocalPickupUtils,
+	MiniCartUtils,
+	PageUtils,
+	PerformanceUtils,
+	RequestUtils,
+	ShippingUtils,
+} from '@woocommerce/e2e-utils';
+
+/**
+ * Set of console logging types observed to protect against unexpected yet
+ * handled (i.e. not catastrophic) errors or warnings. Each key corresponds
+ * to the Playwright ConsoleMessage type, its value the corresponding function
+ * on the console global object.
+ */
+const OBSERVED_CONSOLE_MESSAGE_TYPES = [ 'warn', 'error' ] as const;
+
+/**
+ * Adds a page event handler to emit uncaught exception to process if one of
+ * the observed console logging types is encountered.
+ *
+ * @param message The console message.
+ */
+function observeConsoleLogging( message: ConsoleMessage ) {
+	const type = message.type();
+	if (
+		! OBSERVED_CONSOLE_MESSAGE_TYPES.includes(
+			type as ( typeof OBSERVED_CONSOLE_MESSAGE_TYPES )[ number ]
+		)
+	) {
+		return;
+	}
+
+	const text = message.text();
+
+	// An exception is made for _blanket_ deprecation warnings: Those
+	// which log regardless of whether a deprecated feature is in use.
+	if ( text.includes( 'This is a global warning' ) ) {
+		return;
+	}
+
+	// A chrome advisory warning about SameSite cookies is informational
+	// about future changes, tracked separately for improvement in core.
+	//
+	// See: https://core.trac.wordpress.org/ticket/37000
+	// See: https://www.chromestatus.com/feature/5088147346030592
+	// See: https://www.chromestatus.com/feature/5633521622188032
+	if ( text.includes( 'A cookie associated with a cross-site resource' ) ) {
+		return;
+	}
+
+	// Viewing posts on the front end can result in this error, which
+	// has nothing to do with Gutenberg.
+	if ( text.includes( 'net::ERR_UNKNOWN_URL_SCHEME' ) ) {
+		return;
+	}
+
+	// Not implemented yet.
+	// Network errors are ignored only if we are intentionally testing
+	// offline mode.
+	// if (
+	// 	text.includes( 'net::ERR_INTERNET_DISCONNECTED' ) &&
+	// 	isOfflineMode()
+	// ) {
+	// 	return;
+	// }
+
+	// As of WordPress 5.3.2 in Chrome 79, navigating to the block editor
+	// (Posts > Add New) will display a console warning about
+	// non - unique IDs.
+	// See: https://core.trac.wordpress.org/ticket/23165
+	if ( text.includes( 'elements with non-unique id #_wpnonce' ) ) {
+		return;
+	}
+
+	// Ignore all JQMIGRATE (jQuery migrate) deprecation warnings.
+	if ( text.includes( 'JQMIGRATE' ) ) {
+		return;
+	}
+
+	const logFunction =
+		type as ( typeof OBSERVED_CONSOLE_MESSAGE_TYPES )[ number ];
+
+	// Disable reason: We intentionally bubble up the console message
+	// which, unless the test explicitly anticipates the logging via
+	// @wordpress/jest-console matchers, will cause the intended test
+	// failure.
+
+	// eslint-disable-next-line no-console
+	console[ logFunction ]( text );
+}
+
+const test = base.extend<
+	{
+		admin: Admin;
+		editor: Editor;
+		pageUtils: PageUtils;
+		frontendUtils: FrontendUtils;
+		performanceUtils: PerformanceUtils;
+		snapshotConfig: void;
+		shippingUtils: ShippingUtils;
+		localPickupUtils: LocalPickupUtils;
+		miniCartUtils: MiniCartUtils;
+	},
+	{
+		requestUtils: RequestUtils;
+		wpCoreVersion: number;
+	}
+>( {
+	admin: async ( { page, pageUtils, editor, wpCoreVersion }, use ) => {
+		await use( new Admin( { page, pageUtils, editor, wpCoreVersion } ) );
+	},
+	editor: async ( { page, wpCoreVersion }, use ) => {
+		await use( new Editor( { page, wpCoreVersion } ) );
+	},
+	page: async ( { page }, use ) => {
+		page.on( 'console', observeConsoleLogging );
+
+		await use( page );
+
+		// Clear local storage after each test.
+		try {
+			await page.evaluate( () => {
+				window.localStorage.clear();
+			} );
+		} catch ( error ) {
+			// Ignore errors if page is already closed/navigated away
+			// eslint-disable-next-line no-console
+			console.log( 'Failed to clear localStorage:', error.message );
+		}
+
+		// Dispose the current APIRequestContext to free up resources.
+		await page.request.dispose();
+
+		await wpCLI( `db reset --yes` );
+		// Reset the database to the initial state via snapshot import.
+		await wpCLI( `db import ${ DB_EXPORT_FILE }` );
+	},
+	pageUtils: async ( { page }, use ) => {
+		await use( new PageUtils( { page } ) );
+	},
+	frontendUtils: async ( { page, requestUtils }, use ) => {
+		await use( new FrontendUtils( page, requestUtils ) );
+	},
+	performanceUtils: async ( { page }, use ) => {
+		await use( new PerformanceUtils( page ) );
+	},
+	shippingUtils: async ( { page, admin }, use ) => {
+		await use( new ShippingUtils( page, admin ) );
+	},
+	localPickupUtils: async ( { page, admin }, use ) => {
+		await use( new LocalPickupUtils( page, admin ) );
+	},
+	miniCartUtils: async ( { page, frontendUtils }, use ) => {
+		await use( new MiniCartUtils( page, frontendUtils ) );
+	},
+	requestUtils: [
+		async ( {}, use, workerInfo ) => {
+			const requestUtils = await RequestUtils.setup( {
+				baseURL: workerInfo.project.use.baseURL as string,
+				storageStatePath: STORAGE_STATE_PATH,
+			} );
+
+			await use( requestUtils );
+		},
+		{ scope: 'worker', auto: true },
+	],
+	wpCoreVersion: [
+		async ( {}, use ) => {
+			const output = await wpCLI( 'core version' );
+			const version = output.stdout.trim().split( '\n' ).at( -1 ) ?? '';
+
+			// We can parse this as a float because WP never updates the minor
+			// version over x.9.x. E.g., after 6.9.x, it will be 7.0.x.
+			const parsedVersion = Number.parseFloat( version );
+
+			if ( Number.isNaN( parsedVersion ) ) {
+				throw new Error(
+					`Failed to parse WordPress version: ${ version }`
+				);
+			}
+
+			await use( parsedVersion );
+		},
+		{ scope: 'worker' },
+	],
+} );
+
+export { test, expect };
diff --git a/plugins/woocommerce/tests/e2e-pw/utils/blocks/types.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/types.ts
new file mode 100644
index 00000000000..f80df3679a2
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/types.ts
@@ -0,0 +1,6 @@
+export type BlockData< T = unknown > = {
+	name: string;
+	slug: string;
+	mainClass: string;
+	selectors: Record< 'editor' | 'frontend', Record< string, unknown > >;
+} & ( T extends undefined ? Record< string, never > : T );
diff --git a/plugins/woocommerce/tests/e2e-pw/utils/blocks/wp-cli.ts b/plugins/woocommerce/tests/e2e-pw/utils/blocks/wp-cli.ts
new file mode 100644
index 00000000000..214841520fb
--- /dev/null
+++ b/plugins/woocommerce/tests/e2e-pw/utils/blocks/wp-cli.ts
@@ -0,0 +1,16 @@
+/**
+ * External dependencies
+ */
+import { promisify } from 'util';
+import { exec } from 'child_process';
+
+const execPromisified = promisify( exec );
+
+/**
+ * Runs a WP-CLI command inside the tests-cli container.
+ */
+export async function wpCLI( command: string ) {
+	return await execPromisified(
+		'npm run wp-env run tests-cli -- wp ' + command
+	);
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 71e64ef0c1c..2043c3e30d2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -2649,6 +2649,9 @@ importers:
       eslint-plugin-playwright:
         specifier: 1.6.0
         version: 1.6.0(eslint-plugin-jest@23.20.0(eslint@8.57.1)(typescript@5.7.3))(eslint@8.57.1)
+      handlebars:
+        specifier: ^4.7.9
+        version: 4.7.9
       jest:
         specifier: 29.5.x
         version: 29.5.0(@types/node@24.12.2)(babel-plugin-macros@3.1.0)(node-notifier@8.0.2)(ts-node@10.9.2(@swc/core@1.15.24)(@types/node@24.12.2)(typescript@5.7.3))