Skip to content

Add legacy WordPress support on PHP 5.2#3490

Open
JanJakes wants to merge 14 commits intotrunkfrom
legacy-wp-support-php-5.2
Open

Add legacy WordPress support on PHP 5.2#3490
JanJakes wants to merge 14 commits intotrunkfrom
legacy-wp-support-php-5.2

Conversation

@JanJakes
Copy link
Copy Markdown
Member

@JanJakes JanJakes commented Apr 14, 2026

Stacked on #3501 (Add PHP 5.2 WebAssembly builds and runtime support). Review that PR first.

Summary

Boot legacy WordPress (1.0 – 6.2) in the browser, with SQLite as the database.

Screenshot 2026-04-15 at 13 30 53

The Playground settings panel gains an "Include older versions" checkbox that exposes every WordPress release from 1.0 through 6.9 (with jazz release code names) and auto-locks the PHP version to whatever runs it.

Replaces and closes #3469 (the PHP 5.6 version of the same feature).

What's in this PR

Builds on the PHP 5.2 WASM runtime from #3501 and adds:

  • Offline PHP 7+ → 5.2 downgrader (scripts/php52-downgrader/) — a nikic/PhpParser pipeline of ~20 visitors that strips modern syntax so arbitrary PHP 7-era files parse on PHP 5.2.
  • SQLite plugin patcher (scripts/patch-sqlite-for-php52.mjs) — produces a PHP 5.2-compatible build of the upstream SQLite integration plugin.
  • Legacy WordPress boot flow (wordpress/src/boot.ts, legacy-wp-fixes.ts, mysql-shims.ts) — legacy-aware installer path, PDO table fallback for WP < 3.5, ~3400 lines of targeted source patches for WP 1.0 – 2.8 parser/SQL quirks, auth/nonce compat, wp-db.php SQLite compatibility, and more.
  • WP 5.0–6.2 on PHP 7.4 — extends the legacy boot to cover the mid-modern range where PHP 8.x is unreliable.
  • Site settings UI — "Include older versions" checkbox + grouped dropdown + automatic PHP locking: WP 1.0 – 4.9 → PHP 5.2, WP 5.0 – 6.2 → PHP 7.4, WP 6.3+ → default.
  • Release code names — WP version picker shows jazz-musician shorthand (e.g. "6.9 (Gene)", "4.9 (Tipton)").
  • TinyMCE content_css fix — inlines editor CSS via content_style to work around TinyMCE's document.open() creating a service-worker-uncontrolled iframe.

Test plan

Automated (Playwright, all 32 legacy versions)

npm run dev
# Wait for http://localhost:5400/website-server/ to be ready
node packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs

Covers front page, single post, admin dashboard, new post page, and plugin activation across every WP minor release from 1.0 to 6.2.

Manual UI walk-through

  1. npm run dev and open http://localhost:5400/.
  2. Open the settings gear → tick Include older versions.
  3. Verify the WP dropdown shows two groups and every release from 1.0 to 6.9.
  4. Pick WP 4.9 → PHP locks to 5.2 (disabled).
  5. Pick WP 5.5 → PHP locks to 7.4 (disabled).
  6. Pick WP 4.8, go to Posts → Add New, verify TinyMCE loads without CSS errors.

🤖 Generated with Claude Code

@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch 5 times, most recently from d2b37ed to a0865df Compare April 15, 2026 11:48
@JanJakes JanJakes marked this pull request as ready for review April 15, 2026 11:52
@JanJakes JanJakes requested review from a team, adamziel, Copilot and mho22 April 15, 2026 11:52
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds first-class legacy WordPress (1.0–4.9) support by introducing a PHP 5.2 WASM runtime, a PHP 7+→5.2 downgrader pipeline for the SQLite integration plugin, and end-to-end boot/install fixes plus UI + CI coverage to select and validate older WordPress versions.

Changes:

  • Added PHP 5.2 runtimes (node + web), wiring them into loaders and version typing (AllPHPVersion).
  • Implemented an AST-based PHP 7+→5.2 downgrader (many visitors + pretty printer) and bundled a prepatched SQLite plugin zip for PHP 5.2.
  • Updated Playground boot flow, worker downloads, and website settings UI to support “Include older versions” + automatic PHP version locking; added Playwright coverage and CI job.

Reviewed changes

