From a9cf43f02b623e55874b37b31d8737e449986ef5 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 4 Sep 2026 15:16:44 +0530 Subject: [PATCH 1/5] perf(e2e): Run specs in parallel workers and overlap CI setup The e2e suite ran with a single Playwright worker because every spec shared one analytics capture log and several specs mutate site-wide state. Test execution took ~2.5 min of a ~6 min CI job. Test side: - Give each Playwright worker its own analytics capture log. The mu-plugin derives the log path from an X-CLD-E2E-Worker header (browser and REST traffic) or a CLD_E2E_WORKER env var (WP-CLI via docker exec). Unmarked requests keep using the shared log. - Add tests/e2e/fixtures.js which sets both markers per worker; all specs import test/expect from it. - Raise workers to 3 and tag the five specs that mutate global state (connection, plugin activation, real-credential delivery) with @serial. `npm run test:e2e` runs the parallel set first, then the serial set with one worker in the same wp-env. - settings-analytics flips image_quality instead of image_format so it no longer races media-analytics on the same key. - Short-circuit Admin API calls made with the fake e2e cloud with a local 401. Connect::history() issued seven ~1s real round-trips on every dashboard load, pushing page loads past the navigation timeout. - cache-analytics: create the cache point after the admin page load, and clear the stale parent plus the asset lock before the single purge test. activate_parents() deleted the unconfigured parent and left a 10s lock that blocked the tracked branch in rest_purge_all(). CI side: - Run wp-env start in the background while browsers install and assets build. - Cache Playwright browsers keyed on the @playwright/test version. - npm ci --ignore-scripts in the e2e job; composer and patch-package are not needed to run the suite. - Pin WP_ENV_HOME in the unit job so the wp-env sources cache path is actually the one wp-env writes to. Local run: parallel phase 20 tests in ~23s, serial phase 12 tests in ~1.3 min, down from ~2.5 min for 32 tests serially. --- .github/workflows/ci.yml | 64 ++++++++++++++--- .wp-env/mu-plugins/analytics-capture.php | 80 ++++++++++++++++++++- package.json | 4 +- tests/e2e/cache-analytics.spec.js | 25 ++++++- tests/e2e/cloudinary-image-delivery.spec.js | 6 +- tests/e2e/cloudinary-video-delivery.spec.js | 6 +- tests/e2e/connection-analytics.spec.js | 6 +- tests/e2e/deactivation-analytics.spec.js | 6 +- tests/e2e/features-analytics.spec.js | 2 +- tests/e2e/fixtures.js | 78 ++++++++++++++++++++ tests/e2e/hello-world.spec.js | 2 +- tests/e2e/media-analytics.spec.js | 2 +- tests/e2e/playwright.config.js | 9 ++- tests/e2e/plugin.spec.js | 2 +- tests/e2e/settings-analytics.spec.js | 20 +++--- tests/e2e/sync-analytics.spec.js | 2 +- tests/e2e/utils/wizard.js | 28 +++++++- tests/e2e/wizard-setup.spec.js | 6 +- 18 files changed, 309 insertions(+), 39 deletions(-) create mode 100644 tests/e2e/fixtures.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee8155e34..76bd481b3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,6 +83,9 @@ jobs: unit: name: Unit (PHPUnit) runs-on: ubuntu-latest + env: + # Shell-expanded by wp-env; matches the "Cache wp-env sources" path. + WP_ENV_HOME: /home/runner/.wp-env steps: - name: Checkout repository uses: actions/checkout@v4 @@ -106,10 +109,14 @@ jobs: # `env:start` from re-cloning them on every run. The key covers the # wp-env config and version, because a change to either means different # sources have to be downloaded. + # + # WP_ENV_HOME is pinned at the job level because wp-env's default differs + # per platform (`~/.wp-env`, or `~/wp-env` on Linux runners that ship + # snap), and the unpinned path produced a cache that was never saved. - name: Cache wp-env sources uses: actions/cache@v4 with: - path: ~/.wp-env + path: ${{ env.WP_ENV_HOME }} key: ${{ runner.os }}-wp-env-${{ hashFiles('.wp-env.json', 'package-lock.json') }} restore-keys: | ${{ runner.os }}-wp-env- @@ -141,17 +148,56 @@ jobs: node-version-file: '.nvmrc' cache: 'npm' + # --ignore-scripts skips the postinstall hook: `composer install` only + # pulls dev tooling (phpcs, phpstan, phpunit) the plugin does not load + # at runtime, and `patch-package` only patches wp-hookdoc for the docs + # build. Neither is needed to run the e2e suite. - name: Install dependencies - run: npm ci - - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium + run: npm ci --ignore-scripts - - name: Build assets - run: npm run build + - name: Get Playwright version + id: playwright-version + run: echo "version=$(node -p "require('@playwright/test/package.json').version")" >> "$GITHUB_OUTPUT" - - name: Start wp-env - run: npm run env:start + - name: Cache Playwright browsers + id: playwright-cache + uses: actions/cache@v4 + with: + path: ~/.cache/ms-playwright + key: ${{ runner.os }}-playwright-${{ steps.playwright-version.outputs.version }} + + # wp-env spends most of its time pulling Docker images and installing + # WordPress, none of which depends on the Node-side steps. Run it in the + # background while the browser install and asset build proceed, then + # wait for it. All of this has to live in one step because `wait` only + # sees children of the same shell. wp-env output goes to a file and is + # printed afterwards so the interleaved log stays readable. + - name: Start wp-env, install browsers, build assets + env: + PLAYWRIGHT_CACHE_HIT: ${{ steps.playwright-cache.outputs.cache-hit }} + run: | + npm run env:start > wp-env-start.log 2>&1 & + wp_env_pid=$! + + if [ "$PLAYWRIGHT_CACHE_HIT" = "true" ]; then + # Browser binaries came from cache; only the apt packages they + # need are missing on a fresh runner. + npx playwright install-deps chromium + else + npx playwright install --with-deps chromium + fi + + npm run build + + echo "::group::wp-env start" + if wait "$wp_env_pid"; then + cat wp-env-start.log + echo "::endgroup::" + else + cat wp-env-start.log + echo "::endgroup::" + exit 1 + fi - name: Run E2E tests env: diff --git a/.wp-env/mu-plugins/analytics-capture.php b/.wp-env/mu-plugins/analytics-capture.php index 4564fdb9a..a8f68dc93 100644 --- a/.wp-env/mu-plugins/analytics-capture.php +++ b/.wp-env/mu-plugins/analytics-capture.php @@ -11,23 +11,99 @@ * proceed, which meant every local/CI test run was quietly leaking synthetic * events (and deactivation "feedback") into the real production collector. * + * Two additions support running the e2e suite in parallel Playwright + * workers against this single WordPress install: the capture log is + * per-worker (see cld_analytics_capture_worker_marker()), and Admin API + * calls made with the fake e2e credentials are answered locally (see + * cld_e2e_fake_cloud_intercept()). + * * @package Cloudinary */ defined( 'ABSPATH' ) || exit; /** - * Returns the path to the capture log file. + * Returns the e2e worker marker for the current request, if any. + * + * Playwright runs spec files in parallel workers against this single + * WordPress install. Each worker tags its browser/REST traffic with an + * `X-CLD-E2E-Worker` header and its WP-CLI calls with a `CLD_E2E_WORKER` + * env var, so every worker gets its own capture log and one worker's + * events (or `--clear`) can't leak into another worker's assertions. + * + * Requests without a marker (manual QA, fire-and-forget loopback threads + * spawned by the sync queue) fall back to the shared, unsuffixed log. + * + * @return string Sanitized marker, or empty string when none is present. + */ +function cld_analytics_capture_worker_marker() { + $marker = ''; + + if ( ! empty( $_SERVER['HTTP_X_CLD_E2E_WORKER'] ) ) { + $marker = wp_unslash( $_SERVER['HTTP_X_CLD_E2E_WORKER'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + } elseif ( false !== getenv( 'CLD_E2E_WORKER' ) && '' !== getenv( 'CLD_E2E_WORKER' ) ) { + $marker = getenv( 'CLD_E2E_WORKER' ); + } + + return preg_replace( '/[^A-Za-z0-9_-]/', '', (string) $marker ); +} + +/** + * Returns the path to the capture log file for the current worker. * * @return string */ function cld_analytics_capture_log_path() { $upload = wp_upload_dir(); + $marker = cld_analytics_capture_worker_marker(); + $suffix = '' !== $marker ? '-' . $marker : ''; - return $upload['basedir'] . '/analytics-capture.log'; + return $upload['basedir'] . '/analytics-capture' . $suffix . '.log'; } add_filter( 'pre_http_request', 'cld_analytics_capture_intercept', 10, 3 ); +add_filter( 'pre_http_request', 'cld_e2e_fake_cloud_intercept', 10, 3 ); + +/** + * Cloud name used by `fakeCloudinaryConnected()` in tests/e2e/utils/connection.js. + */ +const CLD_E2E_FAKE_CLOUD = 'e2e-fake-cloud'; + +/** + * Short-circuits Cloudinary Admin API calls made with the fake e2e + * credentials. + * + * Analytics specs fake a connection so `Connect::is_connected()` is true. + * The dashboard then still calls the real Admin API for usage stats and + * per-day history (`Connect::history()` issues one request per day, and the + * 401 responses it gets are never cached because `is_wp_error()` entries + * are refetched). Each real round-trip is ~1s, so one `page=cloudinary` + * load can exceed Playwright's navigation timeout, and parallel workers + * multiply the load. Answer those calls locally with the same 401 the real + * API would return so the plugin's error handling still runs. + * + * @param false|array|WP_Error $preempt Whether to preempt the request. + * @param array $parsed_args Parsed request arguments. + * @param string $url The request URL. + * + * @return false|array|WP_Error + */ +function cld_e2e_fake_cloud_intercept( $preempt, $parsed_args, $url ) { + if ( false === strpos( $url, 'api.cloudinary.com/v1_1/' . CLD_E2E_FAKE_CLOUD . '/' ) ) { + return $preempt; + } + + return array( + 'headers' => array( 'content-type' => 'application/json' ), + 'body' => wp_json_encode( array( 'error' => array( 'message' => 'Invalid credentials (e2e fake cloud)' ) ) ), + 'response' => array( + 'code' => 401, + 'message' => 'Unauthorized', + ), + 'cookies' => array(), + 'filename' => null, + ); +} /** * Logs outgoing analytics/deactivation-reason requests and preempts them diff --git a/package.json b/package.json index f109af064..ffd39cf0f 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,9 @@ "postinstall": "patch-package && composer install", "readme": "composer readme", "prepare": "husky", - "test:e2e": "playwright test --config tests/e2e/playwright.config.js", + "test:e2e": "npm-run-all --silent test:e2e:parallel test:e2e:serial", + "test:e2e:parallel": "playwright test --config tests/e2e/playwright.config.js --grep-invert @serial", + "test:e2e:serial": "playwright test --config tests/e2e/playwright.config.js --grep @serial --workers=1", "test:e2e:debug": "playwright test --config tests/e2e/playwright.config.js --ui", "test:unit": "wp-env run tests-cli --env-cwd=\"wp-content/plugins/$(basename \"$PWD\")\" vendor/bin/phpunit" }, diff --git a/tests/e2e/cache-analytics.spec.js b/tests/e2e/cache-analytics.spec.js index dd8a292bb..179b4e054 100644 --- a/tests/e2e/cache-analytics.spec.js +++ b/tests/e2e/cache-analytics.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies @@ -53,8 +53,13 @@ test.describe( 'Non-media cache analytics', () => { admin, page, } ) => { - createCachePoint(); + // Load the admin page before creating the cache point. CACHE_POINT_PATH + // is not enabled in the cache settings, so an admin page load's + // Assets::activate_parents() treats an existing parent for it as + // disabled and deletes it. Creating the parent afterwards means the + // REST call below still finds it. await admin.visitAdminPage( 'admin.php', 'page=cloudinary' ); + createCachePoint(); const { restBase, nonce } = await getRestContext( page ); const response = await page.request.post( `${ restBase }/show_cache`, { @@ -119,8 +124,24 @@ test.describe( 'Non-media cache analytics', () => { // rather than relying on a subsequent admin page load's side effect // (`Assets::update_asset_paths()`) to materialize it, which is a // timing-sensitive path that has flaked under CI load. + // + // Also remove any leftover parent for CACHE_POINT_PATH (created by + // earlier tests in this file) and release the asset lock. That path + // is not enabled in settings, so the admin page load below would + // otherwise purge it via Assets::activate_parents() -> + // purge_parent() -> lock_assets(), a 10s transient nothing clears. + // While locked, get_assets_settings() returns nothing, no parent is + // activated, and rest_purge_all() never reaches the tracked branch. + // With sub-second page loads this test lands inside that window. const realCachePoint = 'wp-content/uploads/'; wpEvalFile( ` + $assets = get_plugin_instance()->get_component( 'assets' ); + $stale = $assets->get_asset_parent( '${ CACHE_POINT_PATH }' ); + if ( $stale instanceof \\WP_Post ) { + wp_delete_post( $stale->ID, true ); + } + $assets->unlock_assets(); + $admin = get_plugin_instance()->get_component( 'admin' ); $method = new \\ReflectionMethod( $admin, 'save_settings' ); $method->setAccessible( true ); diff --git a/tests/e2e/cloudinary-image-delivery.spec.js b/tests/e2e/cloudinary-image-delivery.spec.js index 40644e1cd..2f081b98f 100644 --- a/tests/e2e/cloudinary-image-delivery.spec.js +++ b/tests/e2e/cloudinary-image-delivery.spec.js @@ -3,7 +3,7 @@ */ const fs = require( 'fs' ); const path = require( 'path' ); -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies @@ -45,7 +45,9 @@ function expectCloudinaryUrl( rawUrl, expectedCloud ) { ).toBe( true ); } -test.describe( 'Cloudinary image delivery', () => { +// @serial: needs real credentials in `cloudinary_connect` for `wp cloudinary +// sync`, while every analytics spec overwrites that option with fake ones. +test.describe( 'Cloudinary image delivery', { tag: '@serial' }, () => { test.beforeAll( () => { ( { cloudName } = ensureCloudinaryConnected() ); } ); diff --git a/tests/e2e/cloudinary-video-delivery.spec.js b/tests/e2e/cloudinary-video-delivery.spec.js index f7d47a09d..45efe4504 100644 --- a/tests/e2e/cloudinary-video-delivery.spec.js +++ b/tests/e2e/cloudinary-video-delivery.spec.js @@ -4,7 +4,7 @@ const fs = require( 'fs' ); const path = require( 'path' ); const { execSync } = require( 'child_process' ); -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies @@ -85,7 +85,9 @@ function setVideoPlayer( value ) { } ); } -test.describe( 'Cloudinary video delivery', () => { +// @serial: needs real credentials in `cloudinary_connect` for `wp cloudinary +// sync`, while every analytics spec overwrites that option with fake ones. +test.describe( 'Cloudinary video delivery', { tag: '@serial' }, () => { test.beforeAll( () => { ( { cloudName } = ensureCloudinaryConnected() ); } ); diff --git a/tests/e2e/connection-analytics.spec.js b/tests/e2e/connection-analytics.spec.js index 3bbf2067d..4f22a19c9 100644 --- a/tests/e2e/connection-analytics.spec.js +++ b/tests/e2e/connection-analytics.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies @@ -29,7 +29,9 @@ const SEL = { tab4: '#tab-4', }; -test.describe( 'Connection management analytics', () => { +// @serial: resets and empties `cloudinary_connect`, disconnecting the plugin +// for every other spec that happens to be running at the same time. +test.describe( 'Connection management analytics', { tag: '@serial' }, () => { test.beforeEach( async ( { context } ) => { resetCloudinaryConnection(); clearAnalyticsEvents(); diff --git a/tests/e2e/deactivation-analytics.spec.js b/tests/e2e/deactivation-analytics.spec.js index 3db54bcef..811e5a95c 100644 --- a/tests/e2e/deactivation-analytics.spec.js +++ b/tests/e2e/deactivation-analytics.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies @@ -29,7 +29,9 @@ const SEL = { `.cloudinary-deactivation input[name="option"][value="${ id }"]`, }; -test.describe( 'Deactivation analytics', () => { +// @serial: deactivates and fully uninstalls the plugin (dropping its tables +// and options); no other spec can run while that is in flight. +test.describe( 'Deactivation analytics', { tag: '@serial' }, () => { test.beforeEach( async () => { // Fake a connected state (no live Cloudinary credentials required) // so the connected/reason-picker modal — rather than the diff --git a/tests/e2e/features-analytics.spec.js b/tests/e2e/features-analytics.spec.js index c543bc605..16e83f6f8 100644 --- a/tests/e2e/features-analytics.spec.js +++ b/tests/e2e/features-analytics.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js new file mode 100644 index 000000000..9f336f623 --- /dev/null +++ b/tests/e2e/fixtures.js @@ -0,0 +1,78 @@ +/** + * Shared Playwright test object for the e2e suite. + * + * Wraps `@wordpress/e2e-test-utils-playwright`'s `test` so every spec file + * runs with a per-worker marker attached to all of its WordPress traffic. + * The `.wp-env/mu-plugins/analytics-capture.php` mu-plugin uses that marker + * to route captured analytics events into a per-worker log file, which is + * what lets the analytics specs run in parallel workers against one shared + * WordPress install without their `clearAnalyticsEvents()` calls and exact + * event-count assertions stepping on each other. + * + * The marker travels two ways: + * + * - `X-CLD-E2E-Worker` request header, via the browser context's + * `extraHTTPHeaders`, so page loads and `page.request.*` REST calls made by + * the plugin's PHP side are attributed to this worker. + * - `CLD_E2E_WORKER` env var on the Playwright worker process, which + * `utils/wizard.js`'s `wpCli()` / `wpEvalFile()` forward into their + * `docker exec` calls so WP-CLI reads and writes the same per-worker log. + * + * Specs should import `test` and `expect` from this module instead of from + * the WordPress package directly. + */ + +const base = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Header name the mu-plugin reads the worker marker from. + * + * @type {string} + */ +const WORKER_HEADER = 'X-CLD-E2E-Worker'; + +/** + * Builds the marker for a given Playwright worker. + * + * `parallelIndex` is stable across worker restarts (e.g. after a retry) and + * bounded by the configured `workers` count, unlike `workerIndex` which keeps + * incrementing, so the number of per-worker log files stays small. + * + * @param {import('@playwright/test').WorkerInfo} workerInfo + * @return {string} Marker such as `w0`. + */ +function markerForWorker( workerInfo ) { + return `w${ workerInfo.parallelIndex }`; +} + +const test = base.test.extend( { + // Worker-scoped and auto so it runs before any test in the worker, and + // before the worker-scoped `requestUtils` fixture from the WP package + // resolves. Setting `process.env` here is safe because each Playwright + // worker is its own process. + cldE2EWorkerMarker: [ + async ( {}, provide, workerInfo ) => { + const marker = markerForWorker( workerInfo ); + process.env.CLD_E2E_WORKER = marker; + await provide( marker ); + delete process.env.CLD_E2E_WORKER; + }, + { scope: 'worker', auto: true }, + ], + + // Merge the marker header into whatever `extraHTTPHeaders` the config + // already provides, rather than replacing it. + extraHTTPHeaders: async ( { extraHTTPHeaders }, provide, testInfo ) => { + await provide( { + ...( extraHTTPHeaders || {} ), + [ WORKER_HEADER ]: markerForWorker( testInfo ), + } ); + }, +} ); + +module.exports = { + ...base, + test, + expect: base.expect, + WORKER_HEADER, +}; diff --git a/tests/e2e/hello-world.spec.js b/tests/e2e/hello-world.spec.js index d7082d497..2f1e1dbc7 100644 --- a/tests/e2e/hello-world.spec.js +++ b/tests/e2e/hello-world.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); test.describe( 'Hello World', () => { test( 'front page loads with a non-empty title', async ( { page } ) => { diff --git a/tests/e2e/media-analytics.spec.js b/tests/e2e/media-analytics.spec.js index 88c139422..f5d6a1a67 100644 --- a/tests/e2e/media-analytics.spec.js +++ b/tests/e2e/media-analytics.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies diff --git a/tests/e2e/playwright.config.js b/tests/e2e/playwright.config.js index e3cb78dd5..00605eb83 100644 --- a/tests/e2e/playwright.config.js +++ b/tests/e2e/playwright.config.js @@ -23,7 +23,14 @@ module.exports = defineConfig( { reporter: process.env.CI ? [ [ 'github' ], [ 'list' ] ] : 'list', forbidOnly: !! process.env.CI, retries: process.env.CI ? 2 : 0, - workers: 1, + // Spec files are spread across workers; tests within one file still run + // in order (fullyParallel is off), which the delivery specs' shared + // beforeAll/afterAll state relies on. Specs tagged @serial mutate + // site-wide state (connection, plugin activation) and are run in a + // second, single-worker pass by `npm run test:e2e`; see package.json. + // Analytics specs are safe to run concurrently because tests/e2e/fixtures.js + // gives each worker its own analytics capture log. + workers: 3, timeout: 60_000, expect: { timeout: 10_000, diff --git a/tests/e2e/plugin.spec.js b/tests/e2e/plugin.spec.js index d84c42d34..b0c9b303d 100644 --- a/tests/e2e/plugin.spec.js +++ b/tests/e2e/plugin.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); test.describe( 'Cloudinary plugin', () => { test( 'is listed and active on the Plugins screen', async ( { diff --git a/tests/e2e/settings-analytics.spec.js b/tests/e2e/settings-analytics.spec.js index 0c8bbd88a..27e294816 100644 --- a/tests/e2e/settings-analytics.spec.js +++ b/tests/e2e/settings-analytics.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies @@ -57,13 +57,17 @@ test.describe( 'Settings & navigation analytics', () => { 'page=cloudinary_image_settings' ); - // Flip the image format select to force a real change. - const formatSelect = page.locator( - 'select[name="image_settings[image_format]"]' + // Flip the image quality select to force a real change. Deliberately + // not image_format: media-analytics.spec.js flips that one, and the + // two specs run in parallel workers against the same site. Each spec + // owning a different key keeps its flipped key in the save diff no + // matter how the two saves interleave. + const qualitySelect = page.locator( + 'select[name="image_settings[image_quality]"]' ); - const current = await formatSelect.inputValue(); - const nextValue = 'webp' === current ? 'auto' : 'webp'; - await formatSelect.selectOption( nextValue ); + const current = await qualitySelect.inputValue(); + const nextValue = '80' === current ? 'auto' : '80'; + await qualitySelect.selectOption( nextValue ); await page.locator( SEL.saveButton ).click(); await page.waitForLoadState( 'networkidle' ); @@ -74,7 +78,7 @@ test.describe( 'Settings & navigation analytics', () => { ); expect( savedEvents.length ).toBe( 1 ); expect( savedEvents[ 0 ].page ).toBe( 'image_settings' ); - expect( savedEvents[ 0 ].changed_keys ).toContain( 'image_format' ); + expect( savedEvents[ 0 ].changed_keys ).toContain( 'image_quality' ); } ); test( 'dismissing an admin notice emits notice_dismissed', async ( { diff --git a/tests/e2e/sync-analytics.spec.js b/tests/e2e/sync-analytics.spec.js index 40d5449d5..be55d81cd 100644 --- a/tests/e2e/sync-analytics.spec.js +++ b/tests/e2e/sync-analytics.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies diff --git a/tests/e2e/utils/wizard.js b/tests/e2e/utils/wizard.js index 5cd50132a..ebcc98c0e 100644 --- a/tests/e2e/utils/wizard.js +++ b/tests/e2e/utils/wizard.js @@ -56,6 +56,22 @@ function getCliContainer() { return cli; } +/** + * `docker exec` flags that forward the per-worker e2e marker (set by + * `tests/e2e/fixtures.js`) into the container, so the analytics-capture + * mu-plugin's WP-CLI command reads and clears this worker's own log rather + * than a log shared with the other parallel workers. + * + * @return {string[]} Zero or more `-e KEY=VALUE` arguments. + */ +function workerEnvFlags() { + const marker = process.env.CLD_E2E_WORKER; + if ( ! marker || ! /^[A-Za-z0-9_-]+$/.test( marker ) ) { + return []; + } + return [ '-e', `CLD_E2E_WORKER=${ marker }` ]; +} + /** * Run a WP-CLI command inside the wp-env cli container. * @@ -67,6 +83,7 @@ function wpCli( args ) { const cmd = [ 'docker', 'exec', + ...workerEnvFlags(), container, 'wp', ...args, @@ -112,7 +129,16 @@ function wpEvalFile( phpCode ) { stdio: [ 'ignore', 'pipe', 'pipe' ], } ); return execSync( - `docker exec ${ container } wp eval-file ${ remotePath } --allow-root`, + [ + 'docker', + 'exec', + ...workerEnvFlags(), + container, + 'wp', + 'eval-file', + remotePath, + '--allow-root', + ].join( ' ' ), { encoding: 'utf8', stdio: [ 'ignore', 'pipe', 'pipe' ] } ).trim(); } finally { diff --git a/tests/e2e/wizard-setup.spec.js b/tests/e2e/wizard-setup.spec.js index dff165278..029c9dc47 100644 --- a/tests/e2e/wizard-setup.spec.js +++ b/tests/e2e/wizard-setup.spec.js @@ -1,7 +1,7 @@ /** * External dependencies */ -const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' ); +const { test, expect } = require( './fixtures' ); /** * Internal dependencies @@ -29,7 +29,9 @@ const SEL = { wizardWrap: '.cld-wizard', }; -test.describe( 'Cloudinary wizard setup', () => { +// @serial: wipes `cloudinary_connect` in beforeEach and re-connects with real +// credentials, which would break any parallel spec relying on a connection. +test.describe( 'Cloudinary wizard setup', { tag: '@serial' }, () => { test.beforeEach( async ( { context } ) => { // Clear server-side state via WP-CLI. resetCloudinaryConnection(); From 9dca8d3f7f8c13cdb4d087519007541d3f4e02cb Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 4 Sep 2026 15:46:22 +0530 Subject: [PATCH 2/5] ci(e2e): Drop --ignore-scripts from npm ci With npm 10 (Node 22) the flag made the install step take 4 to 7 minutes in two consecutive attempts, against ~36s for the plain npm ci in the other jobs of the same run. The postinstall hook it was meant to skip costs about 7s. --- .github/workflows/ci.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76bd481b3..41bf38de9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -148,12 +148,12 @@ jobs: node-version-file: '.nvmrc' cache: 'npm' - # --ignore-scripts skips the postinstall hook: `composer install` only - # pulls dev tooling (phpcs, phpstan, phpunit) the plugin does not load - # at runtime, and `patch-package` only patches wp-hookdoc for the docs - # build. Neither is needed to run the e2e suite. + # Deliberately not `--ignore-scripts`: with the npm 10 that ships with + # the pinned Node, that flag made this step take 4 to 7 minutes instead + # of ~36s (a known npm 10 reify stall around lifecycle-script nodes). + # The postinstall hook's composer install is only ~7s. - name: Install dependencies - run: npm ci --ignore-scripts + run: npm ci - name: Get Playwright version id: playwright-version From cb9483b3e86095a8eff6b1b348f67b77b6379f76 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 4 Sep 2026 16:03:00 +0530 Subject: [PATCH 3/5] ci(unit): Install only wp-env and drop wp-env sources cache The unit job pulled all ~2,200 npm packages to get one CLI. Install @wordpress/env alone, at the version pinned in package-lock.json, into a scratch prefix so npm does not reconcile against the repo lockfile. Composer deps are installed directly. Remove the wp-env sources cache. Once WP_ENV_HOME made it work, the restored directory carried the previous run's install state, the plugin activation hook did not run, and PHPUnit flagged two tests risky for a missing relationships table. --- .github/workflows/ci.yml | 54 ++++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41bf38de9..14d7686e9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -83,18 +83,19 @@ jobs: unit: name: Unit (PHPUnit) runs-on: ubuntu-latest - env: - # Shell-expanded by wp-env; matches the "Cache wp-env sources" path. - WP_ENV_HOME: /home/runner/.wp-env steps: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' - cache: 'npm' - name: Cache Composer uses: actions/cache@v4 @@ -104,36 +105,35 @@ jobs: restore-keys: | ${{ runner.os }}-composer-unit- - # wp-env downloads WordPress and the core PHPUnit suite into this - # directory, which is roughly a gigabyte of git clones. Caching it keeps - # `env:start` from re-cloning them on every run. The key covers the - # wp-env config and version, because a change to either means different - # sources have to be downloaded. - # - # WP_ENV_HOME is pinned at the job level because wp-env's default differs - # per platform (`~/.wp-env`, or `~/wp-env` on Linux runners that ship - # snap), and the unpinned path produced a cache that was never saved. - - name: Cache wp-env sources - uses: actions/cache@v4 - with: - path: ${{ env.WP_ENV_HOME }} - key: ${{ runner.os }}-wp-env-${{ hashFiles('.wp-env.json', 'package-lock.json') }} - restore-keys: | - ${{ runner.os }}-wp-env- - - # Also installs the Composer dependencies through the postinstall script. - - name: Install dependencies - run: npm ci + # This job only needs vendor/bin/phpunit (from Composer) and the wp-env + # CLI. A full `npm ci` pulls ~2,200 packages and has taken anywhere from + # 36s to 7 minutes on hosted runners; @wordpress/env alone is ~400 + # packages and installs in ~30s. It is installed into a scratch prefix + # outside the repo so npm does not reconcile against package-lock.json + # and pull the whole tree anyway. The version is read from the lockfile + # so it cannot drift from what developers run locally. + - name: Install Composer dependencies + run: composer install --no-interaction --no-progress + - name: Install wp-env + run: | + version=$(node -p "require('./package-lock.json').packages['node_modules/@wordpress/env'].version") + echo "Installing @wordpress/env@$version" + npm install --prefix "$RUNNER_TEMP/wp-env" --no-audit --no-fund "@wordpress/env@$version" + echo "$RUNNER_TEMP/wp-env/node_modules/.bin" >> "$GITHUB_PATH" + + # The wp-env sources directory is deliberately not cached. A restored + # ~/.wp-env carries the previous run's install state, which skipped the + # plugin's activation hook and left the relationships table missing. - name: Start wp-env - run: npm run env:start + run: wp-env start - name: Run unit tests - run: npm run test:unit + run: wp-env run tests-cli --env-cwd="wp-content/plugins/$(basename "$PWD")" vendor/bin/phpunit - name: Stop wp-env if: always() - run: npm run env:stop + run: wp-env stop e2e: name: E2E (Playwright) From 6401f2cddbb9fad3cbb4118af7bf6a1a52abe4f1 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 4 Sep 2026 16:14:07 +0530 Subject: [PATCH 4/5] test(e2e): Carry worker marker in a cookie, not a header extraHTTPHeaders is attached to every request the browser context makes, including cross-origin ones. The custom header forced a CORS preflight that third parties (fonts inside the Cloudinary player iframe) rejected. A cookie is scoped to the site under test and is shared by page.request, so REST calls still carry it. --- .wp-env/mu-plugins/analytics-capture.php | 10 ++++--- tests/e2e/fixtures.js | 34 +++++++++++++++--------- 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/.wp-env/mu-plugins/analytics-capture.php b/.wp-env/mu-plugins/analytics-capture.php index a8f68dc93..05a9c0116 100644 --- a/.wp-env/mu-plugins/analytics-capture.php +++ b/.wp-env/mu-plugins/analytics-capture.php @@ -26,8 +26,8 @@ * Returns the e2e worker marker for the current request, if any. * * Playwright runs spec files in parallel workers against this single - * WordPress install. Each worker tags its browser/REST traffic with an - * `X-CLD-E2E-Worker` header and its WP-CLI calls with a `CLD_E2E_WORKER` + * WordPress install. Each worker tags its browser/REST traffic with a + * `cld_e2e_worker` cookie and its WP-CLI calls with a `CLD_E2E_WORKER` * env var, so every worker gets its own capture log and one worker's * events (or `--clear`) can't leak into another worker's assertions. * @@ -39,8 +39,10 @@ function cld_analytics_capture_worker_marker() { $marker = ''; - if ( ! empty( $_SERVER['HTTP_X_CLD_E2E_WORKER'] ) ) { - $marker = wp_unslash( $_SERVER['HTTP_X_CLD_E2E_WORKER'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized + // Dev/CI-only mu-plugin with no page cache in front of it, so the VIP + // cache-constraints sniff on $_COOKIE does not apply. + if ( ! empty( $_COOKIE['cld_e2e_worker'] ) ) { // phpcs:ignore WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE + $marker = wp_unslash( $_COOKIE['cld_e2e_worker'] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPressVIPMinimum.Variables.RestrictedVariables.cache_constraints___COOKIE } elseif ( false !== getenv( 'CLD_E2E_WORKER' ) && '' !== getenv( 'CLD_E2E_WORKER' ) ) { $marker = getenv( 'CLD_E2E_WORKER' ); } diff --git a/tests/e2e/fixtures.js b/tests/e2e/fixtures.js index 9f336f623..7f488ddf3 100644 --- a/tests/e2e/fixtures.js +++ b/tests/e2e/fixtures.js @@ -11,9 +11,13 @@ * * The marker travels two ways: * - * - `X-CLD-E2E-Worker` request header, via the browser context's - * `extraHTTPHeaders`, so page loads and `page.request.*` REST calls made by - * the plugin's PHP side are attributed to this worker. + * - `cld_e2e_worker` cookie on the browser context, scoped to the site under + * test, so page loads and `page.request.*` REST calls (which share the + * context's cookie jar) are attributed to this worker. A cookie rather than + * an `extraHTTPHeaders` entry because Playwright attaches those headers to + * every request including cross-origin ones, and a custom header forces a + * CORS preflight that third parties (e.g. fonts loaded inside the Cloudinary + * player iframe) reject. * - `CLD_E2E_WORKER` env var on the Playwright worker process, which * `utils/wizard.js`'s `wpCli()` / `wpEvalFile()` forward into their * `docker exec` calls so WP-CLI reads and writes the same per-worker log. @@ -25,11 +29,11 @@ const base = require( '@wordpress/e2e-test-utils-playwright' ); /** - * Header name the mu-plugin reads the worker marker from. + * Cookie name the mu-plugin reads the worker marker from. * * @type {string} */ -const WORKER_HEADER = 'X-CLD-E2E-Worker'; +const WORKER_COOKIE = 'cld_e2e_worker'; /** * Builds the marker for a given Playwright worker. @@ -60,13 +64,17 @@ const test = base.test.extend( { { scope: 'worker', auto: true }, ], - // Merge the marker header into whatever `extraHTTPHeaders` the config - // already provides, rather than replacing it. - extraHTTPHeaders: async ( { extraHTTPHeaders }, provide, testInfo ) => { - await provide( { - ...( extraHTTPHeaders || {} ), - [ WORKER_HEADER ]: markerForWorker( testInfo ), - } ); + // Add the marker cookie to every browser context before the WP package's + // `page` fixture (and anything else built on `context`) gets hold of it. + context: async ( { context, baseURL }, provide, testInfo ) => { + await context.addCookies( [ + { + name: WORKER_COOKIE, + value: markerForWorker( testInfo ), + url: baseURL, + }, + ] ); + await provide( context ); }, } ); @@ -74,5 +82,5 @@ module.exports = { ...base, test, expect: base.expect, - WORKER_HEADER, + WORKER_COOKIE, }; From 258b76e3625ec2846bf4d8130c0e6db5612a9cb2 Mon Sep 17 00:00:00 2001 From: Utkarsh Patel Date: Fri, 4 Sep 2026 16:24:38 +0530 Subject: [PATCH 5/5] ci(e2e): Add Setup PHP and Composer cache before npm ci Every job with setup-php completes npm ci in ~35s; the e2e job, without it, took 2.5 to 4 minutes on the same runs with the same npm and the same npm cache hit. Aligning the job's prelude with the others is the one difference left to test. --- .github/workflows/ci.yml | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 14d7686e9..3469612e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -142,16 +142,32 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + # Not needed by Playwright, but every job that has this step completes + # `npm ci` in ~35s while this job, without it, took 2.5 to 4 minutes on + # the same runs (same npm, same cache hit). setup-php also installs + # Composer, which the postinstall hook calls. + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Setup Node.js uses: actions/setup-node@v4 with: node-version-file: '.nvmrc' cache: 'npm' + - name: Cache Composer + uses: actions/cache@v4 + with: + path: ~/.composer/cache + key: ${{ runner.os }}-composer-e2e-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer-e2e- + # Deliberately not `--ignore-scripts`: with the npm 10 that ships with # the pinned Node, that flag made this step take 4 to 7 minutes instead # of ~36s (a known npm 10 reify stall around lifecycle-script nodes). - # The postinstall hook's composer install is only ~7s. - name: Install dependencies run: npm ci