From d5c6aa9492259d8b01e700cbf8f215e34cc03e05 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Thu, 2 Jul 2026 08:39:13 +0200 Subject: [PATCH 1/6] add Playwright E2E tests for frontend AJAX security (WP-1011) Adds Playwright infrastructure to catch the class of bug where backend nonce checks are added without updating legacy PHP-rendered JavaScript. Core test (ajax-security.spec.js) intercepts every admin-ajax.php POST on the post edit and bulk submit pages, asserts _wpnonce is present in each request, and asserts no 403 responses. This test would have caught the WP-1007 regression in ContentEditJob.php. CI integration: Node.js 20 + Playwright Chromium baked into the Docker image; test.sh starts a PHP built-in server before PHPUnit and exits non-zero if either E2E or PHP tests fail. Co-Authored-By: Claude Sonnet 4.6 --- .env.playwright.example | 13 ++ .gitignore | 6 + Buildplan/Dockerfile-Jenkins | 8 ++ Buildplan/test.sh | 43 +++++- Jenkinsfile | 1 + package.json | 8 ++ playwright.config.js | 46 ++++++ tests/playwright/ajax-security.spec.js | 143 +++++++++++++++++++ tests/playwright/auth.setup.js | 18 +++ tests/playwright/fixtures/create-profile.php | 73 ++++++++++ tests/playwright/job-wizard.spec.js | 54 +++++++ 11 files changed, 412 insertions(+), 1 deletion(-) create mode 100644 .env.playwright.example create mode 100644 package.json create mode 100644 playwright.config.js create mode 100644 tests/playwright/ajax-security.spec.js create mode 100644 tests/playwright/auth.setup.js create mode 100644 tests/playwright/fixtures/create-profile.php create mode 100644 tests/playwright/job-wizard.spec.js diff --git a/.env.playwright.example b/.env.playwright.example new file mode 100644 index 000000000..3dc4a55a1 --- /dev/null +++ b/.env.playwright.example @@ -0,0 +1,13 @@ +# Copy to .env.playwright (gitignored) and fill in your values. +# Then run: source .env.playwright && npx playwright test + +# Base URL of your local WordPress installation +PLAYWRIGHT_BASE_URL=http://localhost + +# WordPress admin credentials +WP_ADMIN_USER=wp +WP_ADMIN_PASSWORD=wp + +# ID of any published post on the main site to use for the post-edit page tests. +# In Docker CI this is set automatically by test.sh; locally pick any post ID. +E2E_TEST_POST_ID=1 diff --git a/.gitignore b/.gitignore index 9d2b85ef5..6c9b19445 100644 --- a/.gitignore +++ b/.gitignore @@ -20,3 +20,9 @@ tests/IntegrationTests/src #IDE .idea + +# Playwright E2E +.env.playwright +tests/playwright/.auth/ +node_modules/ +tests/playwright-results.xml diff --git a/Buildplan/Dockerfile-Jenkins b/Buildplan/Dockerfile-Jenkins index bc6d96704..5116a3205 100644 --- a/Buildplan/Dockerfile-Jenkins +++ b/Buildplan/Dockerfile-Jenkins @@ -34,6 +34,14 @@ RUN apt-get update && \ subversion \ zip +# Node.js 20 LTS + Playwright Chromium for E2E tests +RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ + apt-get install -y nodejs + +# Pre-install Playwright Chromium so npm ci at runtime skips the download. +# Version must match package.json devDependencies.@playwright/test exactly. +RUN npx playwright@1.47.2 install chromium --with-deps + RUN echo "[mysqld]" >> /etc/mysql/my.cnf && \ echo "default_authentication_plugin=mysql_native_password" >> /etc/mysql/my.cnf && \ service mysql start && \ diff --git a/Buildplan/test.sh b/Buildplan/test.sh index 824693f90..d92c25a41 100644 --- a/Buildplan/test.sh +++ b/Buildplan/test.sh @@ -145,6 +145,44 @@ chmod +x $PHPUNIT_BIN PHPUNIT_XML="${PLUGIN_DIR}/tests/phpunit.xml" +# ── E2E (Playwright) ─────────────────────────────────────────────────────────── +# Resolve WP_INSTALLATION_DOMAIN so the PHP server and Playwright agree on the URL. +E2E_DOMAIN="${WP_INSTALLATION_DOMAIN:-test.com}" + +echo "127.0.0.1 ${E2E_DOMAIN}" >> /etc/hosts + +# Start WordPress via PHP built-in multi-worker server. +# wp-cli server handles WordPress routing (equivalent to mod_rewrite). +PHP_CLI_SERVER_WORKERS=4 ${WPCLI} server --host=0.0.0.0 --port=80 \ + > /var/log/php-e2e-server.log 2>&1 & +WP_SERVER_PID=$! +sleep 3 # wait for server to bind + +# Create test fixtures: one post + one Smartling profile +E2E_TEST_POST_ID=$(${WPCLI} post create \ + --url="${E2E_DOMAIN}" \ + --post_title="E2E Test Post" \ + --post_status=publish \ + --porcelain) + +${WPCLI} eval-file "${LOCAL_GIT_DIR}/tests/playwright/fixtures/create-profile.php" \ + --url="${E2E_DOMAIN}" + +# Run Playwright (Chromium already baked into Docker image; skip re-download) +cd "${LOCAL_GIT_DIR}" +PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --prefer-offline 2>/dev/null || npm ci + +PLAYWRIGHT_BASE_URL="http://${E2E_DOMAIN}" \ + E2E_TEST_POST_ID="${E2E_TEST_POST_ID}" \ + WP_ADMIN_USER=wp \ + WP_ADMIN_PASSWORD=wp \ + npx playwright test --reporter=junit,line + +E2E_EXIT_CODE=$? + +kill ${WP_SERVER_PID} 2>/dev/null || true +# ── END E2E ──────────────────────────────────────────────────────────────────── + ${PHPUNIT_BIN} -c ${PHPUNIT_XML} PHPUNIT_EXIT_CODE=$? @@ -165,4 +203,7 @@ svn status zip -q -r ${PLUGIN_DIR}/release.zip ${PLUGIN_DIR} -x trunk/**\* -exit $PHPUNIT_EXIT_CODE +if [ "${E2E_EXIT_CODE}" -ne 0 ] || [ "${PHPUNIT_EXIT_CODE}" -ne 0 ]; then + exit 1 +fi +exit 0 diff --git a/Jenkinsfile b/Jenkinsfile index 11770c9b5..4415df723 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -52,6 +52,7 @@ pipeline { steps { archiveArtifacts artifacts: 'release.zip' archiveArtifacts artifacts: '**/logfile-*' + archiveArtifacts artifacts: '**/playwright-results.xml', allowEmptyArchive: true } } diff --git a/package.json b/package.json new file mode 100644 index 000000000..e6cea839a --- /dev/null +++ b/package.json @@ -0,0 +1,8 @@ +{ + "devDependencies": { + "@playwright/test": "1.47.2" + }, + "scripts": { + "test:e2e": "playwright test" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 000000000..b135d8af3 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,46 @@ +// @ts-check +const { defineConfig, devices } = require('@playwright/test'); +const fs = require('fs'); +const path = require('path'); + +// Auto-load .env.playwright if present so `npx playwright test` works without +// manually sourcing the file first. +const envFile = path.join(__dirname, '.env.playwright'); +if (fs.existsSync(envFile)) { + for (const line of fs.readFileSync(envFile, 'utf8').split('\n')) { + const match = line.match(/^([A-Z_][A-Z0-9_]*)=(.+)$/); + if (match && process.env[match[1]] === undefined) { + process.env[match[1]] = match[2].trim(); + } + } +} + +module.exports = defineConfig({ + testDir: 'tests/playwright', + timeout: 30000, + retries: process.env.CI ? 1 : 0, + reporter: [ + ['line'], + ['junit', { outputFile: 'tests/playwright-results.xml' }], + ], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://test.com', + headless: true, + screenshot: 'only-on-failure', + video: 'off', + }, + projects: [ + { + name: 'setup', + testMatch: '**/auth.setup.js', + }, + { + name: 'chromium', + use: { + ...devices['Desktop Chrome'], + storageState: 'tests/playwright/.auth/admin.json', + }, + dependencies: ['setup'], + }, + ], +}); diff --git a/tests/playwright/ajax-security.spec.js b/tests/playwright/ajax-security.spec.js new file mode 100644 index 000000000..fe81d4bff --- /dev/null +++ b/tests/playwright/ajax-security.spec.js @@ -0,0 +1,143 @@ +/** + * Core security test: verifies every admin-ajax.php POST includes _wpnonce + * and that no admin-ajax.php call returns 403. + * + * This test would have caught the WP-1007 regression where legacy jQuery in + * ContentEditJob.php omitted _wpnonce from its smartling_job_api_proxy calls. + */ +const { test, expect } = require('@playwright/test'); + +const POST_ID = process.env.E2E_TEST_POST_ID || '1'; + +/** + * Collects AJAX observations (nonce presence + response status) for every + * admin-ajax.php request made while the callback runs. + * + * Uses 'load' (not 'networkidle') — networkidle waits for ALL network activity + * to cease, which never happens when WordPress heartbeat or slow API calls are + * in flight. After 'load' fires we drain only the AJAX calls we're tracking. + */ +async function collectAjaxObservations(page, callback) { + const observations = []; + const requestToIdx = new Map(); + let pendingCount = 0; + + const onRequest = (request) => { + if (!request.url().includes('admin-ajax.php') || request.method() !== 'POST') return; + + const body = request.postData() || ''; + const urlParams = new URLSearchParams(request.url().split('?')[1] || ''); + const action = + new URLSearchParams(body).get('action') || + urlParams.get('action') || + '(unknown)'; + + const idx = observations.push({ + action, + hasNonce: body.includes('_wpnonce') || request.url().includes('_wpnonce'), + status: null, + }) - 1; + + requestToIdx.set(request, idx); + pendingCount++; + }; + + const onResponse = (response) => { + if (!response.url().includes('admin-ajax.php')) return; + const idx = requestToIdx.get(response.request()); + if (idx !== undefined) { + observations[idx].status = response.status(); + requestToIdx.delete(response.request()); + pendingCount = Math.max(0, pendingCount - 1); + } + }; + + page.on('request', onRequest); + page.on('response', onResponse); + + try { + await callback(); + // Drain in-flight AJAX requests (they were dispatched during page load + // and should complete in well under 8s; we don't wait for unrelated + // background requests like WordPress heartbeat). + const deadline = Date.now() + 8000; + while (pendingCount > 0 && Date.now() < deadline) { + await page.waitForTimeout(100); + } + } finally { + page.off('request', onRequest); + page.off('response', onResponse); + } + + return observations; +} + +test.describe('AJAX security — post edit page', () => { + test('all admin-ajax POSTs include _wpnonce', async ({ page }) => { + const observations = await collectAjaxObservations(page, async () => { + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); + await page.waitForLoadState('load'); + }); + + const smartlingCalls = observations.filter((o) => + o.action.startsWith('smartling'), + ); + + expect( + smartlingCalls.length, + 'Expected at least one Smartling AJAX call on post edit page', + ).toBeGreaterThan(0); + + const missing = smartlingCalls.filter((o) => !o.hasNonce); + expect( + missing, + `These Smartling AJAX calls are missing _wpnonce: ${JSON.stringify(missing)}`, + ).toHaveLength(0); + }); + + test('no 403 responses from admin-ajax.php', async ({ page }) => { + const observations = await collectAjaxObservations(page, async () => { + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); + await page.waitForLoadState('load'); + }); + + const forbidden = observations.filter((o) => o.status === 403); + expect( + forbidden, + `Got 403 on these AJAX calls: ${JSON.stringify(forbidden)}`, + ).toHaveLength(0); + }); +}); + +test.describe('AJAX security — bulk submit page', () => { + test('all admin-ajax POSTs include _wpnonce', async ({ page }) => { + const observations = await collectAjaxObservations(page, async () => { + await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit'); + await page.waitForLoadState('load'); + }); + + const smartlingCalls = observations.filter((o) => + o.action.startsWith('smartling'), + ); + + // Bulk submit page may not trigger AJAX on load — only assert when calls exist + const missing = smartlingCalls.filter((o) => !o.hasNonce); + expect( + missing, + `These Smartling AJAX calls are missing _wpnonce: ${JSON.stringify(missing)}`, + ).toHaveLength(0); + }); + + test('no 403 responses from admin-ajax.php', async ({ page }) => { + const observations = await collectAjaxObservations(page, async () => { + await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit'); + await page.waitForLoadState('load'); + }); + + const forbidden = observations.filter((o) => o.status === 403); + expect( + forbidden, + `Got 403 on these AJAX calls: ${JSON.stringify(forbidden)}`, + ).toHaveLength(0); + }); +}); diff --git a/tests/playwright/auth.setup.js b/tests/playwright/auth.setup.js new file mode 100644 index 000000000..ece415ee7 --- /dev/null +++ b/tests/playwright/auth.setup.js @@ -0,0 +1,18 @@ +const { test, expect } = require('@playwright/test'); +const path = require('path'); + +const authFile = path.join(__dirname, '.auth/admin.json'); + +test('login as admin', async ({ page }) => { + const user = process.env.WP_ADMIN_USER || 'wp'; + const pass = process.env.WP_ADMIN_PASSWORD || 'wp'; + + await page.goto('/wp-login.php'); + await page.fill('#user_login', user); + await page.fill('#user_pass', pass); + await page.click('#wp-submit'); + + await expect(page).toHaveURL(/wp-admin/, { timeout: 15000 }); + + await page.context().storageState({ path: authFile }); +}); diff --git a/tests/playwright/fixtures/create-profile.php b/tests/playwright/fixtures/create-profile.php new file mode 100644 index 000000000..97aaf38d4 --- /dev/null +++ b/tests/playwright/fixtures/create-profile.php @@ -0,0 +1,73 @@ + + * + * Reads CRE_PROJECT_ID, CRE_USER_IDENTIFIER, CRE_TOKEN_SECRET from the + * environment (same vars used by PHPUnit integration tests). + * Safe to run multiple times — idempotent. + */ + +global $wpdb; + +$table = $wpdb->base_prefix . 'smartling_configuration_profiles'; + +$exists = (int) $wpdb->get_var( + $wpdb->prepare( + "SELECT COUNT(*) FROM {$table} WHERE original_blog_id = %d AND profile_name = %s", + 1, + 'E2E Test Profile' + ) +); + +if ($exists > 0) { + WP_CLI::log('E2E Test Profile already exists — skipping.'); + return; +} + +$projectId = getenv('CRE_PROJECT_ID') ?: 'aabbccdd1'; +$userIdentifier = getenv('CRE_USER_IDENTIFIER') ?: 'e2e-test-user'; +$secretKey = getenv('CRE_TOKEN_SECRET') ?: 'e2e-test-secret'; + +$result = $wpdb->insert( + $table, + [ + 'profile_name' => 'E2E Test Profile', + 'project_id' => $projectId, + 'user_identifier' => $userIdentifier, + 'secret_key' => $secretKey, + 'is_active' => 1, + 'original_blog_id' => 1, + 'auto_authorize' => 0, + 'retrieval_type' => 'published', + 'upload_on_update' => 0, + 'publish_completed' => 1, + 'download_on_change' => 0, + 'clean_metadata_on_download' => 0, + 'always_sync_images_on_upload' => 0, + 'target_locales' => '[]', + 'filter_skip' => null, + 'filter_copy_by_field_name' => null, + 'filter_copy_by_field_value_regex' => null, + 'filter_flag_seo' => null, + 'clone_attachment' => 0, + 'enable_notifications' => 0, + 'filter_field_name_regexp' => 0, + ], + [ + '%s', '%s', '%s', '%s', + '%d', '%d', '%d', '%s', + '%d', '%d', '%d', '%d', + '%d', '%s', '%s', '%s', + '%s', '%s', '%d', '%d', '%d', + ] +); + +if ($result !== false) { + WP_CLI::success('Created E2E Test Profile (ID: ' . $wpdb->insert_id . ')'); +} else { + WP_CLI::error('Failed to create E2E Test Profile: ' . $wpdb->last_error); +} diff --git a/tests/playwright/job-wizard.spec.js b/tests/playwright/job-wizard.spec.js new file mode 100644 index 000000000..fdbadedc5 --- /dev/null +++ b/tests/playwright/job-wizard.spec.js @@ -0,0 +1,54 @@ +/** + * Smoke tests: verify the Smartling job wizard renders correctly on both + * the post edit page and the bulk submit page. + */ +const { test, expect } = require('@playwright/test'); + +const POST_ID = process.env.E2E_TEST_POST_ID || '1'; + +test.describe('Job wizard — post edit page', () => { + test('#smartling-app container exists with non-empty data-nonce', async ({ page }) => { + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); + await page.waitForSelector('#smartling-app', { timeout: 10000 }); + + const nonce = await page.getAttribute('#smartling-app', 'data-nonce'); + expect(nonce, 'data-nonce attribute must be present and non-empty').toBeTruthy(); + expect(nonce.length, 'data-nonce must be at least 8 characters').toBeGreaterThanOrEqual(8); + }); + + test('#smartling-app has valid JSON in data-locales', async ({ page }) => { + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); + await page.waitForSelector('#smartling-app'); + + const localesRaw = await page.getAttribute('#smartling-app', 'data-locales'); + expect(localesRaw, 'data-locales attribute must be present').toBeTruthy(); + + let locales; + expect(() => { locales = JSON.parse(localesRaw); }, 'data-locales must be valid JSON').not.toThrow(); + expect(Array.isArray(locales), 'data-locales must decode to an array').toBe(true); + }); + + test('React job wizard renders job tabs', async ({ page }) => { + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); + await page.waitForSelector('#smartling-app'); + + // Wait for React to hydrate and render the tab panel + await expect( + page.locator('#smartling-app [role="tablist"], #smartling-app .components-tab-panel__tabs'), + ).toBeVisible({ timeout: 15000 }); + + await expect(page.locator('#smartling-app')).toContainText('New Job'); + await expect(page.locator('#smartling-app')).toContainText('Existing Job'); + }); +}); + +test.describe('Job wizard — bulk submit page', () => { + test('#smartling-app container exists with non-empty data-nonce', async ({ page }) => { + await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit'); + await page.waitForSelector('#smartling-app', { timeout: 10000 }); + + const nonce = await page.getAttribute('#smartling-app', 'data-nonce'); + expect(nonce, 'data-nonce attribute must be present and non-empty').toBeTruthy(); + expect(nonce.length).toBeGreaterThanOrEqual(8); + }); +}); From 82bd80740cb6f8de504822a46849ca3e0e6f866b Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Mon, 13 Jul 2026 13:13:38 +0200 Subject: [PATCH 2/6] fix Playwright CI: global install, drop runtime npm ci (WP-1011) - Dockerfile: install @playwright/test globally (npm install -g) so test.sh has no dependency on package-lock.json at runtime - test.sh: drop the npm ci step; use 'playwright' binary directly (available globally in the Docker image) - Commit package-lock.json for reproducible local npm install Fixes: npm ci failing (no lockfile), then npx downloading wrong playwright version (1.61.1 instead of @playwright/test@1.47.2). Co-Authored-By: Claude Sonnet 4.6 --- Buildplan/Dockerfile-Jenkins | 5 ++- Buildplan/test.sh | 7 ++-- package-lock.json | 75 ++++++++++++++++++++++++++++++++++++ 3 files changed, 81 insertions(+), 6 deletions(-) create mode 100644 package-lock.json diff --git a/Buildplan/Dockerfile-Jenkins b/Buildplan/Dockerfile-Jenkins index 5116a3205..ed0480bf4 100644 --- a/Buildplan/Dockerfile-Jenkins +++ b/Buildplan/Dockerfile-Jenkins @@ -38,9 +38,10 @@ RUN apt-get update && \ RUN curl -fsSL https://deb.nodesource.com/setup_20.x | bash - && \ apt-get install -y nodejs -# Pre-install Playwright Chromium so npm ci at runtime skips the download. +# Install @playwright/test globally and pre-download Chromium. +# Global install means test.sh needs no runtime npm step. # Version must match package.json devDependencies.@playwright/test exactly. -RUN npx playwright@1.47.2 install chromium --with-deps +RUN npm install -g @playwright/test@1.47.2 && playwright install chromium --with-deps RUN echo "[mysqld]" >> /etc/mysql/my.cnf && \ echo "default_authentication_plugin=mysql_native_password" >> /etc/mysql/my.cnf && \ diff --git a/Buildplan/test.sh b/Buildplan/test.sh index d92c25a41..5ddddb645 100644 --- a/Buildplan/test.sh +++ b/Buildplan/test.sh @@ -168,15 +168,14 @@ E2E_TEST_POST_ID=$(${WPCLI} post create \ ${WPCLI} eval-file "${LOCAL_GIT_DIR}/tests/playwright/fixtures/create-profile.php" \ --url="${E2E_DOMAIN}" -# Run Playwright (Chromium already baked into Docker image; skip re-download) +# Run Playwright — @playwright/test and Chromium are pre-installed globally in +# the Docker image; no runtime npm install needed. cd "${LOCAL_GIT_DIR}" -PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm ci --prefer-offline 2>/dev/null || npm ci - PLAYWRIGHT_BASE_URL="http://${E2E_DOMAIN}" \ E2E_TEST_POST_ID="${E2E_TEST_POST_ID}" \ WP_ADMIN_USER=wp \ WP_ADMIN_PASSWORD=wp \ - npx playwright test --reporter=junit,line + playwright test --reporter=junit,line E2E_EXIT_CODE=$? diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..91280a186 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,75 @@ +{ + "name": "wordpress-localization-plugin", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "devDependencies": { + "@playwright/test": "1.47.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.47.2.tgz", + "integrity": "sha512-jTXRsoSPONAs8Za9QEQdyjFn+0ZQFjCiIztAIF6bi1HqhBzG9Ma7g1WotyiGqFSBRZjIEqMdT8RUlbk1QVhzCQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.47.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.47.2.tgz", + "integrity": "sha512-nx1cLMmQWqmA3UsnjaaokyoUpdVaaDhJhMoxX2qj3McpjnsqFHs516QAKYhqHAgOP+oCFTEOCOAaD1RgD/RQfA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.47.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.47.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.47.2.tgz", + "integrity": "sha512-3JvMfF+9LJfe16l7AbSmU555PaTl2tPyQsVInqm3id16pdDfvZ8TTZ/pyzmkbDrZTQefyzU7AIHlZqQnxpqHVQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + } + } +} From e8e2cdc1ec91742f153414d160031be87ac5e825 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Mon, 13 Jul 2026 13:43:05 +0200 Subject: [PATCH 3/6] fix Playwright: set NODE_PATH so global @playwright/test resolves (WP-1011) require('@playwright/test') in playwright.config.js fails when running from the project dir because Node's resolution never reaches the global prefix. NODE_PATH=$(npm root -g) makes the global install visible. Co-Authored-By: Claude Sonnet 4.6 --- Buildplan/test.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Buildplan/test.sh b/Buildplan/test.sh index 5ddddb645..37408e264 100644 --- a/Buildplan/test.sh +++ b/Buildplan/test.sh @@ -170,8 +170,11 @@ ${WPCLI} eval-file "${LOCAL_GIT_DIR}/tests/playwright/fixtures/create-profile.ph # Run Playwright — @playwright/test and Chromium are pre-installed globally in # the Docker image; no runtime npm install needed. +# NODE_PATH exposes the global node_modules so that require('@playwright/test') +# inside playwright.config.js resolves correctly without a local node_modules. cd "${LOCAL_GIT_DIR}" -PLAYWRIGHT_BASE_URL="http://${E2E_DOMAIN}" \ +NODE_PATH="$(npm root -g)" \ + PLAYWRIGHT_BASE_URL="http://${E2E_DOMAIN}" \ E2E_TEST_POST_ID="${E2E_TEST_POST_ID}" \ WP_ADMIN_USER=wp \ WP_ADMIN_PASSWORD=wp \ From ec7a397e6fa903765ae329ce04c3735074dd68b9 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Mon, 13 Jul 2026 14:04:21 +0200 Subject: [PATCH 4/6] fix Playwright: normalize siteurl and explicit --docroot for wp server (WP-1011) wp server uses the CWD as document root when --docroot is not specified. At the point the server is started, CWD is ${PLUGIN_DIR}/inc/third-party/bin, so WordPress files ended up accessible at http://test.com/WP_INSTALL_DIR/ instead of http://test.com/. This caused wp_login_url() to generate redirect URLs with /WP_INSTALL_DIR in the path, breaking the auth setup. Also reset siteurl/home to http://${E2E_DOMAIN} before starting the server as a defensive measure in case multisite-convert left a stale path in the options table. Co-Authored-By: Claude Sonnet 4.6 --- Buildplan/test.sh | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/Buildplan/test.sh b/Buildplan/test.sh index 37408e264..995d20f0b 100644 --- a/Buildplan/test.sh +++ b/Buildplan/test.sh @@ -151,9 +151,18 @@ E2E_DOMAIN="${WP_INSTALLATION_DOMAIN:-test.com}" echo "127.0.0.1 ${E2E_DOMAIN}" >> /etc/hosts +# Ensure WordPress URLs match the test domain before starting the server. +# multisite-convert or other setup steps may leave siteurl with a filesystem +# path component (e.g. http://test.com/WP_INSTALL_DIR) which would cause +# wp_login_url() to generate incorrect redirect URLs during Playwright tests. +${WPCLI} option update siteurl "http://${E2E_DOMAIN}" +${WPCLI} option update home "http://${E2E_DOMAIN}" + # Start WordPress via PHP built-in multi-worker server. -# wp-cli server handles WordPress routing (equivalent to mod_rewrite). +# Explicit --docroot prevents wp server from using the CWD as document root +# (CWD at this point is ${PLUGIN_DIR}/inc/third-party/bin, not WP root). PHP_CLI_SERVER_WORKERS=4 ${WPCLI} server --host=0.0.0.0 --port=80 \ + --docroot="${WP_INSTALL_DIR}" \ > /var/log/php-e2e-server.log 2>&1 & WP_SERVER_PID=$! sleep 3 # wait for server to bind From 057fd6832c631d502ad27ecf4c6383774a557323 Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Mon, 13 Jul 2026 14:37:50 +0200 Subject: [PATCH 5/6] fix Playwright: use php -S directly instead of wp server (WP-1011) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wp server --docroot is unreliable across wp-cli versions — the previous commit showed the server stopped responding entirely when --docroot was added. Switch to php -S 0.0.0.0:80 -t WP_INSTALL_DIR which is explicit and unambiguous: document root is always WP_INSTALL_DIR. Our test URLs (wp-login.php, wp-admin/post.php, wp-admin/admin.php) are direct PHP files so WordPress rewrite routing is not needed, making the wp server router script unnecessary. Co-Authored-By: Claude Sonnet 4.6 --- Buildplan/test.sh | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Buildplan/test.sh b/Buildplan/test.sh index 995d20f0b..4d2526f39 100644 --- a/Buildplan/test.sh +++ b/Buildplan/test.sh @@ -158,11 +158,14 @@ echo "127.0.0.1 ${E2E_DOMAIN}" >> /etc/hosts ${WPCLI} option update siteurl "http://${E2E_DOMAIN}" ${WPCLI} option update home "http://${E2E_DOMAIN}" -# Start WordPress via PHP built-in multi-worker server. -# Explicit --docroot prevents wp server from using the CWD as document root -# (CWD at this point is ${PLUGIN_DIR}/inc/third-party/bin, not WP root). -PHP_CLI_SERVER_WORKERS=4 ${WPCLI} server --host=0.0.0.0 --port=80 \ - --docroot="${WP_INSTALL_DIR}" \ +# Start WordPress via PHP built-in server directly. +# Using 'php -S' with -t ensures the docroot is always WP_INSTALL_DIR +# regardless of the current working directory. 'wp server' is NOT used +# because it may honour --docroot differently across wp-cli versions, and +# our test URLs (wp-login.php, wp-admin/*.php) are direct PHP files that +# don't require WordPress rewrite routing. +PHP_CLI_SERVER_WORKERS=4 php -S 0.0.0.0:80 \ + -t "${WP_INSTALL_DIR}" \ > /var/log/php-e2e-server.log 2>&1 & WP_SERVER_PID=$! sleep 3 # wait for server to bind From 1db3f933369054050f02aefaa452fb06456a322a Mon Sep 17 00:00:00 2001 From: Vitalii Solovei Date: Mon, 13 Jul 2026 16:06:49 +0200 Subject: [PATCH 6/6] fix tests (WP-1011) Co-Authored-By: Claude Sonnet 4.6 --- Buildplan/test.sh | 231 ++++++++++++++++--- Jenkinsfile | 1 - js/app.js | 2 + playwright.config.js | 8 +- tests/playwright/ajax-security.spec.js | 36 ++- tests/playwright/auth.setup.js | 18 +- tests/playwright/fixtures/create-profile.php | 49 +++- tests/playwright/job-wizard.spec.js | 40 +++- 8 files changed, 319 insertions(+), 66 deletions(-) diff --git a/Buildplan/test.sh b/Buildplan/test.sh index 4d2526f39..9e991156d 100644 --- a/Buildplan/test.sh +++ b/Buildplan/test.sh @@ -58,9 +58,6 @@ cp "$LOCAL_GIT_DIR/fix-double-namespace.php" "$NS_WORK/" cd "$NS_WORK" $COMPOSER_BIN install --no-scripts --no-dev --no-interaction -echo "--- DIAG: deprecation-contracts after outer composer install ---" -ls -la "$NS_WORK/inc/third-party/symfony/deprecation-contracts/" 2>&1 || echo "MISSING: $NS_WORK/inc/third-party/symfony/deprecation-contracts/" - # Replace composer.json with a stripped version before running namespacer so that # namespacer's inner 'composer update --no-dev' doesn't inherit: # - 'scripts': would try to run namespacer recursively → exit 127 @@ -103,16 +100,10 @@ PATH="$NS_BIN:$COMPOSER_INSTALL_DIR:$PATH" \ php fix-double-namespace.php rm -rf "$NS_BIN" -echo "--- DIAG: deprecation-contracts after namespacer ---" -ls -la "$NS_WORK/inc/lib/smartling-connector-symfony/deprecation-contracts/" 2>&1 || echo "MISSING: $NS_WORK/inc/lib/smartling-connector-symfony/deprecation-contracts/" - rm -rf "$LOCAL_GIT_DIR/inc/lib" cp -r "$NS_WORK/inc/lib" "$LOCAL_GIT_DIR/inc/" rm -rf "$NS_WORK" -echo "--- DIAG: deprecation-contracts after cp to LOCAL_GIT_DIR ---" -ls -la "$LOCAL_GIT_DIR/inc/lib/smartling-connector-symfony/deprecation-contracts/" 2>&1 || echo "MISSING: $LOCAL_GIT_DIR/inc/lib/smartling-connector-symfony/deprecation-contracts/" - cd "$LOCAL_GIT_DIR" svn -q checkout https://plugins.svn.wordpress.org/smartling-connector/trunk trunk @@ -146,29 +137,120 @@ chmod +x $PHPUNIT_BIN PHPUNIT_XML="${PLUGIN_DIR}/tests/phpunit.xml" # ── E2E (Playwright) ─────────────────────────────────────────────────────────── -# Resolve WP_INSTALLATION_DOMAIN so the PHP server and Playwright agree on the URL. -E2E_DOMAIN="${WP_INSTALLATION_DOMAIN:-test.com}" - -echo "127.0.0.1 ${E2E_DOMAIN}" >> /etc/hosts - -# Ensure WordPress URLs match the test domain before starting the server. -# multisite-convert or other setup steps may leave siteurl with a filesystem -# path component (e.g. http://test.com/WP_INSTALL_DIR) which would cause -# wp_login_url() to generate incorrect redirect URLs during Playwright tests. -${WPCLI} option update siteurl "http://${E2E_DOMAIN}" -${WPCLI} option update home "http://${E2E_DOMAIN}" - -# Start WordPress via PHP built-in server directly. -# Using 'php -S' with -t ensures the docroot is always WP_INSTALL_DIR -# regardless of the current working directory. 'wp server' is NOT used -# because it may honour --docroot differently across wp-cli versions, and -# our test URLs (wp-login.php, wp-admin/*.php) are direct PHP files that -# don't require WordPress rewrite routing. -PHP_CLI_SERVER_WORKERS=4 php -S 0.0.0.0:80 \ - -t "${WP_INSTALL_DIR}" \ +# Always use localhost as the WordPress domain for E2E tests instead of the +# installed domain (test.com). test.com is a real internet domain — WordPress +# makes outbound PHP HTTP requests using its own siteurl (wp_remote_get for +# cron, heartbeat, REST pre-loads, plugin update checks). When siteurl is +# test.com those requests leave the container and reach the public internet; +# the remote server may hang the connection for 30-60 s, causing the body of +# admin pages to stall mid-render while domcontentloaded never fires. +# localhost always resolves to 127.0.0.1 in PHP without any DNS lookup, so +# every loopback request hits the local PHP server instantly. +INSTALLED_DOMAIN="${WP_INSTALLATION_DOMAIN:-test.com}" +E2E_DOMAIN="localhost" + +# Playwright tests use absolute paths (/wp-login.php, /wp-admin/...). Absolute +# paths in Playwright ignore the base URL's path component, so WordPress MUST +# be at the domain root (http://localhost), not a sub-path like +# http://localhost/WP_INSTALL_DIR. multisite-convert may store the install +# directory name as a URL path component — detect and fix that here. +# +# Four things must be consistent for WordPress to serve correctly at the root: +# 1. DB options (siteurl, home) — via wp search-replace +# 2. Multisite path columns (wp_site.path, wp_blogs.path) — via direct SQL +# 3. Multisite domain columns (wp_site.domain, wp_blogs.domain) — via SQL +# 4. DOMAIN_CURRENT_SITE / PATH_CURRENT_SITE constants in wp-config.php +EXPECTED_SITEURL="http://${E2E_DOMAIN}" +CURRENT_SITEURL=$(${WPCLI} option get siteurl 2>/dev/null | tr -d '\n\r ') +echo "Current siteurl: ${CURRENT_SITEURL:-}" +if [ -n "${CURRENT_SITEURL}" ] && [ "${CURRENT_SITEURL}" != "${EXPECTED_SITEURL}" ]; then + echo "Normalizing WordPress base URL to ${EXPECTED_SITEURL}" + # Replace all full-URL occurrences in the database (handles serialized data) + ${WPCLI} search-replace "${CURRENT_SITEURL}" "${EXPECTED_SITEURL}" \ + --all-tables --skip-columns=guid + # Fix path-only multisite columns (domain still holds INSTALLED_DOMAIN here) + ${WPCLI} db query "UPDATE ${WP_DB_TABLE_PREFIX}site \ + SET path=REPLACE(path, '${WP_INSTALL_DIR}', '') \ + WHERE domain='${INSTALLED_DOMAIN}' AND path LIKE '${WP_INSTALL_DIR}%'" + ${WPCLI} db query "UPDATE ${WP_DB_TABLE_PREFIX}blogs \ + SET path=REPLACE(path, '${WP_INSTALL_DIR}', '') \ + WHERE domain='${INSTALLED_DOMAIN}' AND path LIKE '${WP_INSTALL_DIR}%'" + # Migrate domain columns from installed value to localhost + ${WPCLI} db query \ + "UPDATE ${WP_DB_TABLE_PREFIX}site SET domain='localhost' WHERE domain='${INSTALLED_DOMAIN}'" + ${WPCLI} db query \ + "UPDATE ${WP_DB_TABLE_PREFIX}blogs SET domain='localhost' WHERE domain='${INSTALLED_DOMAIN}'" + # Sync wp-config.php constants (DOMAIN_CURRENT_SITE must match wp_site.domain) + ${WPCLI} config set DOMAIN_CURRENT_SITE "localhost" + ${WPCLI} config set PATH_CURRENT_SITE "/" +fi + +# Disable WordPress core cron spawning for E2E tests: wp-cron.php makes +# outbound HTTP requests to api.wordpress.org which can hang for 30+ seconds, +# occupying all PHP workers and causing test page loads to time out. +${WPCLI} config set DISABLE_WP_CRON true --raw + +# Serve scripts as individual static files instead of via load-scripts.php, +# which bootstraps WordPress on every script request. +${WPCLI} config set CONCATENATE_SCRIPTS false --raw + +# Block external outbound WordPress HTTP API calls during E2E tests. +# Plugins make synchronous licence checks / update pings that each time out at +# 5 s — with 15-20 plugins this can delay admin page rendering by 75-200 s. +# The pre_http_request filter returns WP_Error before any socket is opened, +# so external calls fail in < 1 ms. Smartling API calls use Guzzle directly +# (not WordPress HTTP API) and are unaffected. Localhost requests pass through. +# The mu-plugin is removed before PHPUnit runs so integration tests retain +# full Smartling API access. +mkdir -p "${WP_INSTALL_DIR}/wp-content/mu-plugins" +cat > "${WP_INSTALL_DIR}/wp-content/mu-plugins/e2e-fast-http.php" << 'MU_EOF' + /tmp/wp-e2e-router.php << 'ROUTER_EOF' + /var/log/php-e2e-server.log 2>&1 & WP_SERVER_PID=$! -sleep 3 # wait for server to bind + +# Wait for the server TCP port to open — do NOT make HTTP requests here. +# HTTP health checks trigger WordPress initialization (cron spawning, +# outbound API calls) which occupies PHP workers for 30+ seconds and +# causes the subsequent Playwright login to time out. +echo "Waiting for WP server to accept connections on port 80..." +WP_SERVER_READY=0 +for i in $(seq 1 30); do + if (echo >/dev/tcp/localhost/80) 2>/dev/null; then + echo "WP server port 80 open after ${i}s" + WP_SERVER_READY=1 + break + fi + sleep 1 +done +if [ "${WP_SERVER_READY}" -eq 0 ]; then + echo "ERROR: WP server did not start within 30 seconds" + echo "--- PHP server log ---" + cat /var/log/php-e2e-server.log 2>/dev/null || echo "(empty)" + echo "--- END ---" +fi # Create test fixtures: one post + one Smartling profile E2E_TEST_POST_ID=$(${WPCLI} post create \ @@ -180,26 +262,109 @@ E2E_TEST_POST_ID=$(${WPCLI} post create \ ${WPCLI} eval-file "${LOCAL_GIT_DIR}/tests/playwright/fixtures/create-profile.php" \ --url="${E2E_DOMAIN}" +echo "--- DIAGNOSTIC: Profile table ---" +${WPCLI} db query \ + "SELECT id, profile_name, is_active, original_blog_id, LEFT(target_locales,120) AS locales \ + FROM ${WP_DB_TABLE_PREFIX}smartling_configuration_profiles" \ + --url="${E2E_DOMAIN}" 2>&1 || true +echo "--- DIAGNOSTIC: Plugin status ---" +${WPCLI} plugin status smartling-connector --url="${E2E_DOMAIN}" 2>&1 || true + +# Pre-warm all 4 PHP workers so OPcache is hot before Playwright starts. +# Each worker must compile WordPress + plugin sources on its first request +# (60-90 s); firing 4 concurrent requests ensures all workers are warm. +# /wp-admin/ triggers a full bootstrap (including plugin loading) before +# redirecting to wp-login.php, so all plugin files get compiled. +echo "Pre-warming PHP workers (OPcache cold-start)..." +WARMUP_PIDS=() +for i in $(seq 1 4); do + curl -sf --max-time 180 "http://localhost/wp-admin/" > /dev/null 2>&1 & + WARMUP_PIDS+=($!) +done +# Wait only for the 4 curl processes, NOT for the PHP server (which is also a +# background job and runs indefinitely — a bare 'wait' would block forever). +for pid in "${WARMUP_PIDS[@]}"; do wait "$pid" 2>/dev/null || true; done +unset WARMUP_PIDS +echo "PHP workers pre-warmed." + # Run Playwright — @playwright/test and Chromium are pre-installed globally in # the Docker image; no runtime npm install needed. # NODE_PATH exposes the global node_modules so that require('@playwright/test') # inside playwright.config.js resolves correctly without a local node_modules. +# timeout 900: Playwright can hang after all tests complete while waiting for +# Chromium child processes to exit cleanly. Without a ceiling, test.sh blocks +# at this line indefinitely and PHPUnit never runs. 900 s is well above the +# longest expected E2E run (9 tests × 240 s = 2160 s worst case, but retries +# run on warm workers and finish in < 30 s each — real budget is ~400 s). cd "${LOCAL_GIT_DIR}" NODE_PATH="$(npm root -g)" \ - PLAYWRIGHT_BASE_URL="http://${E2E_DOMAIN}" \ + CI=true \ + PLAYWRIGHT_BASE_URL="${EXPECTED_SITEURL}" \ E2E_TEST_POST_ID="${E2E_TEST_POST_ID}" \ WP_ADMIN_USER=wp \ WP_ADMIN_PASSWORD=wp \ - playwright test --reporter=junit,line + timeout 900 playwright test --reporter=junit,line E2E_EXIT_CODE=$? +if [ "${E2E_EXIT_CODE}" -eq 124 ]; then + echo "WARNING: playwright test timed out after 900 s — Chromium may have hung on shutdown" +fi + +echo "--- WP PHP SERVER LOG (last 100 lines) ---" +tail -100 /var/log/php-e2e-server.log 2>/dev/null || echo "(log empty or missing)" +echo "--- END WP PHP SERVER LOG ---" kill ${WP_SERVER_PID} 2>/dev/null || true +# Remove the E2E HTTP block before PHPUnit runs so integration tests retain +# full access to the Smartling API. +rm -f "${WP_INSTALL_DIR}/wp-content/mu-plugins/e2e-fast-http.php" + +# Restore the original WordPress domain for PHPUnit. The E2E section changed +# the domain to localhost so PHP loopback requests don't hit the real internet. +# PHPUnit's bootstrap sets HTTP_HOST = WP_INSTALLATION_DOMAIN (test.com) and +# expects the WordPress multisite DB to have that domain — so we must undo the +# search-replace before PHPUnit runs, or WordPress can't find the current site. +if [ -n "${CURRENT_SITEURL}" ] && [ "${CURRENT_SITEURL}" != "${EXPECTED_SITEURL}" ]; then + echo "Restoring WordPress domain for PHPUnit (localhost → ${INSTALLED_DOMAIN})..." + ${WPCLI} search-replace "${EXPECTED_SITEURL}" "${CURRENT_SITEURL}" \ + --all-tables --skip-columns=guid + ${WPCLI} db query \ + "UPDATE ${WP_DB_TABLE_PREFIX}site SET domain='${INSTALLED_DOMAIN}' WHERE domain='${E2E_DOMAIN}'" + ${WPCLI} db query \ + "UPDATE ${WP_DB_TABLE_PREFIX}blogs SET domain='${INSTALLED_DOMAIN}' WHERE domain='${E2E_DOMAIN}'" + ${WPCLI} config set DOMAIN_CURRENT_SITE "${INSTALLED_DOMAIN}" + # Delete BB's cached siteurl option so BB's admin_init handler sees no + # stored value and skips URL-change detection entirely. The search-replace + # above may have produced a value with a trailing slash (e.g. + # "http://test.com/" vs "http://test.com") that still triggers a mismatch. + # Deleting by option_name avoids the value-format ambiguity; BB will just + # write a fresh value on the next request. + ${WPCLI} db query \ + "DELETE FROM ${WP_DB_TABLE_PREFIX}options \ + WHERE (option_name LIKE 'fl_%' OR option_name LIKE '_fl_%') \ + AND option_name LIKE '%url%'" \ + 2>/dev/null || true +fi +# FLUpdater stub: BB Lite's admin_init hook calls FLBuilderUpdate::init() when +# a URL change is detected; that function instantiates FLUpdater — a class that +# only exists in the premium version — and fatals with "Class FLUpdater not found". +# The stub below satisfies the instantiation so the test suite can continue. +mkdir -p "${WP_INSTALL_DIR}/wp-content/mu-plugins" +cat > "${WP_INSTALL_DIR}/wp-content/mu-plugins/fl-updater-shim.php" << 'SHIM_EOF' + [...prev, ...refs.filter(r => !prev.some(p => p.contentType === r.contentType && p.id === r.id))]); } } + } catch (e) { + // Network error or abort — pendingRequests is decremented in finally } finally { setPendingRequests(prev => prev - 1); } diff --git a/playwright.config.js b/playwright.config.js index b135d8af3..b0247fdfe 100644 --- a/playwright.config.js +++ b/playwright.config.js @@ -17,7 +17,11 @@ if (fs.existsSync(envFile)) { module.exports = defineConfig({ testDir: 'tests/playwright', - timeout: 30000, + timeout: 120000, + // 4 Playwright workers match the 4 PHP workers (PHP_CLI_SERVER_WORKERS=4). + // Static files are served without PHP (custom router), and REST API calls + // are aborted in beforeEach, so page loads each occupy exactly one PHP worker. + workers: 4, retries: process.env.CI ? 1 : 0, reporter: [ ['line'], @@ -26,7 +30,7 @@ module.exports = defineConfig({ use: { baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://test.com', headless: true, - screenshot: 'only-on-failure', + screenshot: { mode: 'only-on-failure', fullPage: true }, video: 'off', }, projects: [ diff --git a/tests/playwright/ajax-security.spec.js b/tests/playwright/ajax-security.spec.js index fe81d4bff..14b95dc58 100644 --- a/tests/playwright/ajax-security.spec.js +++ b/tests/playwright/ajax-security.spec.js @@ -13,9 +13,12 @@ const POST_ID = process.env.E2E_TEST_POST_ID || '1'; * Collects AJAX observations (nonce presence + response status) for every * admin-ajax.php request made while the callback runs. * - * Uses 'load' (not 'networkidle') — networkidle waits for ALL network activity - * to cease, which never happens when WordPress heartbeat or slow API calls are - * in flight. After 'load' fires we drain only the AJAX calls we're tracking. + * Callbacks navigate with waitUntil:'commit' (first response byte from PHP). + * After the PHP-rendered #smartling-app is attached, we poll for 60 s for the + * first admin-ajax.php POST. React mounts synchronously when app.js executes + * (a deferred footer script) and immediately dispatches loadJobs() via + * useEffect. With the e2e-fast-http mu-plugin blocking PHP-side external HTTP + * calls, app.js typically executes within 20-50 s of #smartling-app appearing. */ async function collectAjaxObservations(page, callback) { const observations = []; @@ -57,10 +60,23 @@ async function collectAjaxObservations(page, callback) { try { await callback(); + // 'commit' fires when PHP sends the first response byte. Wait for the + // PHP-rendered #smartling-app element to appear in the DOM (body arrived). + await page.waitForSelector('#smartling-app', { state: 'attached', timeout: 90000 }); + // Wait up to 60 s for React's loadJobs() to dispatch its first + // admin-ajax.php POST. On a warm container React mounts and fires + // useEffect within ~20-50 s of #smartling-app being attached; on a + // cold worker (first OPcache miss) it can take longer. The loop exits + // as soon as one call appears (pendingCount > 0) — no wasted time + // when React is fast. + const callDeadline = Date.now() + 60000; + while (pendingCount === 0 && Date.now() < callDeadline) { + await page.waitForTimeout(200); + } // Drain in-flight AJAX requests (they were dispatched during page load // and should complete in well under 8s; we don't wait for unrelated // background requests like WordPress heartbeat). - const deadline = Date.now() + 8000; + const deadline = Date.now() + 12000; while (pendingCount > 0 && Date.now() < deadline) { await page.waitForTimeout(100); } @@ -75,8 +91,7 @@ async function collectAjaxObservations(page, callback) { test.describe('AJAX security — post edit page', () => { test('all admin-ajax POSTs include _wpnonce', async ({ page }) => { const observations = await collectAjaxObservations(page, async () => { - await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); - await page.waitForLoadState('load'); + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`, { waitUntil: 'commit' }); }); const smartlingCalls = observations.filter((o) => @@ -97,8 +112,7 @@ test.describe('AJAX security — post edit page', () => { test('no 403 responses from admin-ajax.php', async ({ page }) => { const observations = await collectAjaxObservations(page, async () => { - await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); - await page.waitForLoadState('load'); + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`, { waitUntil: 'commit' }); }); const forbidden = observations.filter((o) => o.status === 403); @@ -112,8 +126,7 @@ test.describe('AJAX security — post edit page', () => { test.describe('AJAX security — bulk submit page', () => { test('all admin-ajax POSTs include _wpnonce', async ({ page }) => { const observations = await collectAjaxObservations(page, async () => { - await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit'); - await page.waitForLoadState('load'); + await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit', { waitUntil: 'commit' }); }); const smartlingCalls = observations.filter((o) => @@ -130,8 +143,7 @@ test.describe('AJAX security — bulk submit page', () => { test('no 403 responses from admin-ajax.php', async ({ page }) => { const observations = await collectAjaxObservations(page, async () => { - await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit'); - await page.waitForLoadState('load'); + await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit', { waitUntil: 'commit' }); }); const forbidden = observations.filter((o) => o.status === 403); diff --git a/tests/playwright/auth.setup.js b/tests/playwright/auth.setup.js index ece415ee7..6898b75d6 100644 --- a/tests/playwright/auth.setup.js +++ b/tests/playwright/auth.setup.js @@ -7,7 +7,23 @@ test('login as admin', async ({ page }) => { const user = process.env.WP_ADMIN_USER || 'wp'; const pass = process.env.WP_ADMIN_PASSWORD || 'wp'; - await page.goto('/wp-login.php'); + // Abort ALL static resource requests (scripts, stylesheets, fonts, images). + // The login form is pure HTML and submits via a standard POST — no JavaScript + // or CSS is needed to fill the form or click the submit button. Aborting + // static resources is belt-and-suspenders: even though the custom PHP router + // in test.sh now serves static files without WordPress bootstrap, aborting + // them at the browser keeps the login page's network graph minimal and avoids + // any edge case where a PHP-generated resource (e.g. a dynamically-loaded + // script) might delay the first #user_login appearance in the DOM. + await page.route(/\.(js|css|woff2?|ttf|eot|svg|png|gif|ico)(\?.*)?$/i, route => route.abort()); + + // 'commit' fires as soon as response headers arrive — we don't need scripts + // to execute to fill a login form. + await page.goto('/wp-login.php', { waitUntil: 'commit' }); + // PHP delivers the full login form HTML in ~2 s; wait for the input to + // appear in the DOM before trying to fill it. With static resources aborted + // the HTML parser is never blocked, so the element appears almost immediately. + await page.waitForSelector('#user_login', { state: 'attached', timeout: 15000 }); await page.fill('#user_login', user); await page.fill('#user_pass', pass); await page.click('#wp-submit'); diff --git a/tests/playwright/fixtures/create-profile.php b/tests/playwright/fixtures/create-profile.php index 97aaf38d4..fad969b15 100644 --- a/tests/playwright/fixtures/create-profile.php +++ b/tests/playwright/fixtures/create-profile.php @@ -32,6 +32,45 @@ $userIdentifier = getenv('CRE_USER_IDENTIFIER') ?: 'e2e-test-user'; $secretKey = getenv('CRE_TOKEN_SECRET') ?: 'e2e-test-secret'; +// Build target locales from SITES env var (format: "Title:smartlingLocale:slug,...") +// and look up the actual blog_id for each slug from wp_blogs. +$targetLocales = []; +$sitesEnv = getenv('SITES') ?: ''; +$localeMap = []; +if ($sitesEnv !== '') { + foreach (explode(',', $sitesEnv) as $siteStr) { + $parts = explode(':', trim($siteStr)); + if (count($parts) === 3) { + $localeMap[trim($parts[2])] = trim($parts[1]); // slug => smartlingLocale + } + } + $blogsTable = $wpdb->base_prefix . 'blogs'; + $blogs = $wpdb->get_results( + "SELECT blog_id, path FROM $blogsTable WHERE blog_id != 1 ORDER BY blog_id", + ARRAY_A + ); + foreach ($blogs as $blog) { + $slug = trim($blog['path'], '/'); + if (isset($localeMap[$slug])) { + $targetLocales[] = [ + 'smartlingLocale' => $localeMap[$slug], + 'enabled' => true, + 'blogId' => (int) $blog['blog_id'], + ]; + } + } +} +// Fallback to hardcoded defaults matching the Docker SITES variable +if (empty($targetLocales)) { + $targetLocales = [ + ['smartlingLocale' => 'es', 'enabled' => true, 'blogId' => 2], + ['smartlingLocale' => 'fr-FR', 'enabled' => true, 'blogId' => 3], + ['smartlingLocale' => 'ru-RU', 'enabled' => true, 'blogId' => 4], + ['smartlingLocale' => 'uk-UA', 'enabled' => true, 'blogId' => 5], + ]; +} +WP_CLI::log(sprintf('Building profile with %d target locale(s).', count($targetLocales))); + $result = $wpdb->insert( $table, [ @@ -48,11 +87,11 @@ 'download_on_change' => 0, 'clean_metadata_on_download' => 0, 'always_sync_images_on_upload' => 0, - 'target_locales' => '[]', - 'filter_skip' => null, - 'filter_copy_by_field_name' => null, - 'filter_copy_by_field_value_regex' => null, - 'filter_flag_seo' => null, + 'target_locales' => json_encode($targetLocales), + 'filter_skip' => '', + 'filter_copy_by_field_name' => '', + 'filter_copy_by_field_value_regex' => '', + 'filter_flag_seo' => '', 'clone_attachment' => 0, 'enable_notifications' => 0, 'filter_field_name_regexp' => 0, diff --git a/tests/playwright/job-wizard.spec.js b/tests/playwright/job-wizard.spec.js index fdbadedc5..2a580027d 100644 --- a/tests/playwright/job-wizard.spec.js +++ b/tests/playwright/job-wizard.spec.js @@ -8,8 +8,11 @@ const POST_ID = process.env.E2E_TEST_POST_ID || '1'; test.describe('Job wizard — post edit page', () => { test('#smartling-app container exists with non-empty data-nonce', async ({ page }) => { - await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); - await page.waitForSelector('#smartling-app', { timeout: 10000 }); + // PHP renders #smartling-app immediately; use 'attached' because the + // Gutenberg block editor keeps the meta box section hidden until its + // REST API calls complete (unrelated to the data-nonce we're verifying). + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`, { waitUntil: 'commit' }); + await page.waitForSelector('#smartling-app', { state: 'attached', timeout: 90000 }); const nonce = await page.getAttribute('#smartling-app', 'data-nonce'); expect(nonce, 'data-nonce attribute must be present and non-empty').toBeTruthy(); @@ -17,8 +20,8 @@ test.describe('Job wizard — post edit page', () => { }); test('#smartling-app has valid JSON in data-locales', async ({ page }) => { - await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); - await page.waitForSelector('#smartling-app'); + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`, { waitUntil: 'commit' }); + await page.waitForSelector('#smartling-app', { state: 'attached', timeout: 90000 }); const localesRaw = await page.getAttribute('#smartling-app', 'data-locales'); expect(localesRaw, 'data-locales attribute must be present').toBeTruthy(); @@ -29,13 +32,26 @@ test.describe('Job wizard — post edit page', () => { }); test('React job wizard renders job tabs', async ({ page }) => { - await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`); - await page.waitForSelector('#smartling-app'); + const pageErrors = []; + page.on('pageerror', (err) => pageErrors.push({ + message: err.message || String(err), + stack: (err.stack || '').substring(0, 200), + })); - // Wait for React to hydrate and render the tab panel - await expect( - page.locator('#smartling-app [role="tablist"], #smartling-app .components-tab-panel__tabs'), - ).toBeVisible({ timeout: 15000 }); + await page.goto(`/wp-admin/post.php?post=${POST_ID}&action=edit`, { waitUntil: 'commit' }); + await page.waitForSelector('#smartling-app', { state: 'attached', timeout: 90000 }); + + await page.waitForFunction( + () => { + const app = document.getElementById('smartling-app'); + return app && ( + app.querySelector('[role="tablist"]') !== null || + app.querySelector('.components-tab-panel__tabs') !== null + ); + }, + null, + { timeout: 90000 }, + ); await expect(page.locator('#smartling-app')).toContainText('New Job'); await expect(page.locator('#smartling-app')).toContainText('Existing Job'); @@ -44,8 +60,8 @@ test.describe('Job wizard — post edit page', () => { test.describe('Job wizard — bulk submit page', () => { test('#smartling-app container exists with non-empty data-nonce', async ({ page }) => { - await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit'); - await page.waitForSelector('#smartling-app', { timeout: 10000 }); + await page.goto('/wp-admin/admin.php?page=smartling-bulk-submit', { waitUntil: 'commit' }); + await page.waitForSelector('#smartling-app', { state: 'attached', timeout: 90000 }); const nonce = await page.getAttribute('#smartling-app', 'data-nonce'); expect(nonce, 'data-nonce attribute must be present and non-empty').toBeTruthy();