diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ee8155e34..3469612e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,11 +87,15 @@ jobs: - 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 @@ -101,32 +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. - - name: Cache wp-env sources - uses: actions/cache@v4 - with: - path: ~/.wp-env - key: ${{ runner.os }}-wp-env-${{ hashFiles('.wp-env.json', 'package-lock.json') }} - restore-keys: | - ${{ runner.os }}-wp-env- + # 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 - # Also installs the Composer dependencies through the postinstall script. - - name: Install dependencies - run: npm ci + - 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) @@ -135,23 +142,78 @@ 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). - name: Install dependencies run: npm ci - - name: Install Playwright browsers - run: npx playwright install --with-deps chromium - - - 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..05a9c0116 100644 --- a/.wp-env/mu-plugins/analytics-capture.php +++ b/.wp-env/mu-plugins/analytics-capture.php @@ -11,23 +11,101 @@ * 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 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. + * + * 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 = ''; + + // 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' ); + } + + 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..7f488ddf3 --- /dev/null +++ b/tests/e2e/fixtures.js @@ -0,0 +1,86 @@ +/** + * 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: + * + * - `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. + * + * Specs should import `test` and `expect` from this module instead of from + * the WordPress package directly. + */ + +const base = require( '@wordpress/e2e-test-utils-playwright' ); + +/** + * Cookie name the mu-plugin reads the worker marker from. + * + * @type {string} + */ +const WORKER_COOKIE = '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 }, + ], + + // 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 ); + }, +} ); + +module.exports = { + ...base, + test, + expect: base.expect, + WORKER_COOKIE, +}; 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();