Copilot reviewed 82 out of 99 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
tsconfig.base.json Adds TS path aliases for new PHP 5.2 node/web build packages.
scripts/php52-downgrader/src/Visitor/VariadicAndSplatVisitor.php Downgrades variadics and splat calls to PHP 5.2-compatible forms.
scripts/php52-downgrader/src/Visitor/StripTypeDeclarationsVisitor.php Removes PHP 7+ type declarations for PHP 5.2 compatibility.
scripts/php52-downgrader/src/Visitor/ShortTernaryVisitor.php Lowers ?: short ternary to full ternary with temp vars when needed.
scripts/php52-downgrader/src/Visitor/SelfParentStaticRewriter.php Rewrites self/parent/static references when hoisting code out of class scope.
scripts/php52-downgrader/src/Visitor/ReservedMethodRenameVisitor.php Renames reserved/invalid method identifiers to keep output parseable.
scripts/php52-downgrader/src/Visitor/PromoteForHoistedClosuresVisitor.php Promotes member visibility to support hoisted-closure $this access on PHP 5.2.
scripts/php52-downgrader/src/Visitor/Php7ErrorClassesVisitor.php Maps PHP 7 error classes/Throwable to Exception.
scripts/php52-downgrader/src/Visitor/NullsafeVisitor.php Lowers PHP 8 nullsafe operator to ternary + temps.
scripts/php52-downgrader/src/Visitor/NullCoalescingVisitor.php Lowers ?? and ??= into PHP 5.2-compatible forms.
scripts/php52-downgrader/src/Visitor/NamespaceStripVisitor.php Strips namespaces and leading \ in names for PHP 5.2 parsing.
scripts/php52-downgrader/src/Visitor/LateStaticBindingVisitor.php Rewrites static:: to self:: for PHP 5.2 (drops LSB).
scripts/php52-downgrader/src/Visitor/InstanceCallOnNewVisitor.php Rewrites (new Foo())->method/prop access into helper calls.
scripts/php52-downgrader/src/Visitor/FinallyVisitor.php Lowers finally blocks by duplicating finally body into try/catches.
scripts/php52-downgrader/src/Visitor/ExponentVisitor.php Rewrites ** and **= to pow(...).
scripts/php52-downgrader/src/Visitor/DirConstantVisitor.php Forces __DIR__ into dirname(__FILE__) before later rewrites.
scripts/php52-downgrader/src/Visitor/ClosureHoistingVisitor.php Hoists closures/arrow functions into named functions with capture handling.
scripts/php52-downgrader/src/Visitor/ClassKeywordVisitor.php Rewrites ::class magic constant into compatible expressions.
scripts/php52-downgrader/src/Visitor/CallableExprVisitor.php Rewrites complex callable expressions into call_user_func(...).
scripts/php52-downgrader/src/Visitor/AttributeAndDeclareStripVisitor.php Removes PHP 8 attributes and declare(strict_types=1) for PHP 5.2.
scripts/php52-downgrader/src/Visitor/ArrayDerefOnCallVisitor.php Rewrites fn()[0] patterns to helper _pg52_at(...).
scripts/php52-downgrader/src/Visitor/ArrayClassConstantVisitor.php Hoists non-const class constant initializers into static properties + runtime init.
scripts/php52-downgrader/src/PrettyPrinter.php Forces PHP 5.2-friendly printing (long arrays, __DIR__ safety net).
scripts/php52-downgrader/src/Downgrader.php Orchestrates the visitor pipeline + cross-file constant hoist discovery.
scripts/php52-downgrader/composer.json Adds composer package for the downgrader toolchain.
scripts/php52-downgrader/.gitignore Ignores Composer vendor directory for the new tool.
packages/playground/wordpress/tests/test-legacy-wp-version-boot.mjs Adds Playwright test covering boot flows across WP 1.0–4.9.
packages/playground/wordpress/src/mysql-shims.ts Introduces mysql_* stubs for very old WP versions using SQLite.
packages/playground/wordpress/src/boot.ts Adds legacy boot/install flow, network disabling, and safe DB checks for PHP < 7.
packages/playground/wordpress/project.json Adds an Nx target to run the legacy boot test.
packages/playground/wordpress-builds/src/sqlite-database-integration/get-sqlite-driver-module-details.ts Adds module details for the prepatched v2.2.22-php52 SQLite driver asset.
packages/playground/website/src/lib/state/redux/site-management-api-middleware.ts Switches PHP version typing to AllPHPVersion to include legacy versions.
packages/playground/website/src/lib/state/opfs/opfs-site-storage.ts Updates persisted site metadata typing to allow legacy PHP versions.
packages/playground/website/src/components/site-manager/site-settings-form/unconnected-site-settings-form.tsx Adds “Include older versions” toggle + grouped WP dropdown + PHP version locking.
packages/playground/website/src/components/site-manager/site-settings-form/older-wordpress-versions.ts Adds older WP version list and a mapping to forced PHP versions.
packages/playground/remote/src/lib/playground-worker-endpoint.ts Forces single-instance mode for legacy PHP; uses PHP 5.2 MU plugin stub.
packages/playground/remote/src/lib/playground-worker-endpoint-blueprints-v1.ts Enables wordpress.org zip downloads for non-minified versions and selects v2.2.22-php52 for legacy PHP.
packages/playground/remote/src/lib/playground-mu-plugin/playground-includes/wp_http_fetch.php Replaces null coalescing with PHP 5.2-safe isset ternary.
packages/playground/remote/src/lib/playground-mu-plugin/0-playground.php Adds guards for older PHP/WP and avoids PHP 5.4+ array shorthand.
packages/playground/remote/src/lib/playground-mu-plugin/0-playground-php52.php Adds a minimal PHP 5.2-compatible MU plugin stub (dummy HTTP transport + cron disable).
packages/playground/client/src/blueprints-v1-handler.ts Skips update-check prefetch on legacy WordPress versions.
packages/playground/cli/tests/run-cli.spec.ts Adjusts CLI test versions to avoid legacy WP on modern PHP.
packages/playground/cli/src/run-cli.ts Allows selecting legacy PHP via AllPHPVersions and gates extensions/debug defaults for legacy PHP.
packages/playground/cli/src/blueprints-v1/worker-thread-v1.ts Passes phpVersion through to bootWordPress in the CLI worker.
packages/playground/cli/src/blueprints-v1/download.ts Adds ability to pick specific sqlite driver zip variants (incl -php52).
packages/playground/cli/src/blueprints-v1/blueprints-v1-handler.ts Chooses prepatched sqlite driver version for legacy PHP.
packages/playground/blueprints/src/lib/v1/types.ts Updates blueprint PHP version typing to include legacy versions.
packages/playground/blueprints/src/lib/v1/compile.ts Compiles blueprint php version using AllPHPVersions.
packages/playground/blueprints/src/lib/types.ts Updates runtime config PHP typing to AllPHPVersion.
packages/playground/blueprints/public/blueprint-schema.json Adds AllPHPVersion/LegacyPHPVersion to schema to expose PHP 5.2.
packages/php-wasm/web/src/lib/load-runtime.ts Allows loading legacy PHP in web, writes pre-boot php.ini via preRun, and blocks intl for legacy.
packages/php-wasm/web/src/lib/get-php-loader-module.ts Adds loader module resolution for PHP 5.2 web build.
packages/php-wasm/web-builds/5-2/tsconfig.lib.json Adds TS build configuration for PHP 5.2 web build package.
packages/php-wasm/web-builds/5-2/tsconfig.json Adds TS project config for PHP 5.2 web build package.
packages/php-wasm/web-builds/5-2/src/index.ts Adds 5.2 loader that selects JSPI vs Asyncify at runtime.
packages/php-wasm/web-builds/5-2/project.json Adds Nx project/targets for building/publishing PHP 5.2 web binaries.
packages/php-wasm/web-builds/5-2/package.json Adds npm package metadata for @php-wasm/web-5-2.
packages/php-wasm/web-builds/5-2/build.js Adds esbuild-based bundle step for web PHP 5.2 package.
packages/php-wasm/universal/src/lib/supported-php-versions.ts Introduces LegacyPHPVersions, AllPHPVersions, and isLegacyPHPVersion.
packages/php-wasm/universal/src/lib/proxy-file-system.ts Skips PROXYFS mmap patching for legacy PHP to avoid parser corruption.
packages/php-wasm/universal/src/lib/legacy-php-ini.ts Adds pre-boot php.ini content + preRun writer for legacy PHP builds.
packages/php-wasm/universal/src/lib/index.ts Exports new legacy version helpers and legacy php.ini utility.
packages/php-wasm/node/src/lib/load-runtime.ts Enables legacy PHP loading on node; blocks extensions on legacy; adds preRun ini creation.
packages/php-wasm/node/src/lib/get-php-loader-module.ts Adds loader module resolution for PHP 5.2 node build.
packages/php-wasm/node/src/lib/extensions/memcached/get-memcached-extension-module.ts Formatting-only changes in extension module resolution.
packages/php-wasm/node-builds/5-2/tsconfig.lib.json Adds TS build configuration for PHP 5.2 node build package.
packages/php-wasm/node-builds/5-2/tsconfig.json Adds TS project config for PHP 5.2 node build package.
packages/php-wasm/node-builds/5-2/src/index.ts Adds 5.2 loader that selects JSPI vs Asyncify at runtime.
packages/php-wasm/node-builds/5-2/project.json Adds Nx project/targets for building/publishing PHP 5.2 node binaries.
packages/php-wasm/node-builds/5-2/package.json Adds npm package metadata for @php-wasm/node-5-2.
packages/php-wasm/node-builds/5-2/build.js Adds esbuild-based bundle step for node PHP 5.2 package.
packages/php-wasm/compile/php/proc_open.h Adds guards and PHP 5/7-compatible pipe typing for proc_open integration.
packages/php-wasm/compile/php/proc_open.c Adds PHP 5.x stub implementation and PHP 5/7-compatible includes.
packages/php-wasm/compile/php/php_wasm.c Adds PHP 5/7 API compatibility shims for string APIs and SAPI function signatures.
packages/php-wasm/compile/php/apply-mysqlnd-patch.sh Makes mysqlnd patch application conditional by PHP version/file availability.
packages/php-wasm/compile/php/Dockerfile Updates build logic to support PHP 5.x differences (zip/sqlite/curl/imagick/fiber/asm guards, warning flags).
packages/php-wasm/compile/php-wasm-memory-storage/wasm_memory_storage.c Disables custom memory storage implementation for PHP < 7 with no-op init/shutdown.
packages/php-wasm/compile/php-wasm-dns-polyfill/dns_polyfill.c Adds PHP 5/7 signature/parameter parsing compatibility.
packages/php-wasm/compile/php-post-message-to-js/post_message_to_js.c Adds PHP 5/7 parameter parsing and return-string API compatibility.
.github/workflows/ci.yml Adds CI job to boot-test legacy WP versions using Playwright.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread scripts/php52-downgrader/src/Visitor/ClosureHoistingVisitor.php Outdated
Comment thread scripts/php52-downgrader/src/Visitor/ArrayClassConstantVisitor.php Outdated
Comment on lines +276 to +299
private function isPhp52ConstantExpr(Node\Expr $expr): bool
{
if ($expr instanceof Node\Scalar\Int_) {
return true;
}
if ($expr instanceof Node\Scalar\Float_) {
return true;
}
if ($expr instanceof Node\Scalar\String_) {
return true;
}
if ($expr instanceof Expr\ConstFetch) {
$name = $expr->name->toString();
return in_array(
$name,
['true', 'false', 'null', 'TRUE', 'FALSE', 'NULL'],
true
);
}
if ($expr instanceof Expr\UnaryMinus || $expr instanceof Expr\UnaryPlus) {
return $this->isPhp52ConstantExpr($expr->expr);
}
return false;
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The docblock says PHP 5.2 class constants may reference other class constants, but isPhp52ConstantExpr() currently treats all Expr\\ClassConstFetch as non-constant and will hoist them into runtime-initialized static properties. That changes semantics and can break valid constant usages (e.g., switch cases, array keys) that require a real constant. Consider extending isPhp52ConstantExpr() to treat Expr\\ClassConstFetch (at least for non-self/static/parent or other safe cases) as a valid PHP 5.2 constant expression so those constants remain compile-time constants.

Copilot uses AI. Check for mistakes.
Comment on lines +181 to +224
/** Returns the callable expression (first arg to call_user_func_array). */
private function buildCallable(Node $node): ?Expr
{
if ($node instanceof Expr\FuncCall) {
if ($node->name instanceof Node\Name) {
return new Node\Scalar\String_($node->name->toString());
}
if ($node->name instanceof Expr) {
return $node->name;
}
return null;
}
if ($node instanceof Expr\MethodCall) {
$method = $node->name instanceof Node\Identifier
? $node->name->toString()
: null;
if ($method === null) {
return null;
}
return new Expr\Array_([
new Node\ArrayItem($node->var),
new Node\ArrayItem(new Node\Scalar\String_($method)),
]);
}
if ($node instanceof Expr\StaticCall) {
$method = $node->name instanceof Node\Identifier
? $node->name->toString()
: null;
if ($method === null) {
return null;
}
$cls = $node->class instanceof Node\Name
? $node->class->toString()
: null;
if ($cls === null) {
return null;
}
return new Expr\Array_([
new Node\ArrayItem(new Node\Scalar\String_($cls)),
new Node\ArrayItem(new Node\Scalar\String_($method)),
]);
}
return null;
}
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For method/static calls with dynamic names (e.g. $obj->{$m}(...$args) or Foo::{$m}(...$args)), buildCallable() currently returns null, which causes rewriteSplatCall() to bail out and leave the unpack (...) syntax in the output—resulting in PHP 5.2 parse errors. Instead of returning null for dynamic identifiers, consider building a callable array with the method expression as-is, and for StaticCall also allow $node->class forms beyond Node\\Name (e.g. variables) when they can be passed through to call_user_func_array.

Copilot uses AI. Check for mistakes.
Comment thread scripts/php52-downgrader/src/Visitor/SelfParentStaticRewriter.php Outdated
Comment thread packages/php-wasm/node/src/lib/load-runtime.ts
Comment thread packages/playground/wordpress/src/boot.ts Outdated
Comment thread packages/playground/wordpress/src/boot.ts Outdated
Comment thread scripts/php52-downgrader/src/Visitor/ClosureHoistingVisitor.php Outdated
Comment thread packages/php-wasm/universal/src/lib/proxy-file-system.ts
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from a0865df to efa1168 Compare April 15, 2026 20:25
@JanJakes JanJakes changed the base branch from trunk to php-5.2-wasm April 16, 2026 11:51
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch 2 times, most recently from f4e6cbd to 91853e3 Compare April 16, 2026 12:08
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from 91853e3 to 5b6a80c Compare April 16, 2026 12:18
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from 5b6a80c to a91d005 Compare April 16, 2026 12:30
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from a91d005 to 106086f Compare April 16, 2026 12:44
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from 106086f to c1e7651 Compare April 16, 2026 15:47
Base automatically changed from php-5.2-wasm to trunk April 16, 2026 16:13

/**
* Network I/O functions that must be disabled on legacy PHP builds
* (< 7) to avoid "null function or function signature mismatch"
Copy link
Copy Markdown
Collaborator

@adamziel adamziel Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isn't this the problem that EMULATE_FUNCTION_POINTER_CASTS was solving? This isn't a blocker here, but a functional plugin directory in WP Admin would be very cool.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude says:

Short answer: yes and no — two layers at play.

  1. The WASM signature-mismatch traps are the same class EMULATE_FUNCTION_POINTER_CASTS used to paper over. The project dropped that flag a while back in favor of specific C-level patches (proc_open, dns_polyfill, post_message_to_js — there's a note in php-part-1.spec.ts by the proc_open test). PHP 5.2 gets those extensions compiled in too, but its fsockopen/cURL paths still trap — hence the disable_functions stopgap here.

  2. Even without those traps, HTTP on PHP 5.2 wouldn't work: 0-playground-php52.php registers Wp_Http_Dummy (returns false for every request) in place of the Fetch transport, because wp_http_fetch.php uses PHP 5.3+ namespace syntax and won't parse on 5.2.

So a functional plugin directory on 5.2 would need a 5.2-compatible wp_http_fetch.php (the PHP 5.2 downgrader could probably produce one) wired up in 0-playground-php52.php, not just a compile flag. Agreed it's a cool follow-up — not blocking here.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I mean, what stops us from having a custom transport? Also, fsockopen wouldnt go through that transport anyway

Comment thread packages/playground/wordpress/src/boot.ts Outdated
Comment on lines +349 to +350
}
if (phpMajor < 7) {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
}
if (phpMajor < 7) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems like we don't need a separate if here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a6d2e9a — the legacy branch is now a separate bootLegacyWordPress() in legacy-wp/legacy-boot.ts, so the modern bootWordPress no longer carries any legacy-specific if sandwich.

] as const;

/**
* Minimal wp-content/db.php drop-in for modern WordPress. Its job
Copy link
Copy Markdown
Collaborator

@adamziel adamziel Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't have that today, right? Why is this needed after this PR? Would this create an unwanted db.php file for Studio users? Let's add a test for Playground CLI to ensure that booting a site with a custom wp-content mounted doesn't create any new files in that wp-content to make sure we never regress on this.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 537d860 (dropped the modern-WP db.php placeholder — the SQLite preload doesn't need a drop-in at all) and 4f967a9 (new CLI regression test mounts a host wp-content and asserts Playground doesn't drop db.php, object-cache.php, advanced-cache.php, sunrise.php, or any other file at its root).

{ phpVersion: options.phpVersion }
);

// Write wp-content/db.php. Two distinct WordPress code paths
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIR Playground doesn't create that file today. Let's avoid creating it except in legacy WP versions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 537d860 — the modern boot path no longer writes any db.php. A drop-in is still written, but only by the legacy boot path (legacy-wp/legacy-boot.ts).

);
}

// WordPress 5.0–6.1's `wp_check_php_mysql_versions()` runs
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's avoid this on WP 6.2+

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a6d2e9abackportWpPreV62MysqlCheck reads the on-disk $wp_version and short-circuits when it's ≥ 6.2 (also < 5.0, since those go through the legacy boot path instead).

* include definitions for some of the necessary constants.
*/
await ensureWpConfig(php, requestHandler.documentRoot);
const phpMajor = Number.isFinite(parseInt(options.phpVersion ?? '', 10))
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The legacy logic clutters this function a lot, and it's such a niche use-case. Is there any way we could retain a mostly uncluttered boot logic for modern WP versions? As in move that legacy-specific code and large doc blocks out of this function and reduce that to a few small calls. Or even have a separate boot function for legacy WordPresses.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a6d2e9a — the legacy branches in bootWordPress moved to a dedicated bootLegacyWordPress under legacy-wp/legacy-boot.ts. The modern entry point is now a single if (isLegacyPHPVersion(...)) return bootLegacyWordPress(...) dispatch followed by the unchanged modern flow.

);
if (!hasCustomDatabasePath) {
await assertValidDatabaseConnection(requestHandler);
await assertValidDatabaseConnectionSafe(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need assertValidDatabaseConnectionSafe and assertValidDatabaseConnection? Could we keep using assertValidDatabaseConnection as the only function identifier?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a6d2e9a — the *Safe variant is gone; bootWordPress uses the original assertValidDatabaseConnection, and the legacy-specific relaxation lives inside bootLegacyWordPress.

@@ -0,0 +1,145 @@
/**
* PHP code that provides functional mysql_* function stubs.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is cool. Later on this would make sense as a part of @php-wasm/universal as a general mysql shim. Or maybe as a part of the composer packages we'll release for the SQLite plugin? Not sure. Nothing blocking here.

@@ -0,0 +1,3396 @@
/**
* Legacy WordPress version fixes.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wat. This actually works for a large range of WordPress versions? amazing :D

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest version-specific .patch files or, at least, version checks before the replacements here. But this seems to be actually working as is. Scary! This just runs blind replacements without worrying about the WP version – how do we know we're not patching the wrong WordPress version? We probably don't. Does it matter? If everything works, probably not. Those WP versions won't change, and patching this might break the delicate equilibrum we have here. This is terrible. I love it. Let's document that in this doc block.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 76de744 — added a top-of-file doc block in legacy-wp/legacy-fixes.ts spelling out the blind find-and-replace approach, why it's load-bearing on frozen legacy WP releases, and pointing at the boot-smoke suite as the safety net before touching patches there.

* @param php
*/
export async function setupPlatformLevelMuPlugins(php: UniversalPHP) {
export async function setupPlatformLevelMuPlugins(
Copy link
Copy Markdown
Collaborator

@adamziel adamziel Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Legacy handling adds a bunch of clutter. How much can we decouple these things? Could we contain most of these things in like a legacy/ subdirectory or even a single legacy.ts file and then just call them from other places? I'd love the main code paths to remain very focused on the modern WP versions and only have tactical mentions of the legacy flows that can be inspected if needed but that would mostly stay out of our way when we work with this code.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a6d2e9a — all legacy-specific code now lives under packages/playground/wordpress/src/legacy-wp/ (legacy-boot.ts, legacy-fixes.ts, legacy-mu-plugins.ts, legacy-sqlite-preload.ts, mysql-shims.ts). The main boot path only references it through the bootLegacyWordPress dispatch.

Copy link
Copy Markdown
Collaborator

@adamziel adamziel left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ideas are great. Even the terrible ones are impressive. Let's just cleanup the main data flows. Human attention is limited and the legacy code blocks are really large. Let's make sure we don't have to always sieve through them when we work with Playground's main data flows – we won't need them most of the time.

The SQLite database integration plugin uses PHP 7+ features that PHP
5.2 can't parse (type declarations, closures, `??`, short arrays,
traits, class constant expressions, namespaces, etc.). Rather than
maintaining a frozen, hand-patched binary blob that has to be
regenerated by eye on every upstream release, this commit adds the
tooling to rebuild it deterministically offline.

Components:

* `scripts/patch-sqlite-for-php52.mjs` — the offline patcher. Unzips
  the upstream v2.2.22 plugin to a temp dir, runs the AST downgrader,
  applies a short list of per-file surgical fixes for shapes that
  can't live in a generic visitor, and re-zips to the final output.
  Requires a host PHP 7.4+ and `composer` on PATH; auto-runs
  `composer install` in the downgrader directory the first time.

* `scripts/php52-downgrader/` — standalone PHP tool on nikic/php-parser
  v5. `composer.json` + `composer.lock` pin the dependency; vendor
  files are gitignored. 21 `NodeVisitor`s cover every 7+ → 5.2
  syntactic transform: type declarations, `??`, `?->`, `?:`, `**`,
  `[...]`, variadics, `X::class`, `static::`, `__DIR__`, `fn()[n]`,
  `(new X())->y`, PHP 7 error classes, `#[Attributes]`,
  `declare(strict_types)`, fully-qualified names, namespace
  stripping, reserved method renames, `finally`, array class
  constants.

* `ClosureHoistingVisitor` lifts every `Closure` / `ArrowFunction` to
  a named top-level function. `use(...)` captures become extra
  parameters or `$GLOBALS`-backed captures; bodies that reference
  `$this` are rewritten to a renamed `$__pg_this` parameter; the
  host class is opted into public member visibility by
  `PromoteForHoistedClosuresVisitor` so the hoisted function can
  reach private/protected members.

* Every `self::X` / `parent::X` reference inside a hoisted expression
  or an extracted class-constant array is rewritten to the literal
  class name (or the parent class name from the `extends` clause).

* Surgical fallbacks in `patch-sqlite-for-php52.mjs` handle trait
  inlining in `class-wp-pdo-proxy-statement`, `ReflectionProperty`
  try/catch wraps, `WP_SQLite_DB` polyfill injection, `array_column`
  polyfill, `Closure::call` proxies in `WP_SQLite_Driver`, and a
  handful of WP function-existence guards.

* `bin/scan-out-of-class-self.mjs` is a deterministic post-pass that
  walks the pretty-printed output and fails the build if any
  `self::`/`parent::`/`static::` token appears outside a class body.
  Catches scope-leak regressions that runtime-fatal on PHP 5.2 but
  slip past a plain parse check.

Committed artifacts:

* `sqlite-database-integration-v2.2.22.zip` — unpatched upstream.
* `sqlite-database-integration-v2.2.22-php52.zip` — downgraded output
  so downstream consumers (website, CLI) don't need PHP or composer
  at consumption time.
* `get-sqlite-driver-module-details.ts` — updated to expose the new
  `v2.2.22-php52` plugin variant.

Verified with `playground-wordpress` unit tests (80/80 green) and
`test-legacy-wp-version-boot.mjs`: WP 4.9 → 1.0 on PHP 5.2, all five
phases (front / post / admin / new post / plugin activation) pass on
every version that supports each phase.
Port complete legacy WordPress (1.0–4.9) boot support from the
legacy-wordpress-support-v2 branch, adapted for PHP 5.2 only.

boot.ts: Legacy PHP boot flow with source patching, safe install
and validation wrappers, post-install fixups for WP versions that
lack standard install infrastructure, and a `runDbDeltaOnly` path
that gates the PDO fallback to `wp_version < 3.5` so WP 3.5+ boots
cleanly through the AST SQLite driver without getting WP 1.x-shaped
tables written behind the driver's back.

index.ts: Legacy-aware mu-plugin setup with PHP 4 superglobal
polyfills, eval()-based preload for parser-incompatible files,
WordPress version-conditional auth bypass, and an error handler
adapted for PHP 5.2 syntax. Auto-login always takes the in-process
cookie path (PLAYGROUND_SKIP_AUTO_LOGIN_REDIRECT unconditionally
defined) because redirect+Set-Cookie is unreliable on PHP 5.2 WASM
— wp_set_auth_cookie()'s headers don't consistently reach the
Playground service worker's cookie store across all WP versions.

legacy-wp-fixes.ts: ~3000 lines of WP source patches covering:
- wp-settings.php deprecated function removal
- wp-db.php SQLite compatibility
- install.php schema fixes for PDO/SQLite
- WP 1.0–2.8 specific parser and SQL fixes
- Auth cookie and nonce compatibility for WP < 4.5
- Pretty permalink support for legacy WP
- comment_count backfill on wp_posts via $alter_cols (WP 1.5+
  reads wp_posts.comment_count directly in get_comments_number())

mysql-shims.ts: MySQL function stubs delegating to $wpdb for WP < 3.0.

Add CI job and test script for legacy WP version boot testing.
Three wordpress.org-downloaded WP bootstrap issues were silently
breaking modern WP 5.0–6.1 on the SQLite integration:

1. wp_check_php_mysql_versions() on WP 5.0–6.1 hard-checks
   extension_loaded('mysqli'), which a userland stub cannot
   satisfy. Patch wp-includes/load.php to use
   function_exists('mysqli_connect') instead — matching the fix
   WordPress itself shipped in 6.2. No-op on 6.2+.

2. install.php step=2 has its own mysql version check that falls
   through on file_exists(WP_CONTENT_DIR . '/db.php'). Write a
   minimal @playground-managed db.php drop-in for modern WP so
   that check passes; teach the preload guard to recognise our
   own marker and not self-skip on its own file.

3. The SQLite plugin declares private \$allow_unsafe_unquoted_parameters
   on WP_SQLite_DB and then calls \$this->__get() from prepare(),
   expecting wpdb::__get() to return the parent's property. That
   works on WP 6.2+ (wpdb declares the same property upstream)
   but triggers a silent fatal Error on older WordPress, where
   wpdb's __get runs \`return \$this->\$name;\` from the parent
   class context and PHP refuses to read a child's private
   member. Patch the declaration to protected so both class
   contexts can reach it.

Additionally, WP 5.0 (Gutenberg 1.0) crashes the runtime with
exit code 255 inside prefetchUpdateChecks when using the modern
SQLite driver, so extend the prefetch skip range from < 5 to
< 5.1.

Test script:

- Convert flat WP_VERSIONS list to a {wp, php} matrix so the
  same script can cover both the legacy (WP 1.0–4.9 + PHP 5.2)
  and modern (WP 5.0–6.2 + PHP 7.4) ranges.
- Add WP_ONLY env filter for targeted local runs.
- Editor-markers check now also recognises Gutenberg
  (id="editor", edit-post-layout, block-editor-writing-flow)
  so WP 5.0+ post-new.php passes the newPost phase.
- Plugin phase targets Hello Dolly by href instead of the first
  Activate link, which on modern WP is Akismet and lands on a
  setup page without the expected confirmation strings.

All 13 modern versions (5.0–6.2) now pass all 5 phases locally,
and the legacy range (4.9, 3.5, 2.0, 1.2 spot-checked) is
unchanged.
Replace the "Need an older version?" link in the Playground settings
panel with a proper "Include older versions" checkbox. When checked,
the WordPress version dropdown lists every minor release from 1.0
through the current minified set, grouped into "Current versions"
(the minified 6.3 – 6.9 + trunk/beta bucket) and "Older versions"
(non-minified 6.2 back to 1.0) with disabled separator rows.

When the user picks an older version, the PHP version dropdown is
automatically locked to whatever runs that WordPress:

* WP 1.0 – 4.9 → PHP 5.2 (the only pre-7 WASM build we ship).
* WP 5.0 – 6.2 → PHP 7.4 (safest single choice across the whole
  bucket; PHP 8.x is unreliable on WP 5.0 – 5.5).
* WP 6.3+ → no lock, default stays RecommendedPHPVersion.

Implementation notes:

* Widen SiteFormData.phpVersion (and the redux SiteSettings /
  opfs-site-storage schema) from SupportedPHPVersion to
  AllPHPVersion so 5.2 is representable end-to-end.
* The PHP Controller is disabled via SelectControl's own `disabled`
  prop, not react-hook-form's Controller.disabled — the latter nulls
  out the field value and breaks the unlock transition.
* When the user unlocks from a legacy-locked state (e.g. 4.9 → 6.5),
  the effect resets phpVersion to RecommendedPHPVersion so the
  dropdown doesn't render a stale 5.2 that isn't in its option list.
* The checkbox auto-initializes to `true` when the form loads with
  an already-older wpVersion, so bookmarking `?wp=4.9&php=5.2`
  shows the right state without the user toggling anything.
* older-wordpress-versions.ts holds the hardcoded WP release list
  plus getForcedPhpVersionForWordPress() / isOlderWordPressVersion()
  helpers.
Show each WordPress release's jazz-musician shorthand next to its
number in the Site Settings version picker (e.g. "6.9 (Gene)",
"4.9 (Tipton)"). Labels without a matching release — trunk, beta,
and pre-release majors — stay unchanged.

The mapping uses the single-word shorthand each release's
announcement URL on wordpress.org/news carries (first name, last
name, or nickname, depending on the release).
TinyMCE loads its content_css (editor styles, dashicons, theme
CSS) via <link> tags inside an about:blank iframe it creates for
the editing area. The Playground service worker cannot intercept
sub-resource requests from about:blank documents, so those CSS
files return 404 — visible as "Failed to load content css" in
TinyMCE's notification bar. This affects all WP versions that
use TinyMCE (3.9–4.9, and modern WP with Classic Editor) on
both localhost and 127.0.0.1 with a fresh browser context.

Add a mu-plugin that hooks tiny_mce_before_init, reads each
content_css file from disk, and passes the combined CSS inline
via TinyMCE's content_style setting. This bypasses the network
request entirely. Written in PHP 5.2-compatible syntax so it
works across all supported PHP versions.
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from 991109b to df5c21e Compare April 17, 2026 07:13
Everything a legacy WordPress / legacy PHP setup needs now lives
under packages/playground/wordpress/src/legacy-wp/:

  * legacy-boot.ts — bootLegacyWordPress(), the self-contained
    entry point mirroring bootWordPress's step ordering for PHP
    5.2 + WP 1.0–4.9 on SQLite. Also hosts applyLegacyPhpIniOverrides
    (called from bootRequestHandler).
  * legacy-fixes.ts — WordPress source-file patches
    (patchWordPressSourceFiles, backportWpPreV62MysqlCheck),
    generateDbPhpContent, runPostInstallLegacyFixups, and the
    LEGACY_WP_ERROR_REPORTING_* constants.
  * mysql-shims.ts — PHP shims for mysql_* / mysqli_* APIs that
    old WP expects.

boot.ts stays modern-first: a single
`if (isLegacyPHPVersion(options.phpVersion)) return bootLegacyWordPress(...)`
dispatches at the top of bootWordPress, and the body has zero
legacy conditionals. assertValidDatabaseConnection no longer takes
a phpVersion param — only the modern path reaches it. The
installWordPressSafe + assertValidDatabaseConnectionSafe wrappers
are gone; each boot path owns its own error handling.

Also:

  * Gate backportWpPreV62MysqlCheck explicitly to WP 5.0–6.1 (renamed
    from patchLegacyMysqlCheckForModernWp); skip the placeholder
    db.php write on WP 6.2+ so modern trunk behaviour is preserved.
  * Replace parseInt(phpVersion, 10) < 7 checks with
    isLegacyPHPVersion() so non-numeric values like 'latest' never
    slip into the legacy branch.
…lder

Extract every legacy mu-plugin and SQLite preload setup from
index.ts into dedicated files under legacy-wp/:

  * legacy-mu-plugins.ts — setupLegacyPlatformLevelMuPlugins,
    LEGACY_AUTO_LOGIN_BODY, legacy preload env.php, PHP 5.2
    error handler, legacy auto_prepend_file.
  * legacy-sqlite-preload.ts — preloadLegacySqliteIntegration,
    buildLegacySqlitePreload, mysql*/mysqli* stubs, str_*
    polyfills, WP < 3.1 add_action() guard.

Shared helpers live in neutral files at src/ root so legacy-wp/
doesn't import from the parent index.ts:

  * platform-mu-plugins.ts — writeCommonPlatformMuPlugins
    (0-playground.php, sitemap-redirect.php,
    inline-tinymce-content-css.php), called from both paths.
  * sqlite-preload-loader.ts — SQLITE_PRELOAD_LOADER_CLASS,
    referenced by both modern and legacy preload builders.

setupPlatformLevelMuPlugins and preloadSqliteIntegration now
dispatch to the legacy helpers at the top and their bodies carry
no phpMajor / isPhp52 branches. index.ts goes from 1521 lines to
691.

Also drop the WP 5.0–6.1 db.php placeholder.
backportWpPreV62MysqlCheck used to write an empty
wp-content/db.php so install.php step=2's \$mysql_compat check
would fall through its file_exists(WP_CONTENT_DIR . '/db.php')
escape hatch. The placeholder was never needed —
WP_SQLite_DB::db_version() returns '8.0' (per the SQLite plugin's
docstring: "it returns mysql version number, but it means nothing
for SQLite. So it returns the newest mysql version"), which
satisfies install.php's version_compare check directly via the
lazy-\$wpdb loader. With this change no file is ever written to
wp-content on the modern boot path.
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from 30d1c77 to c6241c1 Compare April 17, 2026 13:06
Reviewed every patch in legacy-wp/legacy-fixes.ts (and supporting files)
for need, gating tightness, and comment clarity. Net: 4828 → 3637 lines.
Verified WP 1.0–4.9 + PHP 5.2 still boot end-to-end.

Notable changes:
- Drop ensureLegacyAdminAuth — duplicated the auto-login flow already
  provided by legacy-mu-plugins.ts.
- Drop the wp_check_mysql_version no-op — mysql-shims.ts already
  returns '8.0.0' from mysql_get_server_info.
- Drop the dead overrides array in patchWpAdminRelativePaths — the
  generic regex pass already covers every entry.
- Drop duplicate mysql_/mysqli_/str_* stubs from generateDbPhpContent —
  the 0-sqlite.php preload runs first and already defines them.
- Promote `mail` to LEGACY_PHP_DISABLED_NETWORK_FUNCTIONS so it's
  blocked across the whole boot lifecycle, and drop the wp_mail no-op
  in patchWpInstallMailCrash (PHPMailer's SMTP fallback also dies on
  the disabled fsockopen, so it now fails safely with a WP_Error).
- Drop the dead collapseWp1xInstallerSteps helper — install.php is
  never reached during legacy boot, and runPostInstallLegacyFixups
  seeds the admin user via PDO so a manual visit short-circuits to
  "Already Installed".
- Drop the dead `if (!\$_pg_skip_redirect)` redirect branches from all
  three eras of LEGACY_AUTO_LOGIN_BODY — playground_load_mu_plugins
  unconditionally defines PLAYGROUND_SKIP_AUTO_LOGIN_REDIRECT before
  invoking the body in legacy contexts.
- Restrict playground_legacy_set_auth_cookies_early to WP < 2.5; the
  wordpressuser_* cookies it sets are inert on later versions.
- Share legacyAuthCookieBlock(usernameExpr) between
  patchAdminAuthRedirect and patchAdminAjaxAuth; replace their brittle
  wp-settings.php text scans with readOnDiskWpVersion gates.
- Move every WP-version-specific gate out of the patch functions and
  into patchWordPressSourceFiles — the orchestrator now reads the WP
  version once and dispatches each patch only when its range matches.
  The previously needle-only patches (e.g. patchWp10*, patchWp21*,
  patchCheckAdminReferer) get explicit ranges too, so they short-circuit
  at the front door instead of reading files they can't apply to.
- Replace the fragile one-level-nesting regex in patchCheckAdminReferer
  with a reusable balanced-brace helper (replacePhpFunctionBody), reused
  by the "not installed" die() and the do_action('init') rewrite
  extracted out of patchWpSettingsPhp.
- Fix patchWpSettingsPhp bug where settingsChanged was set
  unconditionally inside the error_reporting block, rewriting
  wp-settings on every boot.
- Split the PHP 5.2 vs 5.3+ error-handler boilerplate in
  legacy-mu-plugins.ts into named helpers instead of inline ternaries.
- Trim multi-paragraph "## The bug / ## The fix" docblocks to a single
  why-sentence each, dropping embedded WP source samples and
  indentation diagrams.
Spell out in the top-of-file doc block that the legacy patches run
as plain, idempotent string replacements without parsing PHP, why
this relies on legacy WP releases being frozen, and point at the
boot-smoke suite as the safety net for changes here.
Guards against Playground writing its own db.php, object-cache.php,
advanced-cache.php, or sunrise.php into a user-mounted wp-content.
Studio and similar consumers mount real wp-content directories
into Playground, and a stray Playground-managed drop-in there would
silently take over the user's external site.
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from 981c11d to 4f967a9 Compare April 17, 2026 14:45
Two targeted changes that remove ~2/3 of the per-version wall time:

- Drop waitForWPFrame's 3 s poll granularity down to 500 ms, so the
  loop reflects how fast Playground actually responds instead of
  always burning whole 3 s ticks.
- Replace the two unconditional waitForTimeout(8000) waits after
  post-click (Phase 2) and plugin-activate click (Phase 5) with an
  event-based wait: waitForWPFrame now accepts `excludeUrl` (skip
  the scoped frame while its URL still matches the pre-click value)
  and `contentPredicate` (require specific outcome text before
  returning, so we don't race the intermediate admin shell between
  POST and post-redirect render).

Phase 5 also explicitly waits for any Activate link to become
visible before selecting one — navigateViaUrlBar returns as soon
as plugins.php has any body text, which on slow CI boots can be
just the admin shell before the plugin list is populated.

Local run on 7 representative versions (6.2, 6.1, 6.0, 5.9, 5.8,
4.9, 3.2) goes from ~33 s/version to ~10 s/version, all 5 phases
pass including the ones that flaked under the prior code.
@JanJakes JanJakes force-pushed the legacy-wp-support-php-5.2 branch from 490d1fb to d7c2cd4 Compare April 17, 2026 15:21
The SQLite integration plugin ships with MySQL 8 strict defaults
(NO_ZERO_DATE + STRICT_TRANS_TABLES + ONLY_FULL_GROUP_BY), but
legacy WordPress (1.0–4.9, contemporary with MySQL 4.1–5.6) was
written against empty or near-empty sql_mode defaults. Three
`patchWp*ZeroDate*` helpers were previously patching every
wp_insert_post() variant that emits '0000-00-00 00:00:00' for
drafts, just to make those INSERTs pass.

Reset the driver's default `active_sql_modes` to `array()` once
during SQLite preload for legacy PHP boots. One regex on the
driver source, two source variants (standard multi-line and the
php52-downgraded single-line form) covered by the same needle.
WP 3.9+ already achieves the same effect at runtime via
wpdb::set_sql_mode(), so the relaxed driver default is a no-op
there and strictly an improvement for WP < 3.9.

Net: -213 lines in legacy-fixes.ts (three patch functions +
their needles), +39 in legacy-sqlite-preload.ts. Smoke test
across WP 1.5 / 2.1 / 2.5 / 2.8 / 3.2 / 3.5 / 4.0 / 4.1 all
pass on PHP 5.2.
Phase 3 (wp-admin dashboard load via URL bar) has been flaking on
shared CI runners for one random modern-WP version per run — 5.5,
5.9, 6.0 across recent runs, always with the same empty-detail
`admin [TIMEOUT]` after the full 120 s navigateViaUrlBar budget.
The iframe gets stuck partway into the first /wp-admin/ load and
never progresses; a fresh URL-bar fill+Enter almost always unsticks
it on the second try.

Retry once on timeout inline. Cheap and keeps real admin failures
visible — the retry only kicks in when the first attempt returned
null, so a genuine admin-page error still reports as the specific
failure mode (ERROR / UNKNOWN) rather than getting retried.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants