Commit f9755edb347 for woocommerce

commit f9755edb3477375d5bb65f203b41d4f9ff1bbbf3
Author: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com>
Date:   Tue Oct 20 19:29:18 2026 +0300

    Fix Blocks parallel e2e failing with "Environment not initialized" (#70039)

    Stop Blocks parallel e2e workers from running wp-env at the same time

    Blocks setup now saves the CLI container ID, and workers run WP-CLI
    through docker exec with it. Overlapping wp-env commands could erase the
    runtime key from the wp-env cache file, which failed every later
    wp-env command.

diff --git a/plugins/woocommerce/changelog/fix-blocks-e2e-wp-env-cli-race b/plugins/woocommerce/changelog/fix-blocks-e2e-wp-env-cli-race
new file mode 100644
index 00000000000..6be1f097424
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-blocks-e2e-wp-env-cli-race
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+Comment: Fix Blocks parallel E2E WP-CLI calls breaking the wp-env cache; tests only, no production change.
+
diff --git a/plugins/woocommerce/tests/e2e/fixtures/blocks-setup.ts b/plugins/woocommerce/tests/e2e/fixtures/blocks-setup.ts
index e69cabe2097..2c7e4e48697 100644
--- a/plugins/woocommerce/tests/e2e/fixtures/blocks-setup.ts
+++ b/plugins/woocommerce/tests/e2e/fixtures/blocks-setup.ts
@@ -8,6 +8,7 @@ import { RequestUtils } from '@wordpress/e2e-test-utils-playwright';
 import {
 	adminFile,
 	wpCLI,
+	saveBlocksCliContainerId,
 	customerFile,
 	BLOCK_THEME_SLUG,
 	DB_EXPORT_FILE,
@@ -66,6 +67,8 @@ setup( 'blocks setup', async () => {
 	console.log( 'Running global setup:' );
 	console.time( '└ Total time' );

+	await saveBlocksCliContainerId();
+
 	let databaseImported = false;

 	try {
diff --git a/plugins/woocommerce/tests/e2e/utils/blocks/wp-cli.test.mjs b/plugins/woocommerce/tests/e2e/utils/blocks/wp-cli.test.mjs
index 46806e9dca0..b737153f49a 100644
--- a/plugins/woocommerce/tests/e2e/utils/blocks/wp-cli.test.mjs
+++ b/plugins/woocommerce/tests/e2e/utils/blocks/wp-cli.test.mjs
@@ -2,16 +2,54 @@
  * External dependencies
  */
 import assert from 'node:assert/strict';
+import { mkdtemp, readFile, writeFile } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
 import { test } from 'node:test';

 /**
  * Internal dependencies
  */
-import { createBlocksDatabaseRestorer } from './wp-cli.ts';
+import { createBlocksCli } from './wp-cli.ts';

 const CLI_CONTAINER_ID = '0123456789ab';
+const DISCOVERY_CALL = [
+	'npm',
+	[ 'run', 'wp-env:e2e', 'run', 'cli', '--', 'printenv', 'HOSTNAME' ],
+];
+const USER = `${ process.getuid?.() ?? 1000 }:${ process.getgid?.() ?? 1000 }`;
+
+const restoreCall = ( databaseFile ) => [
+	'docker',
+	[
+		'exec',
+		'--workdir',
+		'/var/www/html',
+		CLI_CONTAINER_ID,
+		'sh',
+		'-c',
+		'wp db reset --yes && wp db import "$1"',
+		'restore-blocks-database',
+		databaseFile,
+	],
+];
+
+const wpCLICall = ( command ) => [
+	'sh',
+	[
+		'-c',
+		`docker exec --workdir /var/www/html --user ${ USER } ${ CLI_CONTAINER_ID } wp ${ command }`,
+	],
+];
+
+const missingContainerIdFile = async () =>
+	path.join(
+		await mkdtemp( path.join( tmpdir(), 'blocks-cli-' ) ),
+		'state',
+		'blocks-cli-container-id'
+	);

-test( 'reuses the discovered CLI container across database restores', async () => {
+const createRecorder = () => {
 	const calls = [];
 	const runCommand = async ( executable, args ) => {
 		calls.push( [ executable, args ] );
@@ -25,57 +63,83 @@ test( 'reuses the discovered CLI container across database restores', async () =

 		return { stdout: '', stderr: '' };
 	};
-	const restoreDatabase = createBlocksDatabaseRestorer( runCommand );

-	await restoreDatabase( '/tmp/first snapshot.sql' );
-	await restoreDatabase( '/tmp/second.sql' );
+	return { calls, runCommand };
+};
+
+test( 'uses the saved CLI container ID without starting wp-env', async () => {
+	const containerIdFile = await missingContainerIdFile();
+	const setup = createRecorder();
+	await createBlocksCli(
+		setup.runCommand,
+		containerIdFile
+	).saveCliContainerId();
+
+	assert.deepEqual( setup.calls, [ DISCOVERY_CALL ] );
+	assert.equal( await readFile( containerIdFile, 'utf8' ), CLI_CONTAINER_ID );
+
+	const worker = createRecorder();
+	const cli = createBlocksCli( worker.runCommand, containerIdFile );
+
+	await cli.wpCLI( 'core version' );
+	await cli.restoreDatabase( '/tmp/first snapshot.sql' );
+	await cli.restoreDatabase( '/tmp/second.sql' );
+
+	assert.deepEqual( worker.calls, [
+		wpCLICall( 'core version' ),
+		restoreCall( '/tmp/first snapshot.sql' ),
+		restoreCall( '/tmp/second.sql' ),
+	] );
+} );
+
+test( 'finds the CLI container through wp-env once when no ID is saved', async () => {
+	const { calls, runCommand } = createRecorder();
+	const cli = createBlocksCli( runCommand, await missingContainerIdFile() );
+
+	await cli.restoreDatabase( '/tmp/snapshot.sql' );
+	await cli.wpCLI( 'option get siteurl' );

 	assert.deepEqual( calls, [
-		[
-			'npm',
-			[ 'run', 'wp-env:e2e', 'run', 'cli', '--', 'printenv', 'HOSTNAME' ],
-		],
-		[
-			'docker',
-			[
-				'exec',
-				'--workdir',
-				'/var/www/html',
-				CLI_CONTAINER_ID,
-				'sh',
-				'-c',
-				'wp db reset --yes && wp db import "$1"',
-				'restore-blocks-database',
-				'/tmp/first snapshot.sql',
-			],
-		],
-		[
-			'docker',
-			[
-				'exec',
-				'--workdir',
-				'/var/www/html',
-				CLI_CONTAINER_ID,
-				'sh',
-				'-c',
-				'wp db reset --yes && wp db import "$1"',
-				'restore-blocks-database',
-				'/tmp/second.sql',
-			],
-		],
+		DISCOVERY_CALL,
+		restoreCall( '/tmp/snapshot.sql' ),
+		wpCLICall( 'option get siteurl' ),
 	] );
 } );

+test( 'finds the CLI container through wp-env when the saved ID is invalid', async () => {
+	const containerIdFile = await missingContainerIdFile();
+	const { calls, runCommand } = createRecorder();
+	await createBlocksCli( runCommand, containerIdFile ).saveCliContainerId();
+	await writeFile( containerIdFile, '' );
+	calls.length = 0;
+
+	await createBlocksCli( runCommand, containerIdFile ).wpCLI( 'core version' );
+
+	assert.deepEqual( calls, [ DISCOVERY_CALL, wpCLICall( 'core version' ) ] );
+} );
+
+test( 'setup replaces a stale saved ID', async () => {
+	const containerIdFile = await missingContainerIdFile();
+	const { calls, runCommand } = createRecorder();
+	await createBlocksCli( runCommand, containerIdFile ).saveCliContainerId();
+	await writeFile( containerIdFile, 'stale' );
+
+	await createBlocksCli( runCommand, containerIdFile ).saveCliContainerId();
+
+	assert.equal( calls.length, 2 );
+	assert.equal( await readFile( containerIdFile, 'utf8' ), CLI_CONTAINER_ID );
+} );
+
 test( 'fails before restoring when CLI container discovery is malformed', async () => {
 	const calls = [];
 	const runCommand = async ( executable, args ) => {
 		calls.push( [ executable, args ] );
 		return { stdout: 'npm output without a container ID\n', stderr: '' };
 	};
-	const restoreDatabase = createBlocksDatabaseRestorer( runCommand );
+	const cli = createBlocksCli( runCommand, await missingContainerIdFile() );

 	await assert.rejects(
-		restoreDatabase( '/tmp/snapshot.sql' ),
+		cli.restoreDatabase( '/tmp/snapshot.sql' ),
 		/Failed to determine the Blocks E2E CLI container ID/
 	);
 	assert.equal( calls.length, 1 );
@@ -99,14 +163,14 @@ test( 'retries CLI container discovery after a failed attempt', async () => {

 		return { stdout: '', stderr: '' };
 	};
-	const restoreDatabase = createBlocksDatabaseRestorer( runCommand );
+	const cli = createBlocksCli( runCommand, await missingContainerIdFile() );

 	await assert.rejects(
-		restoreDatabase( '/tmp/snapshot.sql' ),
+		cli.restoreDatabase( '/tmp/snapshot.sql' ),
 		/transient wp-env failure/
 	);

-	await restoreDatabase( '/tmp/snapshot.sql' );
+	await cli.restoreDatabase( '/tmp/snapshot.sql' );

 	assert.deepEqual(
 		calls.map( ( [ executable ] ) => executable ),
diff --git a/plugins/woocommerce/tests/e2e/utils/blocks/wp-cli.ts b/plugins/woocommerce/tests/e2e/utils/blocks/wp-cli.ts
index 736d2c6766e..42fe16a68e1 100644
--- a/plugins/woocommerce/tests/e2e/utils/blocks/wp-cli.ts
+++ b/plugins/woocommerce/tests/e2e/utils/blocks/wp-cli.ts
@@ -2,9 +2,10 @@
  * External dependencies
  */
 import { promisify } from 'util';
-import { exec, execFile } from 'child_process';
+import { execFile } from 'child_process';
+import { mkdir, readFile, writeFile } from 'fs/promises';
+import path from 'path';

-const execPromisified = promisify( exec );
 const execFilePromisified = promisify( execFile );

 type CommandResult = {
@@ -22,83 +23,127 @@ async function runCommand( executable: string, args: string[] ) {
 }

 /**
- * Runs a WP-CLI command inside the single-container E2E environment's `cli`
- * container (started via `wp-env --config .wp-env.e2e.json`).
- */
-export async function wpCLI( command: string ) {
-	return await execPromisified(
-		'npm run wp-env:e2e run cli -- wp ' + command
-	);
-}
-
-/**
- * Creates a database restore function that resolves the Blocks E2E CLI
- * container once and reuses it for subsequent restores.
+ * Runs WP-CLI through `docker exec` in the Blocks E2E CLI container.
+ *
+ * Overlapping wp-env commands can corrupt its cache file and break every later wp-env command, so parallel workers read the container ID that `blocks setup` saved to `containerIdFile`. They fall back to wp-env only when that file is missing or invalid. After recreating the environment, run `blocks setup` again to save the new ID.
  */
-export function createBlocksDatabaseRestorer( execute: RunCommand ) {
+export function createBlocksCli( execute: RunCommand, containerIdFile: string ) {
 	let cliContainerIdPromise: Promise< string > | undefined;

-	const getCliContainerId = async () => {
-		if ( ! cliContainerIdPromise ) {
-			cliContainerIdPromise = execute( 'npm', [
-				'run',
-				'wp-env:e2e',
-				'run',
-				'cli',
-				'--',
-				'printenv',
-				'HOSTNAME',
-			] ).then( ( { stdout, stderr } ) => {
-				// Match a 12 to 64 character hex string (Docker container ID) on its own line,
-				// optionally followed by a carriage return.
-				const cliContainerId = stdout.match(
-					/^(?<containerId>[a-f0-9]{12,64})\r?$/m
-				)?.groups?.containerId;
-
-				if ( ! cliContainerId ) {
-					throw new Error(
-						`Failed to determine the Blocks E2E CLI container ID: ${ stdout } ${ stderr }`
-					);
-				}
-
-				return cliContainerId;
-			} );
-
-			// Drop failed discoveries from the cache so the next restore
-			// retries instead of reusing the rejection forever.
-			cliContainerIdPromise.catch( () => {
-				cliContainerIdPromise = undefined;
-			} );
+	const discoverCliContainerId = async () => {
+		const { stdout, stderr } = await execute( 'npm', [
+			'run',
+			'wp-env:e2e',
+			'run',
+			'cli',
+			'--',
+			'printenv',
+			'HOSTNAME',
+		] );
+		// Match a 12 to 64 character hex string (Docker container ID) on its own line,
+		// optionally followed by a carriage return.
+		const cliContainerId = stdout.match(
+			/^(?<containerId>[a-f0-9]{12,64})\r?$/m
+		)?.groups?.containerId;
+
+		if ( ! cliContainerId ) {
+			throw new Error(
+				`Failed to determine the Blocks E2E CLI container ID: ${ stdout } ${ stderr }`
+			);
 		}

-		return await cliContainerIdPromise;
+		return cliContainerId;
 	};

-	return async ( databaseFile: string ) => {
-		const cliContainerId = await getCliContainerId();
-
-		return await execute( 'docker', [
-			'exec',
-			'--workdir',
-			'/var/www/html',
-			cliContainerId,
-			'sh',
-			'-c',
-			'wp db reset --yes && wp db import "$1"',
-			'restore-blocks-database',
-			databaseFile,
-		] );
+	const cache = ( promise: Promise< string > ) => {
+		cliContainerIdPromise = promise;
+		// Drop failed lookups from the cache so the next call retries
+		// instead of reusing the rejection forever.
+		promise.catch( () => {
+			cliContainerIdPromise = undefined;
+		} );
+		return promise;
+	};
+
+	const getCliContainerId = async () =>
+		await ( cliContainerIdPromise ??
+			cache(
+				readFile( containerIdFile, 'utf8' ).then( ( savedId ) => {
+					const cliContainerId = savedId.trim();
+					return /^[a-f0-9]{12,64}$/.test( cliContainerId )
+						? cliContainerId
+						: discoverCliContainerId();
+				}, discoverCliContainerId )
+			) );
+
+	return {
+		async saveCliContainerId() {
+			const cliContainerId = await cache( discoverCliContainerId() );
+			await mkdir( path.dirname( containerIdFile ), { recursive: true } );
+			await writeFile( containerIdFile, cliContainerId );
+		},
+
+		async wpCLI( command: string ) {
+			const cliContainerId = await getCliContainerId();
+			const user = `${ process.getuid?.() ?? 1000 }:${
+				process.getgid?.() ?? 1000
+			}`;
+
+			// Let the host shell parse `command`, as it did when this ran through wp-env.
+			return await execute( 'sh', [
+				'-c',
+				`docker exec --workdir /var/www/html --user ${ user } ${ cliContainerId } wp ${ command }`,
+			] );
+		},
+
+		async restoreDatabase( databaseFile: string ) {
+			const cliContainerId = await getCliContainerId();
+
+			return await execute( 'docker', [
+				'exec',
+				'--workdir',
+				'/var/www/html',
+				cliContainerId,
+				'sh',
+				'-c',
+				'wp db reset --yes && wp db import "$1"',
+				'restore-blocks-database',
+				databaseFile,
+			] );
+		},
 	};
 }

-const restoreDatabase = createBlocksDatabaseRestorer( runCommand );
+// Relative to `plugins/woocommerce`, where wp-env and Playwright run.
+const blocksCli = createBlocksCli(
+	runCommand,
+	path.resolve(
+		process.env.WP_ARTIFACTS_PATH || 'tests/e2e/.state',
+		'blocks-cli-container-id'
+	)
+);
+
+/**
+ * Finds the Blocks E2E CLI container through wp-env and saves its ID for the test workers. Call it only from `blocks setup`, which runs before the workers start.
+ */
+export async function saveBlocksCliContainerId() {
+	return await blocksCli.saveCliContainerId();
+}
+
+/**
+ * Runs a WP-CLI command inside the single-container E2E environment's `cli`
+ * container (started via `wp-env --config .wp-env.e2e.json`).
+ */
+export async function wpCLI( command: string ) {
+	return await blocksCli.wpCLI( command );
+}

 /**
  * Resets the Blocks E2E database and imports its snapshot through the existing
  * CLI container.
  */
 export async function restoreBlocksDatabase( databaseFile: string ) {
-	return await restoreDatabase( databaseFile );
+	return await blocksCli.restoreDatabase( databaseFile );
 }

 /**
@@ -111,7 +156,6 @@ export async function getPostIdBySlug( slug: string, postType = 'product' ) {
 	const result = await wpCLI(
 		`post list --post_type="${ postType }" --name="${ slug }" --field=ID`
 	);
-	// Extract just the numeric ID from output (npm adds prefix lines to stdout).
 	const postId = result.stdout.match( /^(\d+)\r?$/m )?.[ 1 ];
 	if ( ! postId ) {
 		throw new Error(