diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index cbcdc33f..efb9ad91 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -351,13 +351,56 @@ function blueprintWithWpConfigDefines(blueprint: unknown, defines: JsonObject): } } + +/** + * Repair `site_admins` after Playground's `enableMultisite` step. + * + * The network install leaves `site_admins` as an empty string rather than the + * `array( $admin_login )` core's populate_network() normally writes — + * `admin_user_id` and `admin_email` come out correct, so the install only + * partially succeeds. An empty value is worse than a missing one: core falls + * back to a sane default only when the option is absent + * (`get_site_option( 'site_admins', array( 'admin' ) )`), so the empty string + * is returned instead and reaches `in_array()`: + * + * TypeError: in_array(): Argument #2 ($haystack) must be of type array, + * string given (wp-includes/capabilities.php) + * + * That fatals `is_super_admin()`, `grant_super_admin()` and every + * `manage_network_*` capability check, so any multisite suite touching network + * capabilities dies inside core rather than failing usefully. + * + * Idempotent and conservative: only writes when the current value is not a + * non-empty array, and derives the login from `admin_user_id` rather than + * assuming "admin". + */ +function multisiteSiteAdminsRepairPhp(): string { + return ` +$admins = get_site_option('site_admins'); +if (!is_array($admins) || $admins === array()) { + $login = null; + $admin_user_id = (int) get_site_option('admin_user_id'); + if ($admin_user_id > 0) { + $user = get_userdata($admin_user_id); + if ($user && $user->user_login !== '') { $login = $user->user_login; } + } + if ($login === null) { + $first = get_users(array('number' => 1, 'orderby' => 'ID', 'order' => 'ASC', 'fields' => array('user_login'))); + if (!empty($first)) { $login = $first[0]->user_login; } + } + if ($login !== null) { update_site_option('site_admins', array($login)); } +}` +} + function blueprintWithMultisite(blueprint: unknown, multisite: boolean): unknown { if (!multisite) { return blueprint } + const multisiteSteps = [{ step: "enableMultisite" }, { step: "runPHP", code: multisiteSiteAdminsRepairPhp() }] + if (!isPlainObject(blueprint)) { - return { steps: [{ step: "enableMultisite" }] } + return { steps: multisiteSteps } } const existingSteps = Array.isArray(blueprint.steps) ? blueprint.steps : [] @@ -366,7 +409,7 @@ function blueprintWithMultisite(blueprint: unknown, multisite: boolean): unknown } return { ...blueprint, - steps: [{ step: "enableMultisite" }, ...existingSteps], + steps: [...multisiteSteps, ...existingSteps], } } diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index a0d9d6d5..e38cf592 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -930,10 +930,15 @@ const multisiteRecipe = buildWordPressPhpunitRecipe({ multisite: true, blueprint: { steps: [{ step: "setSiteOptions", options: { blogname: "Network tests" } }] }, }) -assert.deepEqual((multisiteRecipe.runtime.blueprint as { steps: unknown[] }).steps, [ - { step: "enableMultisite" }, +const multisiteSteps = (multisiteRecipe.runtime.blueprint as { steps: Array<{ step?: string; code?: string }> }).steps +assert.equal(multisiteSteps[0]?.step, "enableMultisite", "multisite PHPUnit recipes must boot Playground as multisite before running tests") +// enableMultisite leaves site_admins as an empty string, which fatals core's +// capability checks; the repair step must follow it before anything else runs. +assert.equal(multisiteSteps[1]?.step, "runPHP", "multisite recipes must repair site_admins immediately after enableMultisite") +assert.ok(multisiteSteps[1]?.code?.includes("site_admins"), "the step after enableMultisite must be the site_admins repair") +assert.deepEqual(multisiteSteps.slice(2), [ { step: "setSiteOptions", options: { blogname: "Network tests" } }, -], "multisite PHPUnit recipes must boot Playground as multisite before running tests") +], "caller-supplied blueprint steps must be preserved after the multisite bootstrap") 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")) diff --git a/tests/playground-multisite-site-admins.test.ts b/tests/playground-multisite-site-admins.test.ts new file mode 100644 index 00000000..1eea2fdc --- /dev/null +++ b/tests/playground-multisite-site-admins.test.ts @@ -0,0 +1,46 @@ +import assert from "node:assert/strict" + +import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.js" + +/* + * Playground's enableMultisite step leaves site_admins as an empty string + * instead of array( $admin_login ). Because the option exists, core's + * get_site_option( 'site_admins', array( 'admin' ) ) default never applies, and + * the empty string reaches in_array() — fatalling is_super_admin(), + * grant_super_admin() and every manage_network_* check inside core. + * + * A multisite recipe must therefore carry a repair step, ordered after + * enableMultisite. + */ +const recipe = buildWordPressPhpunitRecipe({ + pluginSlug: "example-plugin", + multisite: true, + mounts: [], +} as never) as { runtime?: { blueprint?: { steps?: Array<{ step?: string; code?: string }> } } } + +const steps = recipe.runtime?.blueprint?.steps ?? [] +const names = steps.map((step) => step.step) + +assert.ok(names.includes("enableMultisite"), `expected enableMultisite, got ${JSON.stringify(names)}`) + +const repairIndex = steps.findIndex( + (step) => step.step === "runPHP" && typeof step.code === "string" && step.code.includes("site_admins"), +) +assert.ok(repairIndex >= 0, `expected a runPHP site_admins repair step, got ${JSON.stringify(names)}`) +assert.ok( + names.indexOf("enableMultisite") < repairIndex, + "site_admins repair must run after enableMultisite", +) + +const repairCode = steps[repairIndex]?.code ?? "" +assert.ok(repairCode.includes("is_array"), "repair must only write when the value is not already a valid array") +assert.ok(repairCode.includes("admin_user_id"), "repair should derive the login from admin_user_id, not assume 'admin'") + +// Single-site recipes must not carry multisite steps at all. +const single = buildWordPressPhpunitRecipe({ pluginSlug: "example-plugin", multisite: false, mounts: [] } as never) as { + runtime?: { blueprint?: { steps?: Array<{ step?: string }> } } +} +const singleNames = (single.runtime?.blueprint?.steps ?? []).map((step) => step.step) +assert.ok(!singleNames.includes("enableMultisite"), "single-site recipe must not enable multisite") + +console.log("multisite site_admins repair step present, ordered, and single-site unaffected")