Skip to content

Commit 117e16a

Browse files
authored
fix: preserve deferred WordPress hook lifecycle (#2008)
* fix: preserve deferred WordPress hook lifecycle * fix: replay fallback init callbacks * fix: preserve multisite with external MySQL * fix: defer MySQL multisite setup to PHPUnit * test: surface MySQL multisite diagnostics * fix: establish managed multisite constants * fix: let PHPUnit own WordPress bootstrap
1 parent e57a542 commit 117e16a

7 files changed

Lines changed: 181 additions & 14 deletions

packages/runtime-core/src/recipe-builders.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
8282
wp: options.wordpressVersion ?? DEFAULT_WORDPRESS_VERSION,
8383
...(options.phpVersion ? { phpVersion: options.phpVersion } : {}),
8484
...(options.wordpressInstallMode ? { wordpressInstallMode: options.wordpressInstallMode } : {}),
85-
blueprint: blueprintWithMultisite(options.blueprint ?? { steps: [] }, options.multisite ?? false),
85+
blueprint: blueprintWithMultisite(options.blueprint ?? { steps: [] }, (options.multisite ?? false) && options.databaseType !== "mysql"),
8686
...(options.preview || options.multisite ? { preview: multisitePreview(options.preview, options.multisite ?? false) } : {}),
8787
...(options.extensions?.length ? { extensions: options.extensions } : {}),
8888
...(options.backendPackage ? { backendPackage: options.backendPackage } : {}),

packages/runtime-playground/src/php-bootstrap.ts

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ ${phpBody(code)}`
2121
}
2222

2323
export function bootstrapPhpCode(spec: RuntimeCreateSpec, code: string, args: string[], wpCliBridge?: PhpBootstrapBridge, failureDiagnosticFile?: string): string {
24-
if (argValue(args, "bootstrap") === "none") {
24+
const bootstrapMode = argValue(args, "bootstrap")
25+
if (bootstrapMode === "none") {
2526
return code
2627
}
2728

@@ -36,15 +37,15 @@ ${saveQueriesBootstrapPhp(args)}
3637
${runtimeEnvPhp(spec, args)}
3738
${secretEnvPhp(spec)}
3839
${componentManifestPhp(spec)}
39-
require_once '/wordpress/wp-load.php';
40+
${bootstrapMode === "runtime-only" ? "" : "require_once '/wordpress/wp-load.php';"}
4041
${failureDiagnosticFile ? phpFailureDiagnosticCompletionPhp() : ""}
41-
${recipeActivePluginBootstrapPhp(spec, args)}
42+
${bootstrapMode === "runtime-only" ? "" : recipeActivePluginBootstrapPhp(spec, args)}
4243
${wpCliBridge ? `putenv(${JSON.stringify(`WP_CODEBOX_TERMINAL_ACTION_URL=${wpCliBridge.url}`)});
4344
putenv(${JSON.stringify(`WP_CODEBOX_TERMINAL_ACTION_TOKEN=${wpCliBridge.token}`)});
4445
` : ""}
4546
${command.body}`
4647

47-
return failureDiagnosticFile ? phpFailureDiagnosticWrapperPhp(bootstrapped, failureDiagnosticFile) : bootstrapped
48+
return failureDiagnosticFile && bootstrapMode !== "runtime-only" ? phpFailureDiagnosticWrapperPhp(bootstrapped, failureDiagnosticFile) : bootstrapped
4849
}
4950

