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/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/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts
index d1d1e140..b54a6bdb 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);
}
@@ -1080,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,
@@ -1089,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';
}
@@ -1127,8 +1166,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;
@@ -1140,7 +1181,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/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/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts
index 0a404e0a..58798677 100644
--- a/tests/disposable-mysql-mysqli.integration.test.ts
+++ b/tests/disposable-mysql-mysqli.integration.test.ts
@@ -146,6 +146,63 @@ 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")
+ const multisiteArtifacts = join(directory, "managed-multisite-artifacts")
+ 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, artifactsDirectory: multisiteArtifacts, previewHoldBlocking: false, previewLeaseRequested: false, previewLeaseChild: false, timeoutMs: 300_000, json: true, summary: false, dryRun: false })
+ const multisiteFailure = multisiteResult as typeof multisiteResult & { error?: unknown }
+ assert.equal(multisiteResult.success, true, JSON.stringify({ error: multisiteFailure.error, executions: multisiteResult.executions }))
console.log("disposable MySQL and MariaDB mysqli E2E passed")
} finally {
await rm(directory, { recursive: true, force: true })
diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts
index 34391670..78b4b958 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")
@@ -191,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 {
@@ -568,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({
@@ -663,10 +719,13 @@ 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"))
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)
@@ -711,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-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")
}