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..ed0480bf4 100644 --- a/Buildplan/Dockerfile-Jenkins +++ b/Buildplan/Dockerfile-Jenkins @@ -34,6 +34,15 @@ 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 + +# 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 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 && \ service mysql start && \ diff --git a/Buildplan/test.sh b/Buildplan/test.sh index 824693f90..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 @@ -145,9 +136,235 @@ chmod +x $PHPUNIT_BIN PHPUNIT_XML="${PLUGIN_DIR}/tests/phpunit.xml" -${PHPUNIT_BIN} -c ${PHPUNIT_XML} +# ── E2E (Playwright) ─────────────────────────────────────────────────────────── +# 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=$! + +# 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 \ + --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}" + +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)" \ + CI=true \ + PLAYWRIGHT_BASE_URL="${EXPECTED_SITEURL}" \ + E2E_TEST_POST_ID="${E2E_TEST_POST_ID}" \ + WP_ADMIN_USER=wp \ + WP_ADMIN_PASSWORD=wp \ + 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/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" + } + } + } +} 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..b0247fdfe --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,50 @@ +// @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: 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'], + ['junit', { outputFile: 'tests/playwright-results.xml' }], + ], + use: { + baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://test.com', + headless: true, + screenshot: { mode: 'only-on-failure', fullPage: true }, + 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..14b95dc58 --- /dev/null +++ b/tests/playwright/ajax-security.spec.js @@ -0,0 +1,155 @@ +/** + * 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. + * + * 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 = []; + 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(); + // '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() + 12000; + 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`, { waitUntil: 'commit' }); + }); + + 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`, { waitUntil: 'commit' }); + }); + + 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', { waitUntil: 'commit' }); + }); + + 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', { waitUntil: 'commit' }); + }); + + 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..6898b75d6 --- /dev/null +++ b/tests/playwright/auth.setup.js @@ -0,0 +1,34 @@ +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'; + + // 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'); + + 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..fad969b15 --- /dev/null +++ b/tests/playwright/fixtures/create-profile.php @@ -0,0 +1,112 @@ + + * + * 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'; + +// 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, + [ + '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' => 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, + ], + [ + '%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..2a580027d --- /dev/null +++ b/tests/playwright/job-wizard.spec.js @@ -0,0 +1,70 @@ +/** + * 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 }) => { + // 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(); + 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`, { 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(); + + 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 }) => { + const pageErrors = []; + page.on('pageerror', (err) => pageErrors.push({ + message: err.message || String(err), + stack: (err.stack || '').substring(0, 200), + })); + + 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'); + }); +}); + +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', { 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(); + expect(nonce.length).toBeGreaterThanOrEqual(8); + }); +});