From be85eecdde23e47439b285a976de0ee644cf9ab2 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 18 Jul 2026 09:58:53 -0400 Subject: [PATCH 1/2] Load PHPUnit dependencies after install --- docs/recipe-contract.md | 7 ++++++ packages/runtime-core/src/command-registry.ts | 2 +- .../src/phpunit-command-handlers.ts | 4 ++-- tests/phpunit-project-autoload.test.ts | 24 +++++++++++++++++++ ...phpunit-readonly-cache.integration.test.ts | 12 +++++++++- 5 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/recipe-contract.md b/docs/recipe-contract.md index 876b1130..3de48c44 100644 --- a/docs/recipe-contract.md +++ b/docs/recipe-contract.md @@ -716,6 +716,13 @@ The runtime provides: - Structured diagnostics in the recipe artifact bundle, including the raw test result log collected from `/tmp/wp-codebox-phpunit-result.txt`. +`dependencyMounts` is the canonical PHPUnit builder input for plugins that need +the PHPUnit database during activation. Mount each dependency through +`inputs.extra_plugins` with `activate: false`, then provide its sandbox plugin +directory in `dependencyMounts`. In managed bootstrap mode, `wordpress.phpunit` +loads and activates those dependencies after the PHPUnit install stage has +created test tables and before test discovery and execution. + Use `recipe build phpunit` when generating recipes for plugin CI or offloaded lab runners: diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index e763a94e..7b54d840 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -948,7 +948,7 @@ export const commandRegistry = [ { name: "changed-tests-json", description: "Changed test files for diagnostics.", format: "JSON array" }, { name: "env-json", description: "PHPUnit environment values.", format: "JSON object" }, { name: "wp-config-defines-json", description: "wp-config.php constants for the run.", format: "JSON object" }, - { name: "dependency-mounts", description: "Comma-separated mounted dependency paths.", format: "comma-separated sandbox paths" }, + { name: "dependency-mounts", description: "Comma-separated mounted dependency paths loaded and activated after managed PHPUnit installation, before tests execute.", format: "comma-separated sandbox paths" }, { name: "bootstrap-files-json", description: "Plugin-relative bootstrap file fallbacks loaded in managed mode after wp-phpunit fixtures.", format: "JSON array" }, { name: "phpunit-args-json", description: "Structured PHPUnit CLI arguments such as [\"--filter\", \"MyTest::test_case\"].", format: "JSON array" }, { name: "bootstrap-mode", description: "Bootstrap strategy: managed keeps WP Codebox-owned setup; project requires the plugin's native PHPUnit bootstrap.", format: "managed|project" }, diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index ee472671..4b6dbfea 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -1104,8 +1104,7 @@ $loaded_dep_files = array(); $loaded_component_file = null; require_once $tests_dir . '/includes/functions.php'; -tests_add_filter('muplugins_loaded', function () use ($plugin_slug, $plugin_path, $dep_mounts, $pre_component_plugins_loaded_callbacks, $pre_component_init_callbacks, &$deferred_install_plugins_loaded_callbacks, &$deferred_install_init_callbacks, &$loaded_dep_files, &$loaded_component_file) { - $loaded_dep_files = pg_run_load_deps_stage(array('dep_mounts' => $dep_mounts)); +tests_add_filter('muplugins_loaded', function () use ($plugin_slug, $plugin_path, $pre_component_plugins_loaded_callbacks, $pre_component_init_callbacks, &$deferred_install_plugins_loaded_callbacks, &$deferred_install_init_callbacks, &$loaded_component_file) { $loaded_component_file = pg_run_load_component_stage(array('plugin_slug' => $plugin_slug, 'plugin_path' => $plugin_path, 'activate' => false)); $deferred_install_plugins_loaded_callbacks = pg_defer_new_wordpress_hook_callbacks('plugins_loaded', $pre_component_plugins_loaded_callbacks); $deferred_install_init_callbacks = pg_defer_new_wordpress_hook_callbacks('init', $pre_component_init_callbacks); @@ -1113,6 +1112,7 @@ 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); +$loaded_dep_files = pg_run_load_deps_stage(array('dep_mounts' => $dep_mounts)); $activation_files = $loaded_dep_files; if ($loaded_component_file !== null) { $activation_files[] = $loaded_component_file; diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index 022408b5..8149bfea 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -410,6 +410,30 @@ const managedModeCode = phpunitRunCode({ assert.ok(managedModeCode.includes("configured PHPUnit harness autoload file is not readable")) assert.ok(managedModeCode.includes("'cacheResult' => false")) +const installStageIndex = managedModeCode.indexOf("pg_run_install_stage(array(") +const dependencyLoadStageIndex = managedModeCode.indexOf("$loaded_dep_files = pg_run_load_deps_stage", installStageIndex) +const activationStageIndex = managedModeCode.indexOf("pg_run_activation_stage", dependencyLoadStageIndex) +assert.ok(installStageIndex > 0) +assert.ok(dependencyLoadStageIndex > installStageIndex, "dependency plugins must load after managed PHPUnit installation") +assert.ok(activationStageIndex > dependencyLoadStageIndex, "dependency plugins must activate after loading and before tests execute") + +const dependencyRecipe = buildWordPressPhpunitRecipe({ + pluginSlug: "demo-plugin", + extra_plugins: [{ + source: "/workspace/dependency", + slug: "dependency", + pluginFile: "dependency/dependency.php", + activate: false, + }], + dependencyMounts: ["/wordpress/wp-content/plugins/dependency"], +}) +assert.deepEqual(dependencyRecipe.inputs.extra_plugins, [{ + source: "/workspace/dependency", + slug: "dependency", + pluginFile: "dependency/dependency.php", + activate: false, +}]) +assert.ok(dependencyRecipe.workflow.steps[0].args.includes("dependency-mounts=/wordpress/wp-content/plugins/dependency")) const phpunitCacheAllocator = extractPhpFunction(managedModeCode, "wp_codebox_phpunit_args_private_cache_result_file") const phpunitArgsFunction = extractPhpFunction(managedModeCode, "wp_codebox_phpunit_args") diff --git a/tests/playground-phpunit-readonly-cache.integration.test.ts b/tests/playground-phpunit-readonly-cache.integration.test.ts index c834177c..5860879c 100644 --- a/tests/playground-phpunit-readonly-cache.integration.test.ts +++ b/tests/playground-phpunit-readonly-cache.integration.test.ts @@ -11,6 +11,7 @@ import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe const execFileAsync = promisify(execFile) const root = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-readonly-")) const plugin = join(root, "plugin") +const dependency = join(root, "dependency") const harness = join(root, "harness") const recipePath = join(root, "recipe.json") const artifactsPath = join(root, "artifacts") @@ -24,6 +25,13 @@ try { const recipe = buildWordPressPhpunitRecipe({ pluginSlug: "readonly-phpunit-fixture", + extra_plugins: [{ + source: dependency, + slug: "activation-dependency", + pluginFile: "activation-dependency/activation-dependency.php", + activate: false, + }], + dependencyMounts: ["/wordpress/wp-content/plugins/activation-dependency"], mounts: [ { source: plugin, target: "/wordpress/wp-content/plugins/readonly-phpunit-fixture", mode: "readonly" }, { source: join(harness, "vendor"), target: "/wp-codebox-vendor", mode: "readonly" }, @@ -49,9 +57,11 @@ 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"), "assertGreaterThan(0, filesize(dirname(__DIR__) . \'/source-sentinel.bin\')); } }\n") + await writeFile(join(plugin, "tests", "ReadonlyCacheTest.php"), "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\')); } }\n") + await writeFile(join(dependency, "activation-dependency.php"), " 1)))); });\n") } async function digestTree(directory: string): Promise { From fe97056b9cd9b38d4dcc63c27f1e65260ffcc7be Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sat, 18 Jul 2026 10:04:19 -0400 Subject: [PATCH 2/2] Replay PHPUnit dependency plugin hooks --- docs/recipe-contract.md | 4 +++- packages/runtime-playground/src/phpunit-command-handlers.ts | 3 +++ tests/phpunit-project-autoload.test.ts | 6 ++++++ tests/playground-phpunit-readonly-cache.integration.test.ts | 4 ++-- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/docs/recipe-contract.md b/docs/recipe-contract.md index 3de48c44..13461561 100644 --- a/docs/recipe-contract.md +++ b/docs/recipe-contract.md @@ -721,7 +721,9 @@ the PHPUnit database during activation. Mount each dependency through `inputs.extra_plugins` with `activate: false`, then provide its sandbox plugin directory in `dependencyMounts`. In managed bootstrap mode, `wordpress.phpunit` loads and activates those dependencies after the PHPUnit install stage has -created test tables and before test discovery and execution. +created test tables and before test discovery and execution. Any dependency +`plugins_loaded` callbacks registered during that load are invoked once after +dependency activation. Use `recipe build phpunit` when generating recipes for plugin CI or offloaded lab runners: diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index 4b6dbfea..40582270 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -1112,7 +1112,9 @@ 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'); $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); $activation_files = $loaded_dep_files; if ($loaded_component_file !== null) { $activation_files[] = $loaded_component_file; @@ -1123,6 +1125,7 @@ $pre_replayed_plugins_loaded_init_callbacks = pg_snapshot_wordpress_hook_callbac $reopened_ability_categories_init = pg_reopen_wordpress_action('wp_abilities_api_categories_init'); $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)); usort($deferred_install_init_callbacks, static function (array $left, array $right): int { return ($left['priority'] ?? 10) <=> ($right['priority'] ?? 10); diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index 8149bfea..ce9badc9 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -413,9 +413,15 @@ assert.ok(managedModeCode.includes("'cacheResult' => false")) const installStageIndex = managedModeCode.indexOf("pg_run_install_stage(array(") const dependencyLoadStageIndex = managedModeCode.indexOf("$loaded_dep_files = pg_run_load_deps_stage", installStageIndex) const activationStageIndex = managedModeCode.indexOf("pg_run_activation_stage", dependencyLoadStageIndex) +const dependencyPluginsLoadedSnapshotIndex = managedModeCode.indexOf("$pre_dependency_plugins_loaded_callbacks = pg_snapshot_wordpress_hook_callbacks('plugins_loaded');", installStageIndex) +const dependencyPluginsLoadedDeferIndex = managedModeCode.indexOf("$deferred_dependency_plugins_loaded_callbacks = pg_defer_new_wordpress_hook_callbacks('plugins_loaded', $pre_dependency_plugins_loaded_callbacks);", dependencyLoadStageIndex) +const dependencyPluginsLoadedReplayIndex = managedModeCode.indexOf("pg_run_deferred_wordpress_hook_callbacks($deferred_dependency_plugins_loaded_callbacks, array(), 'plugins_loaded');", activationStageIndex) assert.ok(installStageIndex > 0) +assert.ok(dependencyPluginsLoadedSnapshotIndex > installStageIndex && dependencyPluginsLoadedSnapshotIndex < dependencyLoadStageIndex, "dependency plugins_loaded callbacks must be scoped to dependency loading") assert.ok(dependencyLoadStageIndex > installStageIndex, "dependency plugins must load after managed PHPUnit installation") +assert.ok(dependencyPluginsLoadedDeferIndex > dependencyLoadStageIndex && dependencyPluginsLoadedDeferIndex < activationStageIndex, "dependency plugins_loaded callbacks must defer until activation completes") assert.ok(activationStageIndex > dependencyLoadStageIndex, "dependency plugins must activate after loading and before tests execute") +assert.ok(dependencyPluginsLoadedReplayIndex > activationStageIndex, "dependency plugins_loaded callbacks must run once after activation") const dependencyRecipe = buildWordPressPhpunitRecipe({ pluginSlug: "demo-plugin", diff --git a/tests/playground-phpunit-readonly-cache.integration.test.ts b/tests/playground-phpunit-readonly-cache.integration.test.ts index 5860879c..c95b9763 100644 --- a/tests/playground-phpunit-readonly-cache.integration.test.ts +++ b/tests/playground-phpunit-readonly-cache.integration.test.ts @@ -60,8 +60,8 @@ async function writeFixture(): Promise { await mkdir(dependency, { recursive: true }) await writeFile(join(plugin, "readonly-phpunit-fixture.php"), "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\')); } }\n") - await writeFile(join(dependency, "activation-dependency.php"), " 1)))); });\n") + await writeFile(join(plugin, "tests", "ReadonlyCacheTest.php"), "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\')); } }\n") + await writeFile(join(dependency, "activation-dependency.php"), " 1)))); });\n") } async function digestTree(directory: string): Promise {