From 9c6ef1988fba94d754b13e9bf48746ecc3f80f97 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 03:16:25 +0000 Subject: [PATCH 1/7] fix: preserve deferred WordPress hook lifecycle --- .../src/phpunit-command-handlers.ts | 35 ++++++++++- tests/phpunit-project-autoload.test.ts | 59 +++++++++++++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index d1d1e140..ad8008b2 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -537,16 +537,34 @@ function pg_defer_new_wordpress_hook_callbacks(string $hook_name, array $before) } function pg_run_deferred_wordpress_hook_callbacks(array $deferred, array $args = array(), ?string $hook_name = null): void { - global $wp_current_filter; + global $wp_filter, $wp_current_filter; $pushed_hook = false; + $original_hook = null; + $replay_hook = null; if (is_string($hook_name) && $hook_name !== '') { if (!is_array($wp_current_filter)) { $wp_current_filter = array(); } $wp_current_filter[] = $hook_name; $pushed_hook = true; + $original_hook = $wp_filter[$hook_name] ?? null; + $replay_hook = new WP_Hook(); + $wp_filter[$hook_name] = $replay_hook; + foreach ($deferred as $entry) { + $callback = $entry['callback'] ?? null; + if (!is_array($callback) || !isset($callback['function'])) { + continue; + } + $priority = isset($entry['priority']) ? (int) $entry['priority'] : 10; + $accepted_args = isset($callback['accepted_args']) ? (int) $callback['accepted_args'] : count($args); + add_filter($hook_name, $callback['function'], $priority, $accepted_args); + } } try { + if ($replay_hook instanceof WP_Hook) { + $replay_hook->do_action($args); + return; + } foreach ($deferred as $entry) { $callback = $entry['callback'] ?? null; if (!is_array($callback) || !isset($callback['function'])) { @@ -556,6 +574,21 @@ function pg_run_deferred_wordpress_hook_callbacks(array $deferred, array $args = call_user_func_array($callback['function'], array_slice($args, 0, $accepted_args)); } } finally { + if ($replay_hook instanceof WP_Hook && is_string($hook_name)) { + $retained_callbacks = $replay_hook->callbacks; + if ($original_hook instanceof WP_Hook) { + $wp_filter[$hook_name] = $original_hook; + } else { + unset($wp_filter[$hook_name]); + } + foreach ($retained_callbacks as $priority => $callbacks) { + foreach ($callbacks as $callback) { + if (isset($callback['function'])) { + add_filter($hook_name, $callback['function'], (int) $priority, (int) ($callback['accepted_args'] ?? 0)); + } + } + } + } if ($pushed_hook) { array_pop($wp_current_filter); } diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index 34391670..e0b85a15 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -65,6 +65,63 @@ function phpString(value: string): string { return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'` } +function assertDeferredHookReplayUsesWordPressLifecycle(source: string): void { + const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-deferred-hook-")) + const scriptPath = join(tempDir, "assert-deferred-hook.php") + writeFileSync(scriptPath, `callbacks[$priority][$id] = array('function' => $callback, 'accepted_args' => $accepted_args); + } + public function do_action($args): void { + $completed = array(); + while (true) { + ksort($this->callbacks); + $next = null; + foreach ($this->callbacks as $priority => $callbacks) { + foreach ($callbacks as $id => $callback) { + $key = $priority . ':' . $id; + if (!isset($completed[$key])) { + $next = array($key, $callback); + break 2; + } + } + } + if ($next === null) return; + $completed[$next[0]] = true; + call_user_func_array($next[1]['function'], array_slice($args, 0, $next[1]['accepted_args'])); + } + } +} +function add_filter($hook, $callback, $priority = 10, $accepted_args = 1): bool { + global $wp_filter; + if (!isset($wp_filter[$hook])) $wp_filter[$hook] = new WP_Hook(); + $wp_filter[$hook]->add_filter($hook, $callback, $priority, $accepted_args); + return true; +} +function add_action($hook, $callback, $priority = 10, $accepted_args = 1): bool { + return add_filter($hook, $callback, $priority, $accepted_args); +} +${extractPhpFunction(source, "pg_run_deferred_wordpress_hook_callbacks")} +$events = array(); +$wp_current_filter = array(); +$wp_filter = array('init' => new WP_Hook()); +add_action('init', function() use (&$events) { $events[] = 'core-must-not-replay'; }, 5, 0); +$early = function() use (&$events) { + $events[] = 'early'; + add_action('init', function() use (&$events) { $events[] = 'late'; }, 15, 0); +}; +pg_run_deferred_wordpress_hook_callbacks(array(array('priority' => 0, 'callback' => array('function' => $early, 'accepted_args' => 0))), array(), 'init'); +if ($events !== array('early', 'late')) throw new RuntimeException('deferred init order was not faithful: ' . json_encode($events)); +if (count($wp_filter['init']->callbacks, COUNT_RECURSIVE) < 6) throw new RuntimeException('replayed callbacks were not retained'); +if ($wp_current_filter !== array()) throw new RuntimeException('current filter stack leaked'); +echo "ok\n"; +`) + assert.equal(execFileSync("php", [scriptPath], { encoding: "utf8" }), "ok\n") +} + function assertPhpunitConfigurationAndDiscoveryFailures(source: string, functionName: string, discoveryFunctionName: string, logFunctionName: string, supportsImplicitFallback: boolean): void { const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-config-")) const malformedXml = join(tempDir, "phpunit.xml.dist") @@ -663,6 +720,8 @@ const managedModeCode = phpunitRunCode({ databaseType: "sqlite", }) +assertDeferredHookReplayUsesWordPressLifecycle(managedModeCode) + assert.ok(managedModeCode.includes("configured PHPUnit harness autoload file is not readable")) assert.ok(managedModeCode.includes("define('DB_NAME', ':memory:');"), "default managed PHPUnit remains on SQLite") assert.ok(managedModeCode.includes("'cacheResult' => false")) From 42f44893ecf78c7a1658d16a20b0522ab3a3989d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 04:12:14 +0000 Subject: [PATCH 2/7] fix: replay fallback init callbacks --- packages/runtime-playground/src/phpunit-command-handlers.ts | 4 +++- tests/playground-phpunit-readonly-cache.integration.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index ad8008b2..dd4b81a0 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -1160,8 +1160,10 @@ tests_add_filter('muplugins_loaded', function () use ($plugin_slug, $plugin_path pg_run_install_stage(array('config_path' => $config_path, 'tests_dir' => $tests_dir, 'multisite' => $multisite)); pg_remove_new_wordpress_hook_callbacks('shutdown', $pre_component_shutdown_callbacks); $pre_dependency_plugins_loaded_callbacks = pg_snapshot_wordpress_hook_callbacks('plugins_loaded'); +$pre_dependency_init_callbacks = pg_snapshot_wordpress_hook_callbacks('init'); $loaded_dep_files = pg_run_load_deps_stage(array('dep_mounts' => $dep_mounts)); $deferred_dependency_plugins_loaded_callbacks = pg_defer_new_wordpress_hook_callbacks('plugins_loaded', $pre_dependency_plugins_loaded_callbacks); +$deferred_dependency_init_callbacks = pg_defer_new_wordpress_hook_callbacks('init', $pre_dependency_init_callbacks); $activation_files = $loaded_dep_files; if ($loaded_component_file !== null) { $activation_files[] = $loaded_component_file; @@ -1173,7 +1175,7 @@ $reopened_ability_categories_init = pg_reopen_wordpress_action('wp_abilities_api $reopened_ability_init = pg_reopen_wordpress_action('wp_abilities_api_init'); pg_run_deferred_wordpress_hook_callbacks($deferred_install_plugins_loaded_callbacks, array(), 'plugins_loaded'); pg_run_deferred_wordpress_hook_callbacks($deferred_dependency_plugins_loaded_callbacks, array(), 'plugins_loaded'); -$deferred_install_init_callbacks = array_merge($deferred_install_init_callbacks, pg_defer_new_wordpress_hook_callbacks('init', $pre_replayed_plugins_loaded_init_callbacks)); +$deferred_install_init_callbacks = array_merge($deferred_install_init_callbacks, $deferred_dependency_init_callbacks, pg_defer_new_wordpress_hook_callbacks('init', $pre_replayed_plugins_loaded_init_callbacks)); usort($deferred_install_init_callbacks, static function (array $left, array $right): int { return ($left['priority'] ?? 10) <=> ($right['priority'] ?? 10); }); diff --git a/tests/playground-phpunit-readonly-cache.integration.test.ts b/tests/playground-phpunit-readonly-cache.integration.test.ts index 9a3a3826..24ec22f8 100644 --- a/tests/playground-phpunit-readonly-cache.integration.test.ts +++ b/tests/playground-phpunit-readonly-cache.integration.test.ts @@ -66,10 +66,10 @@ try { async function writeFixture(): Promise { await mkdir(join(plugin, "tests"), { recursive: true }) await mkdir(dependency, { recursive: true }) - await writeFile(join(plugin, "readonly-phpunit-fixture.php"), "\ntests\n") await writeFile(join(plugin, "source-sentinel.bin"), sentinel) - await writeFile(join(plugin, "tests", "ReadonlyCacheTest.php"), "assertTrue(is_multisite()); } public function test_sentinel_is_available(): void { $this->assertGreaterThan(0, filesize(dirname(__DIR__) . \'/source-sentinel.bin\')); } public function test_dependency_activation_runs_after_install(): void { $this->assertGreaterThanOrEqual(1, get_option(\'wp_codebox_dependency_activation_users\')); } public function test_dependency_plugins_loaded_runs_once(): void { $this->assertSame(1, (int) get_option(\'wp_codebox_dependency_plugins_loaded_count\')); } public function test_wp_cli_namespaced_stdout_is_available(): void { $this->assertTrue(eval(\'namespace cli; return is_resource(STDOUT);\')); } }\n") + await writeFile(join(plugin, "tests", "ReadonlyCacheTest.php"), "assertTrue(is_multisite()); } public function test_nested_init_callbacks_run_in_priority_order(): void { $this->assertSame(1, (int) get_option(\'wp_codebox_parent_init_ran\')); $this->assertSame(1, (int) get_option(\'wp_codebox_nested_init_ran\')); } public function test_sentinel_is_available(): void { $this->assertGreaterThan(0, filesize(dirname(__DIR__) . \'/source-sentinel.bin\')); } public function test_dependency_activation_runs_after_install(): void { $this->assertGreaterThanOrEqual(1, get_option(\'wp_codebox_dependency_activation_users\')); } public function test_dependency_plugins_loaded_runs_once(): void { $this->assertSame(1, (int) get_option(\'wp_codebox_dependency_plugins_loaded_count\')); } public function test_wp_cli_namespaced_stdout_is_available(): void { $this->assertTrue(eval(\'namespace cli; return is_resource(STDOUT);\')); } }\n") await writeFile(join(dependency, "activation-dependency.php"), " 1)))); });\n") } From 6e95f702f34e58d30b47b2f425df4bc56a63bfc7 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 04:31:26 +0000 Subject: [PATCH 3/7] fix: preserve multisite with external MySQL --- .../src/playground-cli-runner.ts | 26 ++++++++- ...isposable-mysql-mysqli.integration.test.ts | 55 +++++++++++++++++++ ...layground-cli-runner-bootstrap-ini.test.ts | 15 +++++ 3 files changed, 95 insertions(+), 1 deletion(-) diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 735c7e55..c8d6146d 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -460,6 +460,8 @@ function externalDatabaseWpConfig(spec: RuntimeCreateSpec): string | undefined { const host = spec.runtimeEnv?.DB_HOST if (!host) return undefined const port = spec.runtimeEnv?.DB_PORT + const multisite = blueprintEnablesMultisite(spec.environment.blueprint) + const multisiteSite = multisiteSiteIdentity(spec.preview?.siteUrl) const values = { DB_NAME: spec.runtimeEnv?.DB_NAME ?? "runtime", DB_USER: spec.runtimeEnv?.DB_USER ?? "root", @@ -474,11 +476,33 @@ define('DB_HOST', ${phpLiteral(values.DB_HOST)}); define('DB_CHARSET', 'utf8mb4'); define('DB_COLLATE', ''); $table_prefix = 'wp_'; -if (!defined('ABSPATH')) define('ABSPATH', __DIR__ . '/'); +${multisite ? `define('MULTISITE', true); +define('SUBDOMAIN_INSTALL', false); +define('DOMAIN_CURRENT_SITE', ${phpLiteral(multisiteSite.domain)}); +define('PATH_CURRENT_SITE', ${phpLiteral(multisiteSite.path)}); +define('SITE_ID_CURRENT_SITE', 1); +define('BLOG_ID_CURRENT_SITE', 1); +` : ""}if (!defined('ABSPATH')) define('ABSPATH', __DIR__ . '/'); require_once ABSPATH . 'wp-settings.php'; ` } +function blueprintEnablesMultisite(blueprint: unknown): boolean { + if (!blueprint || typeof blueprint !== "object" || Array.isArray(blueprint)) return false + const steps = Array.isArray((blueprint as { steps?: unknown }).steps) ? (blueprint as { steps: unknown[] }).steps : [] + return steps.some((step) => Boolean(step && typeof step === "object" && !Array.isArray(step) && (step as { step?: unknown }).step === "enableMultisite")) +} + +function multisiteSiteIdentity(siteUrl?: string): { domain: string; path: string } { + try { + const url = new URL(siteUrl || "http://localhost") + const path = `/${url.pathname.replace(/^\/+|\/+$/g, "")}` + return { domain: url.hostname || "localhost", path: path === "/" ? path : `${path}/` } + } catch { + return { domain: "localhost", path: "/" } + } +} + function distributionBootstrapPhp(spec: RuntimeCreateSpec): string { const distribution = recipeDistribution(spec) if (!distribution) { diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts index 0a404e0a..2bc7ef63 100644 --- a/tests/disposable-mysql-mysqli.integration.test.ts +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -146,6 +146,61 @@ require_once ABSPATH . 'wp-settings.php'; assert.deepEqual(aggregate.counts, { total: 2, succeeded: 2, failed: 0, timedOut: 0, cancelled: 0 }) assert.deepEqual(aggregate.entries.map((entry: { artifactNamespace: string }) => entry.artifactNamespace), ["phpunit/one", "phpunit/two"]) assert.deepEqual(aggregate.entries.map((entry: { inputIndex: number }) => entry.inputIndex), [0, 1]) + + const multisitePlugin = join(directory, "managed-multisite-fixture") + const multisiteRecipePath = join(directory, "managed-multisite-recipe.json") + await mkdir(join(multisitePlugin, "tests"), { recursive: true }) + await writeFile(join(multisitePlugin, "managed-multisite-fixture.php"), `tests\n") + await writeFile(join(multisitePlugin, "tests", "ManagedMultisiteTest.php"), `assertTrue(is_multisite()); + $original = get_current_blog_id(); + $blog_id = self::factory()->blog->create(); + switch_to_blog($blog_id); + $this->assertSame($blog_id, get_current_blog_id()); + $this->assertTrue(ms_is_switched()); + restore_current_blog(); + $this->assertSame($original, get_current_blog_id()); + $this->assertFalse(ms_is_switched()); + } + + public function test_external_mysql_replays_wordpress_lifecycle_in_order(): void { + $this->assertGreaterThan(0, did_action('init')); + $this->assertSame(1, (int) get_option('wp_codebox_parent_init_ran')); + $this->assertSame(1, (int) get_option('wp_codebox_nested_init_ran')); + $this->assertSame(1, did_action('wp_codebox_nested_init_replayed')); + } + + public function test_runtime_uses_real_mysql(): void { + global $wpdb; + $this->assertInstanceOf(mysqli::class, $wpdb->dbh); + $this->assertSame('1', (string) $wpdb->get_var("SELECT JSON_VALID('{\\"valid\\":true}')")); + } +} +`) + const multisiteRecipe = buildWordPressPhpunitRecipe({ + pluginSlug: "managed-multisite-fixture", + phpVersion: "8.3", + databaseType: "mysql", + multisite: true, + pluginSource: multisitePlugin, + dependencyMounts: ["/wordpress/wp-content/plugins/managed-multisite-fixture"], + mounts: [{ source: join(harness, "vendor"), target: "/wp-codebox-vendor", mode: "readonly" }], + }) + await writeFile(multisiteRecipePath, `${JSON.stringify(multisiteRecipe)}\n`) + const multisiteResult = await runRecipe({ recipePath: multisiteRecipePath, previewHoldBlocking: false, previewLeaseRequested: false, previewLeaseChild: false, timeoutMs: 300_000, json: true, summary: false, dryRun: false }) + assert.equal(multisiteResult.success, true, JSON.stringify(multisiteResult)) console.log("disposable MySQL and MariaDB mysqli E2E passed") } finally { await rm(directory, { recursive: true, force: true }) diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index 918491de..d07f0d32 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -103,6 +103,21 @@ try { const externalWpConfig = await readFile(externalWpConfigPath as string, "utf8") assert.match(externalWpConfig, /define\('DB_HOST', "127\.0\.0\.1:33061"\)/) assert.match(externalWpConfig, /define\('DB_PASSWORD', "secret"\)/) + assert.doesNotMatch(externalWpConfig, /define\('MULTISITE'/) + + calls.length = 0 + const externalMultisiteSpec: RuntimeCreateSpec = { + ...spec, + environment: { ...spec.environment, blueprint: { steps: [{ step: "enableMultisite" }] } }, + } + const externalMultisiteServer = await startPlaygroundCliServer(externalMultisiteSpec, [], { cliModule }) + await externalMultisiteServer[Symbol.asyncDispose]() + const externalMultisiteConfigPath = calls[0]["mount-before-install"]?.find((mount) => mount.vfsPath === "/wordpress/wp-config.php")?.hostPath + assert.equal(typeof externalMultisiteConfigPath, "string") + const externalMultisiteConfig = await readFile(externalMultisiteConfigPath as string, "utf8") + assert.match(externalMultisiteConfig, /define\('MULTISITE', true\)/) + assert.match(externalMultisiteConfig, /define\('SUBDOMAIN_INSTALL', false\)/) + assert.match(externalMultisiteConfig, /define\('DOMAIN_CURRENT_SITE', "localhost"\)/) calls.length = 0 const defaultRuntimeIniSpec: RuntimeCreateSpec = { From 3c907567aa1ae04bc2566ffcda11a6ebfcdb7df3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 04:36:23 +0000 Subject: [PATCH 4/7] fix: defer MySQL multisite setup to PHPUnit --- packages/runtime-core/src/recipe-builders.ts | 2 +- .../src/playground-cli-runner.ts | 26 +------------------ tests/phpunit-project-autoload.test.ts | 8 ++++++ ...layground-cli-runner-bootstrap-ini.test.ts | 15 ----------- 4 files changed, 10 insertions(+), 41 deletions(-) diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index 2db4c051..4a9b446b 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -82,7 +82,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio wp: options.wordpressVersion ?? DEFAULT_WORDPRESS_VERSION, ...(options.phpVersion ? { phpVersion: options.phpVersion } : {}), ...(options.wordpressInstallMode ? { wordpressInstallMode: options.wordpressInstallMode } : {}), - blueprint: blueprintWithMultisite(options.blueprint ?? { steps: [] }, options.multisite ?? false), + blueprint: blueprintWithMultisite(options.blueprint ?? { steps: [] }, (options.multisite ?? false) && options.databaseType !== "mysql"), ...(options.preview || options.multisite ? { preview: multisitePreview(options.preview, options.multisite ?? false) } : {}), ...(options.extensions?.length ? { extensions: options.extensions } : {}), ...(options.backendPackage ? { backendPackage: options.backendPackage } : {}), diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index c8d6146d..735c7e55 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -460,8 +460,6 @@ function externalDatabaseWpConfig(spec: RuntimeCreateSpec): string | undefined { const host = spec.runtimeEnv?.DB_HOST if (!host) return undefined const port = spec.runtimeEnv?.DB_PORT - const multisite = blueprintEnablesMultisite(spec.environment.blueprint) - const multisiteSite = multisiteSiteIdentity(spec.preview?.siteUrl) const values = { DB_NAME: spec.runtimeEnv?.DB_NAME ?? "runtime", DB_USER: spec.runtimeEnv?.DB_USER ?? "root", @@ -476,33 +474,11 @@ define('DB_HOST', ${phpLiteral(values.DB_HOST)}); define('DB_CHARSET', 'utf8mb4'); define('DB_COLLATE', ''); $table_prefix = 'wp_'; -${multisite ? `define('MULTISITE', true); -define('SUBDOMAIN_INSTALL', false); -define('DOMAIN_CURRENT_SITE', ${phpLiteral(multisiteSite.domain)}); -define('PATH_CURRENT_SITE', ${phpLiteral(multisiteSite.path)}); -define('SITE_ID_CURRENT_SITE', 1); -define('BLOG_ID_CURRENT_SITE', 1); -` : ""}if (!defined('ABSPATH')) define('ABSPATH', __DIR__ . '/'); +if (!defined('ABSPATH')) define('ABSPATH', __DIR__ . '/'); require_once ABSPATH . 'wp-settings.php'; ` } -function blueprintEnablesMultisite(blueprint: unknown): boolean { - if (!blueprint || typeof blueprint !== "object" || Array.isArray(blueprint)) return false - const steps = Array.isArray((blueprint as { steps?: unknown }).steps) ? (blueprint as { steps: unknown[] }).steps : [] - return steps.some((step) => Boolean(step && typeof step === "object" && !Array.isArray(step) && (step as { step?: unknown }).step === "enableMultisite")) -} - -function multisiteSiteIdentity(siteUrl?: string): { domain: string; path: string } { - try { - const url = new URL(siteUrl || "http://localhost") - const path = `/${url.pathname.replace(/^\/+|\/+$/g, "")}` - return { domain: url.hostname || "localhost", path: path === "/" ? path : `${path}/` } - } catch { - return { domain: "localhost", path: "/" } - } -} - function distributionBootstrapPhp(spec: RuntimeCreateSpec): string { const distribution = recipeDistribution(spec) if (!distribution) { diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index e0b85a15..d3b77c5c 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -770,6 +770,14 @@ assert.deepEqual((multisiteRecipe.runtime.blueprint as { steps: unknown[] }).ste assert.equal(multisiteRecipe.runtime.preview?.siteUrl, "http://localhost", "multisite PHPUnit recipes need a canonical site URL without the dynamic Playground port") assert.ok(multisiteRecipe.workflow.steps[0].args.includes("multisite=1")) +const externalMysqlMultisiteRecipe = buildWordPressPhpunitRecipe({ + pluginSlug: "network-plugin", + databaseType: "mysql", + multisite: true, +}) +assert.deepEqual((externalMysqlMultisiteRecipe.runtime.blueprint as { steps: unknown[] }).steps, [], "external MySQL must boot single-site until the managed PHPUnit installer creates network tables") +assert.ok(externalMysqlMultisiteRecipe.workflow.steps[0].args.includes("multisite=1"), "managed PHPUnit still receives the declared multisite contract") + const phpunitCacheAllocator = extractPhpFunction(managedModeCode, "wp_codebox_phpunit_args_private_cache_result_file") const phpunitArgsFunction = extractPhpFunction(managedModeCode, "wp_codebox_phpunit_args") const phpunitArgsProbe = join(mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-cache-args-")), "probe.php") diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index d07f0d32..918491de 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -103,21 +103,6 @@ try { const externalWpConfig = await readFile(externalWpConfigPath as string, "utf8") assert.match(externalWpConfig, /define\('DB_HOST', "127\.0\.0\.1:33061"\)/) assert.match(externalWpConfig, /define\('DB_PASSWORD', "secret"\)/) - assert.doesNotMatch(externalWpConfig, /define\('MULTISITE'/) - - calls.length = 0 - const externalMultisiteSpec: RuntimeCreateSpec = { - ...spec, - environment: { ...spec.environment, blueprint: { steps: [{ step: "enableMultisite" }] } }, - } - const externalMultisiteServer = await startPlaygroundCliServer(externalMultisiteSpec, [], { cliModule }) - await externalMultisiteServer[Symbol.asyncDispose]() - const externalMultisiteConfigPath = calls[0]["mount-before-install"]?.find((mount) => mount.vfsPath === "/wordpress/wp-config.php")?.hostPath - assert.equal(typeof externalMultisiteConfigPath, "string") - const externalMultisiteConfig = await readFile(externalMultisiteConfigPath as string, "utf8") - assert.match(externalMultisiteConfig, /define\('MULTISITE', true\)/) - assert.match(externalMultisiteConfig, /define\('SUBDOMAIN_INSTALL', false\)/) - assert.match(externalMultisiteConfig, /define\('DOMAIN_CURRENT_SITE', "localhost"\)/) calls.length = 0 const defaultRuntimeIniSpec: RuntimeCreateSpec = { From 489e3a97db12c9cb3493e9d9b62cd19874003ed5 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 04:40:23 +0000 Subject: [PATCH 5/7] test: surface MySQL multisite diagnostics --- tests/disposable-mysql-mysqli.integration.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts index 2bc7ef63..58798677 100644 --- a/tests/disposable-mysql-mysqli.integration.test.ts +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -149,6 +149,7 @@ require_once ABSPATH . 'wp-settings.php'; const multisitePlugin = join(directory, "managed-multisite-fixture") const multisiteRecipePath = join(directory, "managed-multisite-recipe.json") + const multisiteArtifacts = join(directory, "managed-multisite-artifacts") await mkdir(join(multisitePlugin, "tests"), { recursive: true }) await writeFile(join(multisitePlugin, "managed-multisite-fixture.php"), ` Date: Fri, 24 Jul 2026 04:45:15 +0000 Subject: [PATCH 6/7] fix: establish managed multisite constants --- .../runtime-playground/src/phpunit-command-handlers.ts | 8 +++++++- tests/phpunit-project-autoload.test.ts | 1 + 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index dd4b81a0..b54a6bdb 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -1113,7 +1113,7 @@ if (!is_array($wp_config_defines)) { $wp_config_defines = array(); } if ($multisite) { - $wp_config_defines += array( + $multisite_defines = array( 'WP_TESTS_MULTISITE' => true, 'MULTISITE' => true, 'SUBDOMAIN_INSTALL' => false, @@ -1122,6 +1122,12 @@ if ($multisite) { 'SITE_ID_CURRENT_SITE' => 1, 'BLOG_ID_CURRENT_SITE' => 1, ); + $wp_config_defines += $multisite_defines; + foreach ($multisite_defines as $name => $value) { + if (!defined($name)) { + define($name, $value); + } + } putenv('WP_MULTISITE=1'); $_ENV['WP_MULTISITE'] = '1'; } diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index d3b77c5c..63550eeb 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -726,6 +726,7 @@ assert.ok(managedModeCode.includes("configured PHPUnit harness autoload file is assert.ok(managedModeCode.includes("define('DB_NAME', ':memory:');"), "default managed PHPUnit remains on SQLite") assert.ok(managedModeCode.includes("'cacheResult' => false")) assert.ok(managedModeCode.includes("global $argv, $pg_stage_output_buffering, $wp_rewrite;"), "managed WordPress installation must expose the rewrite global required by multisite setup") +assert.ok(managedModeCode.includes("foreach ($multisite_defines as $name => $value)"), "managed multisite must establish network constants before the WordPress test installer runs") assert.ok(managedModeCode.includes('$dep_mounts = "/wordpress/wp-content/plugins/demo-plugin\\n/wordpress/wp-content/plugins/dependency";'), "dependency mounts must be newline-delimited for the generated PHP runner") const installStageIndex = managedModeCode.indexOf("pg_run_install_stage(array(") const dependencyLoadStageIndex = managedModeCode.indexOf("$loaded_dep_files = pg_run_load_deps_stage", installStageIndex) From a211c5316bd18ea1dd737ba70233e95a51626e4a Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 24 Jul 2026 04:52:28 +0000 Subject: [PATCH 7/7] fix: let PHPUnit own WordPress bootstrap --- packages/runtime-playground/src/php-bootstrap.ts | 9 +++++---- .../runtime-playground/src/wordpress-command-runners.ts | 3 ++- tests/phpunit-project-autoload.test.ts | 5 ++--- 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/runtime-playground/src/php-bootstrap.ts b/packages/runtime-playground/src/php-bootstrap.ts index 3995da35..1b89a630 100644 --- a/packages/runtime-playground/src/php-bootstrap.ts +++ b/packages/runtime-playground/src/php-bootstrap.ts @@ -21,7 +21,8 @@ ${phpBody(code)}` } export function bootstrapPhpCode(spec: RuntimeCreateSpec, code: string, args: string[], wpCliBridge?: PhpBootstrapBridge, failureDiagnosticFile?: string): string { - if (argValue(args, "bootstrap") === "none") { + const bootstrapMode = argValue(args, "bootstrap") + if (bootstrapMode === "none") { return code } @@ -36,15 +37,15 @@ ${saveQueriesBootstrapPhp(args)} ${runtimeEnvPhp(spec, args)} ${secretEnvPhp(spec)} ${componentManifestPhp(spec)} -require_once '/wordpress/wp-load.php'; +${bootstrapMode === "runtime-only" ? "" : "require_once '/wordpress/wp-load.php';"} ${failureDiagnosticFile ? phpFailureDiagnosticCompletionPhp() : ""} -${recipeActivePluginBootstrapPhp(spec, args)} +${bootstrapMode === "runtime-only" ? "" : recipeActivePluginBootstrapPhp(spec, args)} ${wpCliBridge ? `putenv(${JSON.stringify(`WP_CODEBOX_TERMINAL_ACTION_URL=${wpCliBridge.url}`)}); putenv(${JSON.stringify(`WP_CODEBOX_TERMINAL_ACTION_TOKEN=${wpCliBridge.token}`)}); ` : ""} ${command.body}` - return failureDiagnosticFile ? phpFailureDiagnosticWrapperPhp(bootstrapped, failureDiagnosticFile) : bootstrapped + return failureDiagnosticFile && bootstrapMode !== "runtime-only" ? phpFailureDiagnosticWrapperPhp(bootstrapped, failureDiagnosticFile) : bootstrapped } function phpFailureDiagnosticWrapperPhp(code: string, path: string): string { diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index d69b2773..706de66a 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -959,7 +959,8 @@ export async function runPhpunitCommand({ } let response: PlaygroundRunResponse try { - response = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, code, args, undefined, resultFile) }) + const bootstrapArgs = explicitCode ? args : [...args, "bootstrap=runtime-only"] + response = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, code, bootstrapArgs, undefined, resultFile) }) } catch (error) { await persistPluginPhpunitResult(server, resultFile, artifactRoot, processIdentity) await persistVfsDiagnosticFileToHost(server, resultFile, diagnosticHostFile, mounts) diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index 63550eeb..78b4b958 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -248,8 +248,7 @@ pg_run_project_bootstrap_stage(array('project_bootstrap' => '', 'phpunit_xml' => function decodedBootstrapWrapper(source: string): string { const encoded = source.match(/base64_decode\("([A-Za-z0-9+/=]+)"\)/)?.[1] - assert.ok(encoded, "PHPUnit payload must execute inside the bootstrap diagnostic wrapper") - return Buffer.from(encoded, "base64").toString("utf8") + return encoded ? Buffer.from(encoded, "base64").toString("utf8") : source } function assertSelectedTestFileResolution(source: string): void { @@ -625,7 +624,7 @@ const decodedCanonicalHarnessCode = decodedBootstrapWrapper(capturedCanonicalHar assert.ok(decodedCanonicalHarnessCode.includes('$autoload_file = "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php";')) assert.ok(decodedCanonicalHarnessCode.includes('$autoload_file_role = "harness";')) assert.ok(decodedCanonicalHarnessCode.includes('putenv("TC_MYSQL_PORT=3306");'), "runtime service environment is passed to the PHP executed by wordpress.phpunit") -assert.ok(decodedCanonicalHarnessCode.indexOf('putenv("TC_MYSQL_PORT=3306");') < decodedCanonicalHarnessCode.indexOf("require_once '/wordpress/wp-load.php';"), "runtime environment is available to project bootstrap code") +assert.ok(!decodedCanonicalHarnessCode.includes("require_once '/wordpress/wp-load.php';"), "PHPUnit must own WordPress bootstrap so managed multisite constants are established first") let capturedExplicitCode = "" await runPhpunitCommand({