Skip to content

Commit 1db3f93

Browse files
fix tests (WP-1011)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 057fd68 commit 1db3f93

8 files changed

Lines changed: 319 additions & 66 deletions

File tree

Buildplan/test.sh

Lines changed: 198 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,6 @@ cp "$LOCAL_GIT_DIR/fix-double-namespace.php" "$NS_WORK/"
5858
cd "$NS_WORK"
5959
$COMPOSER_BIN install --no-scripts --no-dev --no-interaction
6060

61-
echo "--- DIAG: deprecation-contracts after outer composer install ---"
62-
ls -la "$NS_WORK/inc/third-party/symfony/deprecation-contracts/" 2>&1 || echo "MISSING: $NS_WORK/inc/third-party/symfony/deprecation-contracts/"
63-
6461
# Replace composer.json with a stripped version before running namespacer so that
6562
# namespacer's inner 'composer update --no-dev' doesn't inherit:
6663
# - 'scripts': would try to run namespacer recursively → exit 127
@@ -103,16 +100,10 @@ PATH="$NS_BIN:$COMPOSER_INSTALL_DIR:$PATH" \
103100
php fix-double-namespace.php
104101
rm -rf "$NS_BIN"
105102

106-
echo "--- DIAG: deprecation-contracts after namespacer ---"
107-
ls -la "$NS_WORK/inc/lib/smartling-connector-symfony/deprecation-contracts/" 2>&1 || echo "MISSING: $NS_WORK/inc/lib/smartling-connector-symfony/deprecation-contracts/"
108-
109103
rm -rf "$LOCAL_GIT_DIR/inc/lib"
110104
cp -r "$NS_WORK/inc/lib" "$LOCAL_GIT_DIR/inc/"
111105
rm -rf "$NS_WORK"
112106

113-
echo "--- DIAG: deprecation-contracts after cp to LOCAL_GIT_DIR ---"
114-
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/"
115-
116107
cd "$LOCAL_GIT_DIR"
117108

118109
svn -q checkout https://plugins.svn.wordpress.org/smartling-connector/trunk trunk
@@ -146,29 +137,120 @@ chmod +x $PHPUNIT_BIN
146137
PHPUNIT_XML="${PLUGIN_DIR}/tests/phpunit.xml"
147138

