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") }