5051
function phpFailureDiagnosticWrapperPhp(code: string, path: string): string {

packages/runtime-playground/src/phpunit-command-handlers.ts

Lines changed: 44 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -537,16 +537,34 @@ function pg_defer_new_wordpress_hook_callbacks(string $hook_name, array $before)
537537
}
538538
539539
function pg_run_deferred_wordpress_hook_callbacks(array $deferred, array $args = array(), ?string $hook_name = null): void {
540-
global $wp_current_filter;
540+
global $wp_filter, $wp_current_filter;
541541
$pushed_hook = false;
542+
$original_hook = null;
543+
$replay_hook = null;
542544
if (is_string($hook_name) && $hook_name !== '') {
543545
if (!is_array($wp_current_filter)) {
544546
$wp_current_filter = array();
545547
}
546548
$wp_current_filter[] = $hook_name;
547549
$pushed_hook = true;
550+
$original_hook = $wp_filter[$hook_name] ?? null;
551+
$replay_hook = new WP_Hook();
552+
$wp_filter[$hook_name] = $replay_hook;
553+
foreach ($deferred as $entry) {
554+
$callback = $entry['callback'] ?? null;
555+
if (!is_array($callback) || !isset($callback['function'])) {
556+
continue;
557+
}
558+
$priority = isset($entry['priority']) ? (int) $entry['priority'] : 10;
559+
$accepted_args = isset($callback['accepted_args']) ? (int) $callback['accepted_args'] : count($args);
560+
add_filter($hook_name, $callback['function'], $priority, $accepted_args);
561+
}
548562
}
549563
try {
564+
if ($replay_hook instanceof WP_Hook) {
565+
$replay_hook->do_action($args);
566+
return;
567+
}
550568
foreach ($deferred as $entry) {
551569
$callback = $entry['callback'] ?? null;
552570
if (!is_array($callback) || !isset($callback['function'])) {
@@ -556,6 +574,21 @@ function pg_run_deferred_wordpress_hook_callbacks(array $deferred, array $args =
556574
call_user_func_array($callback['function'], array_slice($args, 0, $accepted_args));
557575
}
558576
} finally {
577+
if ($replay_hook instanceof WP_Hook && is_string($hook_name)) {
578+
$retained_callbacks = $replay_hook->callbacks;
579+
if ($original_hook instanceof WP_Hook) {
580+
$wp_filter[$hook_name] = $original_hook;
581+
} else {
582+
unset($wp_filter[$hook_name]);
583+
}
584+
foreach ($retained_callbacks as $priority => $callbacks) {
585+
foreach ($callbacks as $callback) {
586+
if (isset($callback['function'])) {
587+
add_filter($hook_name, $callback['function'], (int) $priority, (int) ($callback['accepted_args'] ?? 0));
588+
}
589+
}
590+
}
591+
}
559592
if ($pushed_hook) {
560593
array_pop($wp_current_filter);
561594
}
@@ -1080,7 +1113,7 @@ if (!is_array($wp_config_defines)) {
10801113
$wp_config_defines = array();
10811114
}
10821115
if ($multisite) {
1083-
$wp_config_defines += array(
1116+
$multisite_defines = array(
10841117
'WP_TESTS_MULTISITE' => true,
10851118
'MULTISITE' => true,
10861119
'SUBDOMAIN_INSTALL' => false,
@@ -1089,6 +1122,12 @@ if ($multisite) {
10891122
'SITE_ID_CURRENT_SITE' => 1,
10901123
'BLOG_ID_CURRENT_SITE' => 1,
10911124
);
1125+
$wp_config_defines += $multisite_defines;
1126+
foreach ($multisite_defines as $name => $value) {
1127+
if (!defined($name)) {
1128+
define($name, $value);
1129+
}
1130+
}
10921131
putenv('WP_MULTISITE=1');
10931132
$_ENV['WP_MULTISITE'] = '1';
10941133
}
@@ -1127,8 +1166,10 @@ tests_add_filter('muplugins_loaded', function () use ($plugin_slug, $plugin_path
11271166
pg_run_install_stage(array('config_path' => $config_path, 'tests_dir' => $tests_dir, 'multisite' => $multisite));
11281167
pg_remove_new_wordpress_hook_callbacks('shutdown', $pre_component_shutdown_callbacks);
11291168
$pre_dependency_plugins_loaded_callbacks = pg_snapshot_wordpress_hook_callbacks('plugins_loaded');
1169+
$pre_dependency_init_callbacks = pg_snapshot_wordpress_hook_callbacks('init');
11301170
$loaded_dep_files = pg_run_load_deps_stage(array('dep_mounts' => $dep_mounts));
11311171
$deferred_dependency_plugins_loaded_callbacks = pg_defer_new_wordpress_hook_callbacks('plugins_loaded', $pre_dependency_plugins_loaded_callbacks);
1172+
$deferred_dependency_init_callbacks = pg_defer_new_wordpress_hook_callbacks('init', $pre_dependency_init_callbacks);
11321173
$activation_files = $loaded_dep_files;
11331174
if ($loaded_component_file !== null) {
11341175
$activation_files[] = $loaded_component_file;
@@ -1140,7 +1181,7 @@ $reopened_ability_categories_init = pg_reopen_wordpress_action('wp_abilities_api
11401181
$reopened_ability_init = pg_reopen_wordpress_action('wp_abilities_api_init');
11411182
pg_run_deferred_wordpress_hook_callbacks($deferred_install_plugins_loaded_callbacks, array(), 'plugins_loaded');
11421183
pg_run_deferred_wordpress_hook_callbacks($deferred_dependency_plugins_loaded_callbacks, array(), 'plugins_loaded');
1143-
$deferred_install_init_callbacks = array_merge($deferred_install_init_callbacks, pg_defer_new_wordpress_hook_callbacks('init', $pre_replayed_plugins_loaded_init_callbacks));
1184+
$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));
11441185
usort($deferred_install_init_callbacks, static function (array $left, array $right): int {
11451186
return ($left['priority'] ?? 10) <=> ($right['priority'] ?? 10);
11461187
});

packages/runtime-playground/src/wordpress-command-runners.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -959,7 +959,8 @@ export async function runPhpunitCommand({
959959
}
960960
let response: PlaygroundRunResponse
961961
try {
962-
response = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, code, args, undefined, resultFile) })
962+
const bootstrapArgs = explicitCode ? args : [...args, "bootstrap=runtime-only"]
963+
response = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, code, bootstrapArgs, undefined, resultFile) })
963964
} catch (error) {
964965
await persistPluginPhpunitResult(server, resultFile, artifactRoot, processIdentity)
965966
await persistVfsDiagnosticFileToHost(server, resultFile, diagnosticHostFile, mounts)

tests/disposable-mysql-mysqli.integration.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,63 @@ require_once ABSPATH . 'wp-settings.php';
146146
assert.deepEqual(aggregate.counts, { total: 2, succeeded: 2, failed: 0, timedOut: 0, cancelled: 0 })
147147
assert.deepEqual(aggregate.entries.map((entry: { artifactNamespace: string }) => entry.artifactNamespace), ["phpunit/one", "phpunit/two"])
148148
assert.deepEqual(aggregate.entries.map((entry: { inputIndex: number }) => entry.inputIndex), [0, 1])
149+
150+
const multisitePlugin = join(directory, "managed-multisite-fixture")
151+
const multisiteRecipePath = join(directory, "managed-multisite-recipe.json")
152+
const multisiteArtifacts = join(directory, "managed-multisite-artifacts")
153+
await mkdir(join(multisitePlugin, "tests"), { recursive: true })
154+
await writeFile(join(multisitePlugin, "managed-multisite-fixture.php"), `<?php
155+
/** Plugin Name: Managed Multisite Fixture */
156+
add_action('init', static function (): void {
157+
update_option('wp_codebox_parent_init_ran', 1);
158+
add_action('init', static function (): void {
159+
update_option('wp_codebox_nested_init_ran', 1);
160+
do_action('wp_codebox_nested_init_replayed');
161+
}, 15);
162+
}, 0);
163+
`)
164+
await writeFile(join(multisitePlugin, "phpunit.xml.dist"), "<?xml version=\"1.0\"?><phpunit><testsuites><testsuite name=\"managed-multisite\"><directory>tests</directory></testsuite></testsuites></phpunit>\n")
165+
await writeFile(join(multisitePlugin, "tests", "ManagedMultisiteTest.php"), `<?php
166+
final class ManagedMultisiteTest extends WP_UnitTestCase {
167+
public function test_external_mysql_preserves_multisite_blog_switching(): void {
168+
$this->assertTrue(is_multisite());
169+
$original = get_current_blog_id();
170+
$blog_id = self::factory()->blog->create();
171+
switch_to_blog($blog_id);
172+
$this->assertSame($blog_id, get_current_blog_id());
173+
$this->assertTrue(ms_is_switched());
174+
restore_current_blog();
175+
$this->assertSame($original, get_current_blog_id());
176+
$this->assertFalse(ms_is_switched());
177+
}
178+
179+
public function test_external_mysql_replays_wordpress_lifecycle_in_order(): void {
180+
$this->assertGreaterThan(0, did_action('init'));
181+
$this->assertSame(1, (int) get_option('wp_codebox_parent_init_ran'));
182+
$this->assertSame(1, (int) get_option('wp_codebox_nested_init_ran'));
183+
$this->assertSame(1, did_action('wp_codebox_nested_init_replayed'));
184+
}
185+
186+
public function test_runtime_uses_real_mysql(): void {
187+
global $wpdb;
188+
$this->assertInstanceOf(mysqli::class, $wpdb->dbh);
189+
$this->assertSame('1', (string) $wpdb->get_var("SELECT JSON_VALID('{\\"valid\\":true}')"));
190+
}
191+
}
192+
`)
193+
const multisiteRecipe = buildWordPressPhpunitRecipe({
194+
pluginSlug: "managed-multisite-fixture",
195+
phpVersion: "8.3",
196+
databaseType: "mysql",
197+
multisite: true,
198+
pluginSource: multisitePlugin,
199+
dependencyMounts: ["/wordpress/wp-content/plugins/managed-multisite-fixture"],
200+
mounts: [{ source: join(harness, "vendor"), target: "/wp-codebox-vendor", mode: "readonly" }],
201+
})
202+
await writeFile(multisiteRecipePath, `${JSON.stringify(multisiteRecipe)}\n`)
203+
const multisiteResult = await runRecipe({ recipePath: multisiteRecipePath, artifactsDirectory: multisiteArtifacts, previewHoldBlocking: false, previewLeaseRequested: false, previewLeaseChild: false, timeoutMs: 300_000, json: true, summary: false, dryRun: false })
204+
const multisiteFailure = multisiteResult as typeof multisiteResult & { error?: unknown }
205+
assert.equal(multisiteResult.success, true, JSON.stringify({ error: multisiteFailure.error, executions: multisiteResult.executions }))
149206
console.log("disposable MySQL and MariaDB mysqli E2E passed")
150207
} finally {
151208
await rm(directory, { recursive: true, force: true })

tests/phpunit-project-autoload.test.ts

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,63 @@ function phpString(value: string): string {
6565
return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'`
6666
}
6767

68+
function assertDeferredHookReplayUsesWordPressLifecycle(source: string): void {
69+
const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-deferred-hook-"))
70+
const scriptPath = join(tempDir, "assert-deferred-hook.php")
71+
writeFileSync(scriptPath, `<?php
72+
class WP_Hook {
73+
public array $callbacks = array();
74+
public function add_filter($hook, $callback, $priority, $accepted_args): void {
75+
$id = is_string($callback) ? $callback : spl_object_hash($callback);
76+
$this->callbacks[$priority][$id] = array('function' => $callback, 'accepted_args' => $accepted_args);
77+
}
78+
public function do_action($args): void {
79+
$completed = array();
80+
while (true) {
81+
ksort($this->callbacks);
82+
$next = null;
83+
foreach ($this->callbacks as $priority => $callbacks) {
84+
foreach ($callbacks as $id => $callback) {
85+
$key = $priority . ':' . $id;
86+
if (!isset($completed[$key])) {
87+
$next = array($key, $callback);
88+
break 2;
89+
}
90+
}
91+
}
92+
if ($next === null) return;
93+
$completed[$next[0]] = true;
94+
call_user_func_array($next[1]['function'], array_slice($args, 0, $next[1]['accepted_args']));
95+
}
96+
}
97+
}
98+
function add_filter($hook, $callback, $priority = 10, $accepted_args = 1): bool {
99+
global $wp_filter;
100+
if (!isset($wp_filter[$hook])) $wp_filter[$hook] = new WP_Hook();
101+
$wp_filter[$hook]->add_filter($hook, $callback, $priority, $accepted_args);
102+
return true;
103+
}
104+
function add_action($hook, $callback, $priority = 10, $accepted_args = 1): bool {
105+
return add_filter($hook, $callback, $priority, $accepted_args);
106+
}
107+
${extractPhpFunction(source, "pg_run_deferred_wordpress_hook_callbacks")}
108+
$events = array();
109+
$wp_current_filter = array();
110+
$wp_filter = array('init' => new WP_Hook());
111+
add_action('init', function() use (&$events) { $events[] = 'core-must-not-replay'; }, 5, 0);
112+
$early = function() use (&$events) {
113+
$events[] = 'early';
114+
add_action('init', function() use (&$events) { $events[] = 'late'; }, 15, 0);
115+
};
116+
pg_run_deferred_wordpress_hook_callbacks(array(array('priority' => 0, 'callback' => array('function' => $early, 'accepted_args' => 0))), array(), 'init');
117+
if ($events !== array('early', 'late')) throw new RuntimeException('deferred init order was not faithful: ' . json_encode($events));
118+
if (count($wp_filter['init']->callbacks, COUNT_RECURSIVE) < 6) throw new RuntimeException('replayed callbacks were not retained');
119+
if ($wp_current_filter !== array()) throw new RuntimeException('current filter stack leaked');
120+
echo "ok\n";
121+
`)
122+
assert.equal(execFileSync("php", [scriptPath], { encoding: "utf8" }), "ok\n")
123+
}
124+
68125
function assertPhpunitConfigurationAndDiscoveryFailures(source: string, functionName: string, discoveryFunctionName: string, logFunctionName: string, supportsImplicitFallback: boolean): void {
69126
const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-config-"))
70127
const malformedXml = join(tempDir, "phpunit.xml.dist")
@@ -191,8 +248,7 @@ pg_run_project_bootstrap_stage(array('project_bootstrap' => '', 'phpunit_xml' =>
191248

192249
function decodedBootstrapWrapper(source: string): string {
193250
const encoded = source.match(/base64_decode\("([A-Za-z0-9+/=]+)"\)/)?.[1]
194-
assert.ok(encoded, "PHPUnit payload must execute inside the bootstrap diagnostic wrapper")
195-
return Buffer.from(encoded, "base64").toString("utf8")
251+
return encoded ? Buffer.from(encoded, "base64").toString("utf8") : source
196252
}
197253

198254
function assertSelectedTestFileResolution(source: string): void {
@@ -568,7 +624,7 @@ const decodedCanonicalHarnessCode = decodedBootstrapWrapper(capturedCanonicalHar
568624
assert.ok(decodedCanonicalHarnessCode.includes('$autoload_file = "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php";'))
569625
assert.ok(decodedCanonicalHarnessCode.includes('$autoload_file_role = "harness";'))
570626
assert.ok(decodedCanonicalHarnessCode.includes('putenv("TC_MYSQL_PORT=3306");'), "runtime service environment is passed to the PHP executed by wordpress.phpunit")
571-
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")
627+
assert.ok(!decodedCanonicalHarnessCode.includes("require_once '/wordpress/wp-load.php';"), "PHPUnit must own WordPress bootstrap so managed multisite constants are established first")
572628

573629
let capturedExplicitCode = ""
574630
await runPhpunitCommand({
@@ -663,10 +719,13 @@ const managedModeCode = phpunitRunCode({
663719
databaseType: "sqlite",
664720
})
665721

722+
assertDeferredHookReplayUsesWordPressLifecycle(managedModeCode)
723+
666724
assert.ok(managedModeCode.includes("configured PHPUnit harness autoload file is not readable"))
667725
assert.ok(managedModeCode.includes("define('DB_NAME', ':memory:');"), "default managed PHPUnit remains on SQLite")
668726
assert.ok(managedModeCode.includes("'cacheResult' => false"))
669727
assert.ok(managedModeCode.includes("global $argv, $pg_stage_output_buffering, $wp_rewrite;"), "managed WordPress installation must expose the rewrite global required by multisite setup")
728+
assert.ok(managedModeCode.includes("foreach ($multisite_defines as $name => $value)"), "managed multisite must establish network constants before the WordPress test installer runs")
670729
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")
671730
const installStageIndex = managedModeCode.indexOf("pg_run_install_stage(array(")
672731
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
711770
assert.equal(multisiteRecipe.runtime.preview?.siteUrl, "http://localhost", "multisite PHPUnit recipes need a canonical site URL without the dynamic Playground port")
712771
assert.ok(multisiteRecipe.workflow.steps[0].args.includes("multisite=1"))
713772

773+
const externalMysqlMultisiteRecipe = buildWordPressPhpunitRecipe({
774+
pluginSlug: "network-plugin",
775+
databaseType: "mysql",
776+
multisite: true,
777+
})
778+
assert.deepEqual((externalMysqlMultisiteRecipe.runtime.blueprint as { steps: unknown[] }).steps, [], "external MySQL must boot single-site until the managed PHPUnit installer creates network tables")
779+
assert.ok(externalMysqlMultisiteRecipe.workflow.steps[0].args.includes("multisite=1"), "managed PHPUnit still receives the declared multisite contract")
780+
714781
const phpunitCacheAllocator = extractPhpFunction(managedModeCode, "wp_codebox_phpunit_args_private_cache_result_file")
715782
const phpunitArgsFunction = extractPhpFunction(managedModeCode, "wp_codebox_phpunit_args")
716783
const phpunitArgsProbe = join(mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-cache-args-")), "probe.php")

0 commit comments

Comments
 (0)