Skip to content

Commit f5bca51

Browse files
committed
fix: activate managed dependencies after network install
1 parent 8f6d632 commit f5bca51

7 files changed

Lines changed: 93 additions & 22 deletions

packages/cli/src/commands/recipe-runtime-setup.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,11 @@ export async function applyRecipeRuntimeSetup(args: {
217217
executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(composerAutoloaderInstallCode) }), "setup", -2, "extra-plugin.install-composer-autoloaders"))
218218
}
219219

220-
const activatedPlugins = extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false)
220+
const deferPluginActivation = recipe.workflow.steps.some((step) => step.command === "wordpress.phpunit"
221+
&& step.args?.includes("bootstrap-mode=managed")
222+
&& step.args?.includes("database-type=mysql")
223+
&& step.args?.includes("multisite=1"))
224+
const activatedPlugins = deferPluginActivation ? [] : extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false)
221225
if (activatedPlugins.length > 0) {
222226
const activePluginsAfterActivation = await phaseTracker.run("activate_plugins", phasePluginActivationData(activatedPlugins), async () => {
223227
for (const plugin of activatedPlugins) {

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
116116
commandArg("phpunit-xml", options.phpunitXml ?? `${pluginTarget}/phpunit.xml.dist`),
117117
commandArg("phpunit-xml-default", options.phpunitXml === undefined ? "1" : ""),
118118
commandStringListArg("dependency-mounts", options.dependencyMounts ?? []),
119+
commandJsonArg("dependency-plugins-json", phpunitDependencyPlugins(options.dependencyMounts ?? [], options.extra_plugins ?? [])),
119120
commandJsonArg("bootstrap-files-json", options.bootstrapFiles ?? []),
120121
commandJsonArg("preload-files-json", options.preloadFiles ?? []),
121122
commandJsonArg("phpunit-args-json", options.phpunitArgs ?? []),
@@ -129,6 +130,15 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
129130
}
130131
}
131132

133+
function phpunitDependencyPlugins(mounts: readonly string[], plugins: readonly WorkspaceRecipeExtraPlugin[]): Array<{ path: string; activate: boolean }> {
134+
return mounts.map((path) => {
135+
const normalized = path.replace(/\/+$/g, "")
136+
const slug = normalized.slice(normalized.lastIndexOf("/") + 1)
137+
const plugin = plugins.find((candidate) => candidate.slug === slug || candidate.mountSlug === slug)
138+
return { path, activate: plugin?.activate !== false }
139+
})
140+
}
141+
132142
function phpunitRuntimeServices(databaseType: WordPressPhpunitRecipeOptions["databaseType"], services: WorkspaceRecipeRuntimeService[] = []): WorkspaceRecipeRuntimeService[] {
133143
if (databaseType !== undefined && databaseType !== "sqlite" && databaseType !== "mysql") {
134144
throw new Error(`Unsupported PHPUnit database type: ${databaseType}`)

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

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export interface PhpunitRunCodeOptions {
1616
env: Record<string, unknown>
1717
wpConfigDefines: Record<string, unknown>
1818
dependencyMounts: string[]
19+
dependencyPlugins?: Array<{ path: string; activate: boolean }>
1920
bootstrapFiles: string[]
2021
preloadFiles?: string[]
2122
bootstrapMode: string
@@ -454,6 +455,7 @@ $phpunit_args_raw = json_decode(${JSON.stringify(JSON.stringify(options.phpunitA
454455
$bench_env = json_decode(${JSON.stringify(JSON.stringify(options.env))}, true);
455456
$wp_config_defines = json_decode(${JSON.stringify(JSON.stringify(options.wpConfigDefines))}, true);
456457
$dep_mounts = ${JSON.stringify(options.dependencyMounts.join("\n"))};
458+
$dependency_plugins = json_decode(${JSON.stringify(JSON.stringify(options.dependencyPlugins ?? []))}, true);
457459
$bootstrap_files = json_decode(${JSON.stringify(JSON.stringify(options.bootstrapFiles))}, true);
458460
$preload_files = json_decode(${JSON.stringify(JSON.stringify(options.preloadFiles ?? []))}, true);
459461
$bootstrap_mode = ${JSON.stringify(options.bootstrapMode || "managed")};
@@ -1029,25 +1031,52 @@ function pg_run_install_stage(array $cfg) {
10291031
function pg_run_load_deps_stage(array $cfg): array {
10301032
pg_stage_begin('load_deps');
10311033
try {
1032-
$loaded = array();
1033-
foreach (explode("\n", (string) ($cfg['dep_mounts'] ?? '')) as $dep_mount) {
1034-
$dep_mount = trim($dep_mount);
1035-
if ($dep_mount === '') {
1036-
continue;
1034+
$result = array('loaded' => array(), 'activate' => array());
1035+
$plugins = is_array($cfg['plugins'] ?? null) ? $cfg['plugins'] : array();
1036+
if (empty($plugins)) {
1037+
foreach (explode("\n", (string) ($cfg['dep_mounts'] ?? '')) as $path) {
1038+
if (trim($path) !== '') {
1039+
$plugins[] = array('path' => $path, 'activate' => true);
1040+
}
1041+
}
1042+
}
1043+
$plugin_root = realpath(WP_PLUGIN_DIR);
1044+
if ($plugin_root === false || !is_dir($plugin_root)) {
1045+
throw new RuntimeException('WordPress plugin directory is unavailable while loading managed PHPUnit dependencies');
1046+
}
1047+
foreach ($plugins as $plugin) {
1048+
if (!is_array($plugin) || !is_string($plugin['path'] ?? null)) {
1049+
throw new RuntimeException('managed PHPUnit dependency metadata must contain a plugin path');
10371050
}
1038-
foreach (glob($dep_mount . '/*.php') ?: array() as $dep_file) {
1051+
$dep_mount = rtrim(str_replace('\\\\', '/', trim($plugin['path'])), '/');
1052+
$dep_real = realpath($dep_mount);
1053+
if ($dep_real === false || !is_dir($dep_real) || dirname($dep_real) !== $plugin_root) {
1054+
throw new RuntimeException('managed PHPUnit dependency must be a direct child directory of WP_PLUGIN_DIR: ' . $dep_mount);
1055+
}
1056+
if ($dep_mount !== WP_PLUGIN_DIR . '/' . basename($dep_mount)) {
1057+
throw new RuntimeException('managed PHPUnit dependency path is not canonical: ' . $dep_mount);
1058+
}
1059+
$loaded_file = null;
1060+
foreach (glob($dep_real . '/*.php') ?: array() as $dep_file) {
10391061
if (basename($dep_file) === 'db.php') {
10401062
continue;
10411063
}
10421064
if (strpos(file_get_contents($dep_file), 'Plugin Name:') !== false) {
10431065
require_once $dep_file;
1044-
$loaded[] = $dep_file;
1066+
$loaded_file = $dep_file;
10451067
break;
10461068
}
10471069
}
1070+
if ($loaded_file === null) {
1071+
throw new RuntimeException('managed PHPUnit dependency has no readable plugin entrypoint: ' . $dep_mount);
1072+
}
1073+
$result['loaded'][] = $loaded_file;
1074+
if (!empty($plugin['activate'])) {
1075+
$result['activate'][] = $loaded_file;
1076+
}
10481077
}
10491078
pg_stage_ok('load_deps');
1050-
return $loaded;
1079+
return $result;
10511080
} catch (Throwable $e) {
10521081
pg_stage_fail('load_deps', $e);
10531082
exit(1);
@@ -1226,10 +1255,11 @@ pg_run_install_stage(array('config_path' => $config_path, 'tests_dir' => $tests_
12261255
pg_remove_new_wordpress_hook_callbacks('shutdown', $pre_component_shutdown_callbacks);
12271256
$pre_dependency_plugins_loaded_callbacks = pg_snapshot_wordpress_hook_callbacks('plugins_loaded');
12281257
$pre_dependency_init_callbacks = pg_snapshot_wordpress_hook_callbacks('init');
1229-
$loaded_dep_files = pg_run_load_deps_stage(array('dep_mounts' => $dep_mounts));
1258+
$dependency_load = pg_run_load_deps_stage(array('dep_mounts' => $dep_mounts, 'plugins' => $dependency_plugins));
1259+
$loaded_dep_files = $dependency_load['loaded'];
12301260
$deferred_dependency_plugins_loaded_callbacks = pg_defer_new_wordpress_hook_callbacks('plugins_loaded', $pre_dependency_plugins_loaded_callbacks);
12311261
$deferred_dependency_init_callbacks = pg_defer_new_wordpress_hook_callbacks('init', $pre_dependency_init_callbacks);
1232-
$activation_files = $loaded_dep_files;
1262+
$activation_files = $dependency_load['activate'];
12331263
if ($loaded_component_file !== null) {
12341264
$activation_files[] = $loaded_component_file;
12351265
}
@@ -1436,7 +1466,8 @@ try {
14361466
$runner = new PHPUnit\\TextUI\\TestRunner();
14371467
$result = $runner->run($suite, $phpunit_args);
14381468
pg_log($result->wasSuccessful() ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED');
1439-
pg_log('TESTS: ' . $result->count() . ' FAILURES: ' . count($result->failures()) . ' ERRORS: ' . count($result->errors()));
1469+
$assertions = class_exists('PHPUnit\\Framework\\Assert') && method_exists('PHPUnit\\Framework\\Assert', 'getCount') ? PHPUnit\\Framework\\Assert::getCount() : 0;
1470+
pg_log('TESTS: ' . $result->count() . ' ASSERTIONS: ' . $assertions . ' FAILURES: ' . count($result->failures()) . ' ERRORS: ' . count($result->errors()));
14401471
pg_stage_ok('run_tests');
14411472
exit($result->wasSuccessful() ? 0 : 1);
14421473
} catch (Throwable $e) {

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -948,6 +948,13 @@ export async function runPhpunitCommand({
948948
env: jsonObjectArg(args, "env-json"),
949949
wpConfigDefines: jsonObjectArg(args, "wp-config-defines-json"),
950950
dependencyMounts: commaListArg(args, "dependency-mounts"),
951+
dependencyPlugins: jsonArrayArg(args, "dependency-plugins-json").map((plugin) => {
952+
if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) throw new Error("dependency-plugins-json entries must be objects")
953+
const metadata = plugin as Record<string, unknown>
954+
const path = typeof metadata.path === "string" ? metadata.path : ""
955+
if (!path) throw new Error("dependency-plugins-json entries require path")
956+
return { path, activate: metadata.activate !== false }
957+
}),
951958
bootstrapFiles: jsonArrayArg(args, "bootstrap-files-json").filter((value): value is string => typeof value === "string"),
952959
preloadFiles: jsonArrayArg(args, "preload-files-json").filter((value): value is string => typeof value === "string"),
953960
bootstrapMode,

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

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,7 +154,7 @@ require_once ABSPATH . 'wp-settings.php';
154154
const multisiteArtifacts = join(directory, "managed-multisite-artifacts")
155155
await mkdir(join(multisitePlugin, "tests"), { recursive: true })
156156
await mkdir(multisiteDependency, { recursive: true })
157-
await writeFile(join(multisiteDependency, "managed-multisite-dependency.php"), "<?php\n/** Plugin Name: Managed Multisite Dependency */\n")
157+
await writeFile(join(multisiteDependency, "managed-multisite-dependency.php"), "<?php\n/** Plugin Name: Managed Multisite Dependency */\nregister_activation_hook(__FILE__, static function (): void { update_network_option(1, 'wp_codebox_dependency_activated', 1); });\n")
158158
await writeFile(join(multisitePlugin, "managed-multisite-fixture.php"), `<?php
159159
/** Plugin Name: Managed Multisite Fixture */
160160
add_action('init', static function (): void {
@@ -195,9 +195,19 @@ final class ManagedMultisiteTest extends WP_UnitTestCase {
195195
196196
public function test_network_schema_seed_and_dependency_are_materialized(): void {
197197
global $wpdb;
198+
foreach (array($wpdb->blogs, $wpdb->blogmeta, $wpdb->site, $wpdb->sitemeta, $wpdb->registration_log, $wpdb->signups) as $table) {
199+
$this->assertSame($table, $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table))));
200+
}
198201
$this->assertGreaterThanOrEqual(1, (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->blogs} WHERE blog_id = 1"));
199202
$this->assertGreaterThan(0, (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->sitemeta} WHERE site_id = 1"));
200-
$this->assertFileExists('/wordpress/wp-content/plugins/managed-multisite-dependency/managed-multisite-dependency.php');
203+
$this->assertTrue(update_network_option(1, 'wp_codebox_network_write', 'round-trip'));
204+
$this->assertSame('round-trip', get_network_option(1, 'wp_codebox_network_write'));
205+
$dependency = WP_PLUGIN_DIR . '/managed-multisite-dependency/managed-multisite-dependency.php';
206+
$this->assertDirectoryExists(dirname($dependency));
207+
$this->assertFileExists($dependency);
208+
require_once ABSPATH . 'wp-admin/includes/plugin.php';
209+
$this->assertTrue(is_plugin_active_for_network('managed-multisite-dependency/managed-multisite-dependency.php'));
210+
$this->assertSame(1, (int) get_network_option(1, 'wp_codebox_dependency_activated'));
201211
}
202212
}
203213
`)
@@ -207,14 +217,17 @@ final class ManagedMultisiteTest extends WP_UnitTestCase {
207217
databaseType: "mysql",
208218
multisite: true,
209219
pluginSource: multisitePlugin,
210-
extra_plugins: [{ source: multisiteDependency, slug: "managed-multisite-dependency", activate: false }],
211-
dependencyMounts: ["/wordpress/wp-content/plugins/managed-multisite-fixture", "/wordpress/wp-content/plugins/managed-multisite-dependency"],
220+
extra_plugins: [{ source: multisiteDependency, slug: "managed-multisite-dependency", activate: true }],
221+
dependencyMounts: ["/wordpress/wp-content/plugins/managed-multisite-dependency"],
212222
mounts: [{ source: join(harness, "vendor"), target: "/wp-codebox-vendor", mode: "readonly" }],
213223
})
214224
await writeFile(multisiteRecipePath, `${JSON.stringify(multisiteRecipe)}\n`)
215225
const multisiteResult = await runRecipe({ recipePath: multisiteRecipePath, artifactsDirectory: multisiteArtifacts, previewHoldBlocking: false, previewLeaseRequested: false, previewLeaseChild: false, timeoutMs: 300_000, json: true, summary: false, dryRun: false })
216226
const multisiteFailure = multisiteResult as typeof multisiteResult & { error?: unknown }
217227
assert.equal(multisiteResult.success, true, JSON.stringify({ error: multisiteFailure.error, executions: multisiteResult.executions }))
228+
const latest = JSON.parse(await readFile(join(multisiteArtifacts, "latest-runtime.json"), "utf8")) as { paths?: { runtimeDirectory?: string } }
229+
const diagnostic = await readFile(join(multisiteArtifacts, latest.paths?.runtimeDirectory ?? "", "files/phpunit/.pg-test-result.txt"), "utf8")
230+
assert.match(diagnostic, /TESTS: [1-9][0-9]* ASSERTIONS: [1-9][0-9]* FAILURES: 0 ERRORS: 0/, "managed multisite regression must execute nonzero tests and assertions")
218231
console.log("disposable MySQL and MariaDB mysqli E2E passed")
219232
} finally {
220233
await rm(directory, { recursive: true, force: true })

tests/phpunit-project-autoload.test.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -793,10 +793,10 @@ assert.ok(managedModeCode.includes("configured PHPUnit harness autoload file is
793793
assert.ok(managedModeCode.includes("define('DB_NAME', ':memory:');"), "default managed PHPUnit remains on SQLite")
794794
assert.ok(managedModeCode.includes("'cacheResult' => false"))
795795
assert.ok(managedModeCode.includes("global $argv, $pg_stage_output_buffering, $wp_rewrite;"), "managed WordPress installation must expose the rewrite global required by multisite setup")
796-
assert.ok(managedModeCode.includes("foreach ($multisite_defines as $name => $value)"), "managed multisite must establish network constants before the WordPress test installer runs")
796+
assert.ok(managedModeCode.includes("foreach ($multisite_defines as $name => $value)"), "managed multisite establishes network constants for the post-install test bootstrap")
797797
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")
798798
const installStageIndex = managedModeCode.indexOf("pg_run_install_stage(array(")
799-
const dependencyLoadStageIndex = managedModeCode.indexOf("$loaded_dep_files = pg_run_load_deps_stage", installStageIndex)
799+
const dependencyLoadStageIndex = managedModeCode.indexOf("$dependency_load = pg_run_load_deps_stage", installStageIndex)
800800
const activationStageIndex = managedModeCode.indexOf("pg_run_activation_stage", dependencyLoadStageIndex)
801801
const dependencyPluginsLoadedSnapshotIndex = managedModeCode.indexOf("$pre_dependency_plugins_loaded_callbacks = pg_snapshot_wordpress_hook_callbacks('plugins_loaded');", installStageIndex)
802802
const dependencyPluginsLoadedDeferIndex = managedModeCode.indexOf("$deferred_dependency_plugins_loaded_callbacks = pg_defer_new_wordpress_hook_callbacks('plugins_loaded', $pre_dependency_plugins_loaded_callbacks);", dependencyLoadStageIndex)
@@ -825,6 +825,9 @@ assert.deepEqual(dependencyRecipe.inputs.extra_plugins, [{
825825
activate: false,
826826
}])
827827
assert.ok(dependencyRecipe.workflow.steps[0].args.includes("dependency-mounts=/wordpress/wp-content/plugins/dependency"))
828+
assert.ok(dependencyRecipe.workflow.steps[0].args.includes('dependency-plugins-json=[{"path":"/wordpress/wp-content/plugins/dependency","activate":false}]'), "dependency activation intent is carried into managed PHPUnit")
829+
assert.ok(managedModeCode.includes("dirname($dep_real) !== $plugin_root"), "dependency loading rejects paths outside a direct WP_PLUGIN_DIR child")
830+
assert.ok(managedModeCode.includes("managed PHPUnit dependency path is not canonical"), "dependency loading rejects non-canonical sandbox paths")
828831

829832
const multisiteRecipe = buildWordPressPhpunitRecipe({
830833
pluginSlug: "network-plugin",

tests/recipe-runtime-setup-staged-materialization.test.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const recipe: WorkspaceRecipe = {
2121
mode: "readonly",
2222
}],
2323
},
24-
workflow: { steps: [] },
24+
workflow: { steps: [{ command: "wordpress.phpunit", args: ["bootstrap-mode=managed", "database-type=mysql", "multisite=1"] }] },
2525
}
2626
const prepared: PreparedRecipeRuntimeSetup = {
2727
workspaceMounts: [],
@@ -30,7 +30,7 @@ const prepared: PreparedRecipeRuntimeSetup = {
3030
slug: "fixture-dependency",
3131
target: "/wordpress/wp-content/plugins/fixture-dependency",
3232
pluginFile: "fixture-dependency/fixture-dependency.php",
33-
activate: false,
33+
activate: true,
3434
loadAs: "plugin",
3535
cleanupPaths: [],
3636
provenance: { kind: "local", original: "/tmp/host-extra-plugin" },
@@ -96,7 +96,10 @@ const runtime = {
9696
])
9797
},
9898
async materializeMounts() { throw new Error("setup should use materializeStagedInputs when available") },
99-
async execute(spec) { calls.push(`execute:${spec.command}`); throw new Error("setup should not execute commands in this fixture") },
99+
async execute(spec) {
100+
calls.push(`execute:${spec.command}:${(spec.args ?? []).join(" ")}`)
101+
return { id: "setup", command: spec.command, args: spec.args ?? [], exitCode: 0, stdout: "", stderr: "", startedAt: new Date().toISOString(), finishedAt: new Date().toISOString() }
102+
},
100103
async observe() { throw new Error("unused") },
101104
async snapshot() { throw new Error("unused") },
102105
async collectArtifacts() { throw new Error("unused") },
@@ -135,7 +138,7 @@ assert.ok(materializeOperationIndex > inputMountIndex, "materialization is sched
135138
assert.ok(materializeOperationIndex > mountIndex, "materialization is scheduled after staged mount")
136139
assert.ok(materializeOperationIndex > extraPluginMountIndex, "materialization is scheduled after extra-plugin mount")
137140
assert.ok(materializeIndex > materializeOperationIndex, "materialization runs inside the setup phase before commands")
138-
assert.equal(calls.some((call) => call.startsWith("execute:")), false)
141+
assert.equal(calls.some((call) => call.includes("activation-recipe-plugin")), false, "managed MySQL multisite defers requested dependency activation until after network installation")
139142

140143
const executedWorkflowSpecs: ExecutionSpec[] = []
141144
const workflowRuntime = {

0 commit comments

Comments
 (0)