Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/runtime-core/src/recipe-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } : {}),
Expand Down
9 changes: 5 additions & 4 deletions packages/runtime-playground/src/php-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down
47 changes: 44 additions & 3 deletions packages/runtime-playground/src/phpunit-command-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'])) {
Expand All @@ -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);
}
Expand Down Expand Up @@ -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,
Expand All @@ -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';
}
Expand Down Expand Up @@ -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;
Expand All @@ -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);
});
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime-playground/src/wordpress-command-runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
57 changes: 57 additions & 0 deletions tests/disposable-mysql-mysqli.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"), `<?php
/** Plugin Name: Managed Multisite Fixture */
add_action('init', static function (): void {
update_option('wp_codebox_parent_init_ran', 1);
add_action('init', static function (): void {
update_option('wp_codebox_nested_init_ran', 1);
do_action('wp_codebox_nested_init_replayed');
}, 15);
}, 0);
`)
await writeFile(join(multisitePlugin, "phpunit.xml.dist"), "<?xml version=\"1.0\"?><phpunit><testsuites><testsuite name=\"managed-multisite\"><directory>tests</directory></testsuite></testsuites></phpunit>\n")
await writeFile(join(multisitePlugin, "tests", "ManagedMultisiteTest.php"), `<?php
final class ManagedMultisiteTest extends WP_UnitTestCase {
public function test_external_mysql_preserves_multisite_blog_switching(): void {
$this->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 })
Expand Down
73 changes: 70 additions & 3 deletions tests/phpunit-project-autoload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, `<?php
class WP_Hook {
public array $callbacks = array();
public function add_filter($hook, $callback, $priority, $accepted_args): void {
$id = is_string($callback) ? $callback : spl_object_hash($callback);
$this->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")
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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")
Expand Down
Loading
Loading