@@ -58,9 +58,6 @@ cp "$LOCAL_GIT_DIR/fix-double-namespace.php" "$NS_WORK/"
5858cd " $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" \
103100php fix-double-namespace.php
104101rm -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-
109103rm -rf " $LOCAL_GIT_DIR /inc/lib"
110104cp -r " $NS_WORK /inc/lib" " $LOCAL_GIT_DIR /inc/"
111105rm -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-
116107cd " $LOCAL_GIT_DIR "
117108
118109svn -q checkout https://plugins.svn.wordpress.org/smartling-connector/trunk trunk
@@ -146,29 +137,120 @@ chmod +x $PHPUNIT_BIN
146137PHPUNIT_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 &
170232WP_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
174256E2E_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).
187299cd " ${LOCAL_GIT_DIR} "
188300NODE_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
195308E2E_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
197317kill ${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-
202366PHPUNIT_EXIT_CODE=$?
367+ echo " --- PHPUnit finished (exit code ${PHPUNIT_EXIT_CODE} ) ---"
203368
204369service mysql stop
205370
0 commit comments