148139
# ── E2E (Playwright) ───────────────────────────────────────────────────────────
149-
# Resolve WP_INSTALLATION_DOMAIN so the PHP server and Playwright agree on the URL.
150-
E2E_DOMAIN="${WP_INSTALLATION_DOMAIN:-test.com}"
151-
152-
echo "127.0.0.1 ${E2E_DOMAIN}" >> /etc/hosts
153-
154-
# Ensure WordPress URLs match the test domain before starting the server.
155-
# multisite-convert or other setup steps may leave siteurl with a filesystem
156-
# path component (e.g. http://test.com/WP_INSTALL_DIR) which would cause
157-
# wp_login_url() to generate incorrect redirect URLs during Playwright tests.
158-
${WPCLI} option update siteurl "http://${E2E_DOMAIN}"
159-
${WPCLI} option update home "http://${E2E_DOMAIN}"
160-
161-
# Start WordPress via PHP built-in server directly.
162-
# Using 'php -S' with -t ensures the docroot is always WP_INSTALL_DIR
163-
# regardless of the current working directory. 'wp server' is NOT used
164-
# because it may honour --docroot differently across wp-cli versions, and
165-
# our test URLs (wp-login.php, wp-admin/*.php) are direct PHP files that
166-
# don't require WordPress rewrite routing.
167-
PHP_CLI_SERVER_WORKERS=4 php -S 0.0.0.0:80 \
168-
-t "${WP_INSTALL_DIR}" \
140+
# Always use localhost as the WordPress domain for E2E tests instead of the
141+
# installed domain (test.com). test.com is a real internet domain — WordPress
142+
# makes outbound PHP HTTP requests using its own siteurl (wp_remote_get for
143+
# cron, heartbeat, REST pre-loads, plugin update checks). When siteurl is
144+
# test.com those requests leave the container and reach the public internet;
145+
# the remote server may hang the connection for 30-60 s, causing the body of
146+
# admin pages to stall mid-render while domcontentloaded never fires.
147+
# localhost always resolves to 127.0.0.1 in PHP without any DNS lookup, so
148+
# every loopback request hits the local PHP server instantly.
149+
INSTALLED_DOMAIN="${WP_INSTALLATION_DOMAIN:-test.com}"
150+
E2E_DOMAIN="localhost"
151+
152+
# Playwright tests use absolute paths (/wp-login.php, /wp-admin/...). Absolute
153+
# paths in Playwright ignore the base URL's path component, so WordPress MUST
154+
# be at the domain root (http://localhost), not a sub-path like
155+
# http://localhost/WP_INSTALL_DIR. multisite-convert may store the install
156+
# directory name as a URL path component — detect and fix that here.
157+
#
158+
# Four things must be consistent for WordPress to serve correctly at the root:
159+
# 1. DB options (siteurl, home) — via wp search-replace
160+
# 2. Multisite path columns (wp_site.path, wp_blogs.path) — via direct SQL
161+
# 3. Multisite domain columns (wp_site.domain, wp_blogs.domain) — via SQL
162+
# 4. DOMAIN_CURRENT_SITE / PATH_CURRENT_SITE constants in wp-config.php
163+
EXPECTED_SITEURL="http://${E2E_DOMAIN}"
164+
CURRENT_SITEURL=$(${WPCLI} option get siteurl 2>/dev/null | tr -d '\n\r ')
165+
echo "Current siteurl: ${CURRENT_SITEURL:-<empty>}"
166+
if [ -n "${CURRENT_SITEURL}" ] && [ "${CURRENT_SITEURL}" != "${EXPECTED_SITEURL}" ]; then
167+
echo "Normalizing WordPress base URL to ${EXPECTED_SITEURL}"
168+
# Replace all full-URL occurrences in the database (handles serialized data)
169+
${WPCLI} search-replace "${CURRENT_SITEURL}" "${EXPECTED_SITEURL}" \
170+
--all-tables --skip-columns=guid
171+
# Fix path-only multisite columns (domain still holds INSTALLED_DOMAIN here)
172+
${WPCLI} db query "UPDATE ${WP_DB_TABLE_PREFIX}site \
173+
SET path=REPLACE(path, '${WP_INSTALL_DIR}', '') \
174+
WHERE domain='${INSTALLED_DOMAIN}' AND path LIKE '${WP_INSTALL_DIR}%'"
175+
${WPCLI} db query "UPDATE ${WP_DB_TABLE_PREFIX}blogs \
176+
SET path=REPLACE(path, '${WP_INSTALL_DIR}', '') \
177+
WHERE domain='${INSTALLED_DOMAIN}' AND path LIKE '${WP_INSTALL_DIR}%'"
178+
# Migrate domain columns from installed value to localhost
179+
${WPCLI} db query \
180+
"UPDATE ${WP_DB_TABLE_PREFIX}site SET domain='localhost' WHERE domain='${INSTALLED_DOMAIN}'"
181+
${WPCLI} db query \
182+
"UPDATE ${WP_DB_TABLE_PREFIX}blogs SET domain='localhost' WHERE domain='${INSTALLED_DOMAIN}'"
183+
# Sync wp-config.php constants (DOMAIN_CURRENT_SITE must match wp_site.domain)
184+
${WPCLI} config set DOMAIN_CURRENT_SITE "localhost"
185+
${WPCLI} config set PATH_CURRENT_SITE "/"
186+
fi
187+
188+
# Disable WordPress core cron spawning for E2E tests: wp-cron.php makes
189+
# outbound HTTP requests to api.wordpress.org which can hang for 30+ seconds,
190+
# occupying all PHP workers and causing test page loads to time out.
191+
${WPCLI} config set DISABLE_WP_CRON true --raw
192+
193+
# Serve scripts as individual static files instead of via load-scripts.php,
194+
# which bootstraps WordPress on every script request.
195+
${WPCLI} config set CONCATENATE_SCRIPTS false --raw
196+
197+
# Block external outbound WordPress HTTP API calls during E2E tests.
198+
# Plugins make synchronous licence checks / update pings that each time out at
199+
# 5 s — with 15-20 plugins this can delay admin page rendering by 75-200 s.
200+
# The pre_http_request filter returns WP_Error before any socket is opened,
201+
# so external calls fail in < 1 ms. Smartling API calls use Guzzle directly
202+
# (not WordPress HTTP API) and are unaffected. Localhost requests pass through.
203+
# The mu-plugin is removed before PHPUnit runs so integration tests retain
204+
# full Smartling API access.
205+
mkdir -p "${WP_INSTALL_DIR}/wp-content/mu-plugins"
206+
cat > "${WP_INSTALL_DIR}/wp-content/mu-plugins/e2e-fast-http.php" << 'MU_EOF'
207+
<?php
208+
add_filter('pre_http_request', static function($preempt, $parsed_args, $url) {
209+
if (strpos($url, '://localhost') !== false || strpos($url, '://127.0.0.1') !== false) {
210+
return $preempt;
211+
}
212+
return new WP_Error('e2e_blocked', 'External HTTP blocked during E2E tests');
213+
}, 1, 3);
214+
MU_EOF
215+
216+
# Custom router: serve static files (CSS, JS, fonts, images) directly without
217+
# bootstrapping WordPress; only .php files and virtual URLs go through WP.
218+
# This keeps PHP workers free for actual page requests.
219+
cat > /tmp/wp-e2e-router.php << 'ROUTER_EOF'
220+
<?php
221+
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
222+
$file = $_SERVER['DOCUMENT_ROOT'] . $uri;
223+
if (is_file($file) || is_dir($file)) {
224+
return false;
225+
}
226+
chdir($_SERVER['DOCUMENT_ROOT']);
227+
require_once $_SERVER['DOCUMENT_ROOT'] . '/index.php';
228+
ROUTER_EOF
229+
230+
PHP_CLI_SERVER_WORKERS=4 php -S 0.0.0.0:80 -t "${WP_INSTALL_DIR}" /tmp/wp-e2e-router.php \
169231
> /var/log/php-e2e-server.log 2>&1 &
170232
WP_SERVER_PID=$!
171-
sleep 3 # wait for server to bind
233+
234+
# Wait for the server TCP port to open — do NOT make HTTP requests here.
235+
# HTTP health checks trigger WordPress initialization (cron spawning,
236+
# outbound API calls) which occupies PHP workers for 30+ seconds and
237+
# causes the subsequent Playwright login to time out.
238+
echo "Waiting for WP server to accept connections on port 80..."
239+
WP_SERVER_READY=0
240+
for i in $(seq 1 30); do
241+
if (echo >/dev/tcp/localhost/80) 2>/dev/null; then
242+
echo "WP server port 80 open after ${i}s"
243+
WP_SERVER_READY=1
244+
break
245+
fi
246+
sleep 1
247+
done
248+
if [ "${WP_SERVER_READY}" -eq 0 ]; then
249+
echo "ERROR: WP server did not start within 30 seconds"
250+
echo "--- PHP server log ---"
251+
cat /var/log/php-e2e-server.log 2>/dev/null || echo "(empty)"
252+
echo "--- END ---"
253+
fi
172254

