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(