173255
# Create test fixtures: one post + one Smartling profile
174256
E2E_TEST_POST_ID=$(${WPCLI} post create \
@@ -180,26 +262,109 @@ E2E_TEST_POST_ID=$(${WPCLI} post create \
180262
${WPCLI} eval-file "${LOCAL_GIT_DIR}/tests/playwright/fixtures/create-profile.php" \
181263
--url="${E2E_DOMAIN}"
182264

265+
echo "--- DIAGNOSTIC: Profile table ---"
266+
${WPCLI} db query \
267+
"SELECT id, profile_name, is_active, original_blog_id, LEFT(target_locales,120) AS locales \
268+
FROM ${WP_DB_TABLE_PREFIX}smartling_configuration_profiles" \
269+
--url="${E2E_DOMAIN}" 2>&1 || true
270+
echo "--- DIAGNOSTIC: Plugin status ---"
271+
${WPCLI} plugin status smartling-connector --url="${E2E_DOMAIN}" 2>&1 || true
272+
273+
# Pre-warm all 4 PHP workers so OPcache is hot before Playwright starts.
274+
# Each worker must compile WordPress + plugin sources on its first request
275+
# (60-90 s); firing 4 concurrent requests ensures all workers are warm.
276+
# /wp-admin/ triggers a full bootstrap (including plugin loading) before
277+
# redirecting to wp-login.php, so all plugin files get compiled.
278+
echo "Pre-warming PHP workers (OPcache cold-start)..."
279+
WARMUP_PIDS=()
280+
for i in $(seq 1 4); do
281+
curl -sf --max-time 180 "http://localhost/wp-admin/" > /dev/null 2>&1 &
282+
WARMUP_PIDS+=($!)
283+
done
284+
# Wait only for the 4 curl processes, NOT for the PHP server (which is also a
285+
# background job and runs indefinitely — a bare 'wait' would block forever).
286+
for pid in "${WARMUP_PIDS[@]}"; do wait "$pid" 2>/dev/null || true; done
287+
unset WARMUP_PIDS
288+
echo "PHP workers pre-warmed."
289+
183290
# Run Playwright — @playwright/test and Chromium are pre-installed globally in
184291
# the Docker image; no runtime npm install needed.
185292
# NODE_PATH exposes the global node_modules so that require('@playwright/test')
186293
# inside playwright.config.js resolves correctly without a local node_modules.
294+
# timeout 900: Playwright can hang after all tests complete while waiting for
295+
# Chromium child processes to exit cleanly. Without a ceiling, test.sh blocks
296+
# at this line indefinitely and PHPUnit never runs. 900 s is well above the
297+
# longest expected E2E run (9 tests × 240 s = 2160 s worst case, but retries
298+
# run on warm workers and finish in < 30 s each — real budget is ~400 s).
187299
cd "${LOCAL_GIT_DIR}"
188300
NODE_PATH="$(npm root -g)" \
189-
PLAYWRIGHT_BASE_URL="http://${E2E_DOMAIN}" \
301+
CI=true \
302+
PLAYWRIGHT_BASE_URL="${EXPECTED_SITEURL}" \
190303
E2E_TEST_POST_ID="${E2E_TEST_POST_ID}" \
191304
WP_ADMIN_USER=wp \
192305
WP_ADMIN_PASSWORD=wp \
193-
playwright test --reporter=junit,line
306+
timeout 900 playwright test --reporter=junit,line
194307

195308
E2E_EXIT_CODE=$?
309+
if [ "${E2E_EXIT_CODE}" -eq 124 ]; then
310+
echo "WARNING: playwright test timed out after 900 s — Chromium may have hung on shutdown"
311+
fi
312+
313+
echo "--- WP PHP SERVER LOG (last 100 lines) ---"
314+
tail -100 /var/log/php-e2e-server.log 2>/dev/null || echo "(log empty or missing)"
315+
echo "--- END WP PHP SERVER LOG ---"
196316

197317
kill ${WP_SERVER_PID} 2>/dev/null || true
318+
# Remove the E2E HTTP block before PHPUnit runs so integration tests retain
319+
# full access to the Smartling API.
320+
rm -f "${WP_INSTALL_DIR}/wp-content/mu-plugins/e2e-fast-http.php"
321+
322+
# Restore the original WordPress domain for PHPUnit. The E2E section changed
323+
# the domain to localhost so PHP loopback requests don't hit the real internet.
324+
# PHPUnit's bootstrap sets HTTP_HOST = WP_INSTALLATION_DOMAIN (test.com) and
325+
# expects the WordPress multisite DB to have that domain — so we must undo the
326+
# search-replace before PHPUnit runs, or WordPress can't find the current site.
327+
if [ -n "${CURRENT_SITEURL}" ] && [ "${CURRENT_SITEURL}" != "${EXPECTED_SITEURL}" ]; then
328+
echo "Restoring WordPress domain for PHPUnit (localhost → ${INSTALLED_DOMAIN})..."
329+
${WPCLI} search-replace "${EXPECTED_SITEURL}" "${CURRENT_SITEURL}" \
330+
--all-tables --skip-columns=guid
331+
${WPCLI} db query \
332+
"UPDATE ${WP_DB_TABLE_PREFIX}site SET domain='${INSTALLED_DOMAIN}' WHERE domain='${E2E_DOMAIN}'"
333+
${WPCLI} db query \
334+
"UPDATE ${WP_DB_TABLE_PREFIX}blogs SET domain='${INSTALLED_DOMAIN}' WHERE domain='${E2E_DOMAIN}'"
335+
${WPCLI} config set DOMAIN_CURRENT_SITE "${INSTALLED_DOMAIN}"
336+
# Delete BB's cached siteurl option so BB's admin_init handler sees no
337+
# stored value and skips URL-change detection entirely. The search-replace
338+
# above may have produced a value with a trailing slash (e.g.
339+
# "http://test.com/" vs "http://test.com") that still triggers a mismatch.
340+
# Deleting by option_name avoids the value-format ambiguity; BB will just
341+
# write a fresh value on the next request.
342+
${WPCLI} db query \
343+
"DELETE FROM ${WP_DB_TABLE_PREFIX}options \
344+
WHERE (option_name LIKE 'fl_%' OR option_name LIKE '_fl_%') \
345+
AND option_name LIKE '%url%'" \
346+
2>/dev/null || true
347+
fi
348+
# FLUpdater stub: BB Lite's admin_init hook calls FLBuilderUpdate::init() when
349+
# a URL change is detected; that function instantiates FLUpdater — a class that
350+
# only exists in the premium version — and fatals with "Class FLUpdater not found".
351+
# The stub below satisfies the instantiation so the test suite can continue.
352+
mkdir -p "${WP_INSTALL_DIR}/wp-content/mu-plugins"
353+
cat > "${WP_INSTALL_DIR}/wp-content/mu-plugins/fl-updater-shim.php" << 'SHIM_EOF'
354+
<?php
355+
if ( ! class_exists( 'FLUpdater' ) ) {
356+
class FLUpdater {
357+
public function __construct( array $args = [] ) {}
358+
}
359+
}
360+
SHIM_EOF
361+
export WP_DB_HOST="${MYSQL_HOST:-localhost}"
198362
# ── END E2E ────────────────────────────────────────────────────────────────────
199363

364+
echo "--- Starting PHPUnit ---"
200365
${PHPUNIT_BIN} -c ${PHPUNIT_XML}
201-
202366
PHPUNIT_EXIT_CODE=$?
367+
echo "--- PHPUnit finished (exit code ${PHPUNIT_EXIT_CODE}) ---"
203368

204369
service mysql stop
205370

Jenkinsfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@ pipeline {
5252
steps {
5353
archiveArtifacts artifacts: 'release.zip'
5454
archiveArtifacts artifacts: '**/logfile-*'
55-
archiveArtifacts artifacts: '**/playwright-results.xml', allowEmptyArchive: true
5655
}
5756
}
5857

js/app.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ function JobWizard({ isBulkSubmitPage, contentType, contentId, locales, ajaxUrl,
6666
setL2Relations(prev => [...prev, ...refs.filter(r => !prev.some(p => p.contentType === r.contentType && p.id === r.id))]);
6767
}
6868
}
69+
} catch (e) {
70+
// Network error or abort — pendingRequests is decremented in finally
6971
} finally {
7072
setPendingRequests(prev => prev - 1);
7173
}

playwright.config.js

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ if (fs.existsSync(envFile)) {
1717

1818
module.exports = defineConfig({
1919
testDir: 'tests/playwright',
20-
timeout: 30000,
20+
timeout: 120000,
21+
// 4 Playwright workers match the 4 PHP workers (PHP_CLI_SERVER_WORKERS=4).
22+
// Static files are served without PHP (custom router), and REST API calls
23+
// are aborted in beforeEach, so page loads each occupy exactly one PHP worker.
24+
workers: 4,
2125
retries: process.env.CI ? 1 : 0,
2226
reporter: [
2327
['line'],
@@ -26,7 +30,7 @@ module.exports = defineConfig({
2630
use: {
2731
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://test.com',
2832
headless: true,
29-
screenshot: 'only-on-failure',
33+
screenshot: { mode: 'only-on-failure', fullPage: true },
3034
video: 'off',
3135
},
3236
projects: [

0 commit comments

Comments
 (0)