diff --git a/packages/cli/src/commands/recipe-run.ts b/packages/cli/src/commands/recipe-run.ts index b7350b27..faeb3a91 100644 --- a/packages/cli/src/commands/recipe-run.ts +++ b/packages/cli/src/commands/recipe-run.ts @@ -296,7 +296,7 @@ export async function runRecipe(options: RecipeRunOptions, interruption?: Recipe }) interruption?.throwIfInterrupted() - executions.push(...(await applyRecipeRuntimeSetup({ recipe, recipeDirectory, runtime, prepared: preparedRuntimeSetup, phaseExecutor, interruption })).executions) + executions.push(...(await applyRecipeRuntimeSetup({ recipe, recipeDirectory, runtime, runtimeSpec: runtimeCreateSpec, prepared: preparedRuntimeSetup, phaseExecutor, interruption })).executions) fixtureDatabases = await phaseTracker.run("import_fixture_databases", phaseFixtureDatabaseData(recipe), async () => await awaitRecipe("fixture-databases.import", importRecipeFixtureDatabases(recipe, recipeDirectory, runtime!, executions))) const siteSeeds = await awaitRecipe("site-seeds.import", importRecipeSiteSeeds(recipe, recipeDirectory, runtime!, executions)) diff --git a/packages/cli/src/commands/recipe-runtime-setup.ts b/packages/cli/src/commands/recipe-runtime-setup.ts index aa7fe3fe..f9c40e12 100644 --- a/packages/cli/src/commands/recipe-runtime-setup.ts +++ b/packages/cli/src/commands/recipe-runtime-setup.ts @@ -1,7 +1,8 @@ import { cp, mkdtemp, rm, stat } from "node:fs/promises" import { tmpdir } from "node:os" import { join, posix, resolve } from "node:path" -import { phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core" +import { phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type RuntimeCreateSpec, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core" +import { requiresManagedMysqlMultisitePreinstall } from "@automattic/wp-codebox-playground" import { installMuPluginsCode, installPluginComposerAutoloadersCode, prepareRecipeDependencyOverlays, prepareRecipeExtraPlugins, prepareRecipeRuntimeOverlays, prepareRecipeStagedFiles, prepareRecipeWorkspacePreloads, prepareRecipeWorkspaces, recipeMountType, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js" import { pluginRuntimeHealthProbeStep, type RecipeWorkflowPhase } from "../recipe-validation.js" import { pluginRuntimeHealthProbeStepIndex, pluginRuntimeSetupStepIndex } from "../recipe-dry-run.js" @@ -52,9 +53,10 @@ export async function applyRecipeRuntimeSetup(args: { runtime: Runtime prepared: PreparedRecipeRuntimeSetup phaseExecutor: RecipeRunPhaseExecutor + runtimeSpec: Pick interruption?: RecipeInterruptionController }): Promise { - const { recipe, recipeDirectory, runtime, prepared, phaseExecutor, interruption } = args + const { recipe, recipeDirectory, runtime, runtimeSpec, prepared, phaseExecutor, interruption } = args const { workspaceMounts, extraPlugins, dependencyOverlays, stagedFiles, overlays, inputMountBaselinePaths, inputMountPathMap } = prepared const executions: RecipeExecutionResult[] = [] const phaseTracker = phaseExecutor.tracker @@ -127,19 +129,20 @@ export async function applyRecipeRuntimeSetup(args: { interruption?.throwIfInterrupted() } + const extraPluginMounts: MountSpec[] = extraPlugins.map((plugin) => ({ + type: "directory", + source: plugin.source, + target: plugin.target, + mode: "readonly", + metadata: { + kind: "extra-plugin", + slug: plugin.slug, + source: plugin.provenance, + }, + })) await phaseTracker.run("mount_plugins", phasePluginMountData(extraPlugins), async () => { - for (const plugin of extraPlugins) { - await awaitRecipe(`extra-plugin.mount:${plugin.slug}`, runtime.mount({ - type: "directory", - source: plugin.source, - target: plugin.target, - mode: "readonly", - metadata: { - kind: "extra-plugin", - slug: plugin.slug, - source: plugin.provenance, - }, - })) + for (const [index, plugin] of extraPlugins.entries()) { + await awaitRecipe(`extra-plugin.mount:${plugin.slug}`, runtime.mount(extraPluginMounts[index])) interruption?.throwIfInterrupted() } }) @@ -190,6 +193,7 @@ export async function applyRecipeRuntimeSetup(args: { } const materializableMounts: MountSpec[] = [ + ...extraPluginMounts, ...inputMounts, ...stagedFiles.map((stagedFile) => ({ type: stagedFile.type, @@ -205,17 +209,19 @@ export async function applyRecipeRuntimeSetup(args: { interruption?.throwIfInterrupted() } - const muPluginInstallCode = installMuPluginsCode(extraPlugins) + const isolateManagedMultisitePreinstall = recipeHasManagedMysqlMultisitePhpunit(recipe, runtimeSpec) + const muPluginInstallCode = isolateManagedMultisitePreinstall ? null : installMuPluginsCode(extraPlugins) if (muPluginInstallCode) { executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(muPluginInstallCode) }), "setup", -2, "extra-plugin.install-mu-loader")) } - const composerAutoloaderInstallCode = installPluginComposerAutoloadersCode(extraPlugins) + const composerAutoloaderInstallCode = isolateManagedMultisitePreinstall ? null : installPluginComposerAutoloadersCode(extraPlugins) if (composerAutoloaderInstallCode) { executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(composerAutoloaderInstallCode) }), "setup", -2, "extra-plugin.install-composer-autoloaders")) } - const activatedPlugins = extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false) + const deferredPluginFiles = managedPhpunitDeferredPluginFiles(recipe, runtimeSpec) + const activatedPlugins = extraPlugins.filter((plugin) => plugin.loadAs === "plugin" && plugin.activate !== false && !deferredPluginFiles.has(plugin.pluginFile)) if (activatedPlugins.length > 0) { const activePluginsAfterActivation = await phaseTracker.run("activate_plugins", phasePluginActivationData(activatedPlugins), async () => { for (const plugin of activatedPlugins) { @@ -243,6 +249,28 @@ export async function applyRecipeRuntimeSetup(args: { return { executions } } +function managedPhpunitDeferredPluginFiles(recipe: WorkspaceRecipe, runtimeSpec: Pick): Set { + const deferred = new Set() + for (const step of [...(recipe.workflow.before ?? []), ...recipe.workflow.steps, ...(recipe.workflow.after ?? [])]) { + if (step.command !== "wordpress.phpunit" || !requiresManagedMysqlMultisitePreinstall(step.args ?? [], runtimeSpec)) continue + const raw = (step.args ?? []).find((arg) => arg.startsWith("dependency-plugins-json="))?.slice("dependency-plugins-json=".length) + if (!raw) continue + const plugins = JSON.parse(raw) as unknown + if (!Array.isArray(plugins)) throw new Error("dependency-plugins-json must be a JSON array") + for (const plugin of plugins) { + if (plugin && typeof plugin === "object" && !Array.isArray(plugin) && typeof (plugin as { pluginFile?: unknown }).pluginFile === "string") { + deferred.add((plugin as { pluginFile: string }).pluginFile) + } + } + } + return deferred +} + +function recipeHasManagedMysqlMultisitePhpunit(recipe: WorkspaceRecipe, runtimeSpec: Pick): boolean { + return [...(recipe.workflow.before ?? []), ...recipe.workflow.steps, ...(recipe.workflow.after ?? [])] + .some((step) => step.command === "wordpress.phpunit" && requiresManagedMysqlMultisitePreinstall(step.args ?? [], runtimeSpec)) +} + export async function cleanupInputMountBaselines(paths: string[]): Promise { await Promise.all(paths.map((path) => rm(path, { recursive: true, force: true }))) paths.length = 0 diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index 713d1f36..849e8749 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -4,6 +4,7 @@ export { buildRuntimePackageRunRecipe, CODEBOX_RUN_RUNTIME_PACKAGE_ABILITY, RUNT export { RUNTIME_PACKAGE_DIAGNOSTIC_SCHEMA, RUNTIME_PACKAGE_RESULT_SCHEMA, RUNTIME_PACKAGE_TASK_SCHEMA, normalizeRuntimePackageResult, normalizeRuntimePackageTask, validateRuntimePackageTask, type RuntimePackageDiagnostic, type RuntimePackageResult, type RuntimePackageTask } from "./runtime-package-contracts.js" import { normalizeSharedMounts } from "./mount-primitives.js" import { DEFAULT_WORDPRESS_VERSION } from "./runtime-defaults.js" +import { resolvePluginEntrypointContract } from "./component-contracts.js" type JsonObject = Record @@ -76,6 +77,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio const pluginTarget = `/wordpress/wp-content/plugins/${pluginSlug}` const autoloadFile = options.autoloadFile ?? (options.bootstrapMode === "project" ? "" : "/wp-codebox-vendor/autoload.php") const services = phpunitRuntimeServices(options.databaseType, options.services) + const extraPlugins = normalizeExtraPlugins(options.extra_plugins) return { schema: "wp-codebox/workspace-recipe/v1", @@ -90,7 +92,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio ...(options.backendPackage ? { backendPackage: options.backendPackage } : {}), }, inputs: { - extra_plugins: normalizeExtraPlugins(options.extra_plugins), + extra_plugins: extraPlugins, ...(services.length > 0 ? { services } : {}), mounts: normalizeRecipeMounts([ ...(options.pluginSource ? [{ source: options.pluginSource, target: pluginTarget } satisfies WorkspaceRecipeMount] : []), @@ -116,6 +118,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio commandArg("phpunit-xml", options.phpunitXml ?? `${pluginTarget}/phpunit.xml.dist`), commandArg("phpunit-xml-default", options.phpunitXml === undefined ? "1" : ""), commandStringListArg("dependency-mounts", options.dependencyMounts ?? []), + commandJsonArg("dependency-plugins-json", phpunitDependencyPlugins(options.dependencyMounts ?? [], extraPlugins)), commandJsonArg("bootstrap-files-json", options.bootstrapFiles ?? []), commandJsonArg("preload-files-json", options.preloadFiles ?? []), commandJsonArg("phpunit-args-json", options.phpunitArgs ?? []), @@ -129,6 +132,24 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio } } +function phpunitDependencyPlugins(mounts: readonly string[], plugins: readonly WorkspaceRecipeExtraPlugin[]): Array<{ path: string; pluginFile: string; activate: boolean; loadAs: "plugin" | "mu-plugin" }> { + return mounts.map((path) => { + const normalized = path.replace(/\/+$/g, "") + const slug = normalized.slice(normalized.lastIndexOf("/") + 1) + const plugin = plugins.find((candidate) => candidate.slug === slug || candidate.mountSlug === slug) + const loadAs = plugin?.loadAs === "mu-plugin" ? "mu-plugin" : "plugin" + const pluginFile = plugin + ? resolvePluginEntrypointContract({ source: plugin.source ?? plugin.sourcePath ?? "", slug, pluginFile: plugin.pluginFile, loadAs }).pluginFile + : `${slug}/${slug}.php` + return { + path: loadAs === "mu-plugin" ? `/wordpress/wp-content/mu-plugins/contained-runtime/${slug}` : path, + pluginFile, + activate: plugin?.activate !== false, + loadAs, + } + }) +} + function phpunitRuntimeServices(databaseType: WordPressPhpunitRecipeOptions["databaseType"], services: WorkspaceRecipeRuntimeService[] = []): WorkspaceRecipeRuntimeService[] { if (databaseType !== undefined && databaseType !== "sqlite" && databaseType !== "mysql") { throw new Error(`Unsupported PHPUnit database type: ${databaseType}`) diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index ec701682..202403cd 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -20,6 +20,7 @@ export { PLAYWRIGHT_CLOCK_CONTROL_CAPABILITIES, createBrowserClockController, ty export { PLAYWRIGHT_BROWSER_ENVIRONMENT_CAPABILITIES, applyPlaywrightPageEnvironment, browserEnvironmentCell, createPlaywrightBrowserEnvironmentContext, observePlaywrightBrowserEnvironment, resolvePlaywrightBrowserEnvironment, runPlaywrightBrowserEnvironmentMatrix, type BrowserEnvironmentCpuProfile, type BrowserEnvironmentNetworkProfile, type PlaywrightBrowserEnvironmentExecutionInput, type PlaywrightBrowserEnvironmentOptions, type PlaywrightBrowserEnvironmentRuntime, type PlaywrightBrowserEnvironmentSession } from "./browser-environment-matrix.js" export { WORDPRESS_ADVERSARIAL_ADAPTER_SCHEMA, WORDPRESS_ADVERSARIAL_CAPABILITIES, WORDPRESS_ADVERSARIAL_ORACLES, WORDPRESS_CLOCK_CONTROL_CAPABILITIES, WORDPRESS_HTTP_TRANSPORT_FAULT_CAPABILITIES, createWordPressAdversarialAdapter, evaluateWordPressAdversarialOracles, negotiateWordPressHttpTransportFaults, wordpressAdversarialActionSpec, wordpressHttpFaultConfigurationAction, wordpressNoveltySignals, wordpressSchedulerClockAction, type WordPressAdapterFidelity, type WordPressAdversarialAction, type WordPressAdversarialAdapter, type WordPressAdversarialCapability, type WordPressAdversarialSurface } from "./wordpress-adversarial-adapter.js" export { normalizePreviewReviewerAccess, previewReviewerAccess } from "./preview-reviewer-access.js" +export { phpunitExecutionSemantics, requiresManagedMysqlMultisitePreinstall, type PhpunitExecutionSemantics } from "./phpunit-command-semantics.js" export { applyVfsMountSnapshots, materializePlaygroundMountsFromVfs, materializePlaygroundStagedInputs, type HostMountSnapshot, type MountMaterializationResult, type StagedInputMaterializationResult, type VfsMountSnapshot } from "./mount-materialization.js" export { buildReplayExportBlueprint, buildReplayableWordPressSiteBlueprint, buildReplayableWordPressSiteLimitations, writeReplayExportPackage, writeReplayableWordPressSiteBundle, type ReplayExportPackage, type ReplayExportPackageOptions, type ReplayableWordPressSiteBundle, type ReplayableWordPressSiteBundleManifest, type ReplayableWordPressSiteBundleOptions } from "./replayable-wordpress-site-bundle.js" export { RUNTIME_CAPTURE_STATUS_SCHEMA, runtimeCaptureStatus, type RuntimeCaptureDiagnostic, type RuntimeCaptureState, type RuntimeCaptureStatus, type RuntimeCaptureStatusInput } from "./runtime-capture-status.js" diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index b54a6bdb..542dee9d 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -16,12 +16,14 @@ export interface PhpunitRunCodeOptions { env: Record wpConfigDefines: Record dependencyMounts: string[] + dependencyPlugins?: Array<{ path: string; pluginFile: string; activate: boolean; loadAs: "plugin" | "mu-plugin" }> bootstrapFiles: string[] preloadFiles?: string[] bootstrapMode: string projectBootstrap: string multisite: boolean databaseType: "sqlite" | "mysql" + managedMultisitePreinstalled?: boolean /** * Sandbox-internal, writable path for the structured diagnostics log. Defaults * to a /tmp path so diagnostics survive read-only plugin mounts and a mid-install @@ -30,6 +32,8 @@ export interface PhpunitRunCodeOptions { resultFile?: string } +export type PhpunitMultisitePreinstallCodeOptions = Pick + export interface CorePhpunitRunCodeOptions { coreRoot: string testsDir: string @@ -334,6 +338,105 @@ function ${functionName}(array $argv) { }` } +function managedPhpunitConfigWriterPhp(): string { + return `function pg_write_managed_test_config(array $extra_defines, string $table_prefix, string $database_type): string { + $config_path = '/tmp/wp-tests-config.php'; + $config = " getenv('DB_NAME') ?: 'runtime', + 'DB_USER' => getenv('DB_USER') ?: 'runtime', + 'DB_PASSWORD' => getenv('DB_PASSWORD') ?: '', + 'DB_HOST' => $db_host, + ) as $name => $value) { + $config .= 'define(' . var_export($name, true) . ', ' . var_export($value, true) . ");\n"; + } + } else { + $config .= <<<'CONFIG' +define('DB_NAME', ':memory:'); +define('DB_USER', 'root'); +define('DB_PASSWORD', ''); +define('DB_HOST', 'localhost'); +CONFIG; + } + $config .= <<<'CONFIG' +define('DB_CHARSET', 'utf8'); +define('WP_TESTS_DOMAIN', 'example.org'); +define('WP_TESTS_EMAIL', 'admin@example.org'); +define('WP_TESTS_TITLE', 'Test Blog'); +define('WP_PHP_BINARY', 'php'); +define('ABSPATH', '/wordpress/'); +define('FS_CHMOD_FILE', 0644); +define('FS_CHMOD_DIR', 0755); +define('FS_METHOD', 'direct'); +CONFIG; + file_put_contents($config_path, $config); + return $config_path; +}` +} + +export function phpunitMultisitePreinstallCode(options: PhpunitMultisitePreinstallCodeOptions): string { + const wpConfigDefines = { ...options.wpConfigDefines } + for (const name of ["MULTISITE", "SUBDOMAIN_INSTALL", "DOMAIN_CURRENT_SITE", "PATH_CURRENT_SITE", "SITE_ID_CURRENT_SITE", "BLOG_ID_CURRENT_SITE", "WPMU_PLUGIN_DIR"]) { + delete wpConfigDefines[name] + } + wpConfigDefines.WPMU_PLUGIN_DIR = "/tmp/wp-codebox-preinstall-mu-plugins" + return `error_reporting(E_ALL); +ini_set('display_errors', '1'); +ini_set('display_startup_errors', '1'); + +$tests_dir = ${JSON.stringify(options.testsDir)}; +$bench_env = json_decode(${JSON.stringify(JSON.stringify(options.env))}, true); +$wp_config_defines = json_decode(${JSON.stringify(JSON.stringify(wpConfigDefines))}, true); +$database_type = ${JSON.stringify(options.databaseType)}; +$result_file = ${JSON.stringify(options.resultFile ?? PLUGIN_PHPUNIT_RESULT_FILE)}; +$preinstall_complete = false; + +@file_put_contents($result_file, ''); +register_shutdown_function(static function () use (&$preinstall_complete, $result_file): void { + if ($preinstall_complete) { + return; + } + $failure = error_get_last(); + if (is_array($failure) && in_array($failure['type'] ?? 0, array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR, E_RECOVERABLE_ERROR), true)) { + @file_put_contents($result_file, 'STAGE_FATAL:preinstall:' . (string) ($failure['message'] ?? '') . ' at ' . (string) ($failure['file'] ?? '') . ':' . (int) ($failure['line'] ?? 0) . "\n", FILE_APPEND); + } +}); + +${phpEnvAssignmentFunction("pg_apply_env", "json_encode", "error_log('Skipping invalid environment key: ' . var_export($name, true));")} +${phpWpConfigDefineAppenderFunction("pg_append_wp_config_defines", "error_log('Skipping invalid wp_config_defines key: ' . var_export($name, true));")} +${managedPhpunitConfigWriterPhp()} + +pg_apply_env($bench_env); +if (!is_dir('/tmp/wp-codebox-preinstall-mu-plugins') && !mkdir('/tmp/wp-codebox-preinstall-mu-plugins', 0700, true) && !is_dir('/tmp/wp-codebox-preinstall-mu-plugins')) { + throw new RuntimeException('Could not create isolated multisite preinstall mu-plugin directory.'); +} +$config_path = pg_write_managed_test_config($wp_config_defines, 'wptests_', $database_type); +if (!defined('WP_TESTS_MULTISITE')) { + define('WP_TESTS_MULTISITE', true); +} +$argv = array('install.php', $config_path, 'run_ms_tests', 'no_core_tests'); +$_SERVER['argv'] = $argv; +$_SERVER['argc'] = count($argv); +try { + require $tests_dir . '/includes/install.php'; + $preinstall_complete = true; +} catch (Throwable $error) { + @file_put_contents($result_file, 'STAGE_FAIL:preinstall:' . get_class($error) . ': ' . $error->getMessage() . ' at ' . $error->getFile() . ':' . $error->getLine() . "\n", FILE_APPEND); + throw $error; +}` +} + export function phpunitRunCode(options: PhpunitRunCodeOptions): string { return `error_reporting(E_ALL); ini_set('display_errors', '1'); @@ -357,12 +460,14 @@ $phpunit_args_raw = json_decode(${JSON.stringify(JSON.stringify(options.phpunitA $bench_env = json_decode(${JSON.stringify(JSON.stringify(options.env))}, true); $wp_config_defines = json_decode(${JSON.stringify(JSON.stringify(options.wpConfigDefines))}, true); $dep_mounts = ${JSON.stringify(options.dependencyMounts.join("\n"))}; +$dependency_plugins = json_decode(${JSON.stringify(JSON.stringify(options.dependencyPlugins ?? []))}, true); $bootstrap_files = json_decode(${JSON.stringify(JSON.stringify(options.bootstrapFiles))}, true); $preload_files = json_decode(${JSON.stringify(JSON.stringify(options.preloadFiles ?? []))}, true); $bootstrap_mode = ${JSON.stringify(options.bootstrapMode || "managed")}; $project_bootstrap = ${JSON.stringify(options.projectBootstrap)}; $multisite = ${JSON.stringify(options.multisite)}; $database_type = ${JSON.stringify(options.databaseType)}; +$managed_multisite_preinstalled = ${JSON.stringify(options.managedMultisitePreinstalled ?? false)}; function pg_build_phpunit_argv($raw): array { $phpunit_argv = array('phpunit'); @@ -415,6 +520,8 @@ ${phpEnvAssignmentFunction("pg_apply_env", "json_encode", "pg_log('NOTICE: skipp ${phpWpConfigDefineAppenderFunction("pg_append_wp_config_defines", "pg_log('NOTICE: skipping invalid wp_config_defines key: ' . var_export($name, true));")} +${managedPhpunitConfigWriterPhp()} + function pg_diagnostic_context(): string { global $current_stage; $hook = function_exists('current_filter') ? current_filter() : null; @@ -661,48 +768,8 @@ function pg_run_boot_stage(array $cfg = []): ?string { $autoload_required = !empty($cfg['autoload_required']); $extra_defines = $cfg['extra_defines'] ?? array(); $table_prefix = isset($cfg['table_prefix']) && is_string($cfg['table_prefix']) && $cfg['table_prefix'] !== '' ? $cfg['table_prefix'] : 'wptests_'; - $config_path = '/tmp/wp-tests-config.php'; - $config = " getenv('DB_NAME') ?: 'runtime', - 'DB_USER' => getenv('DB_USER') ?: 'runtime', - 'DB_PASSWORD' => getenv('DB_PASSWORD') ?: '', - 'DB_HOST' => $db_host, - ) as $name => $value) { - $config .= 'define(' . var_export($name, true) . ', ' . var_export($value, true) . ");\n"; - } - } else { - $config .= <<<'CONFIG' -define('DB_NAME', ':memory:'); -define('DB_USER', 'root'); -define('DB_PASSWORD', ''); -define('DB_HOST', 'localhost'); -CONFIG; - } - $config .= <<<'CONFIG' -define('DB_CHARSET', 'utf8'); -define('WP_TESTS_DOMAIN', 'example.org'); -define('WP_TESTS_EMAIL', 'admin@example.org'); -define('WP_TESTS_TITLE', 'Test Blog'); -define('WP_PHP_BINARY', 'php'); -define('ABSPATH', '/wordpress/'); -define('FS_CHMOD_FILE', 0644); -define('FS_CHMOD_DIR', 0755); -define('FS_METHOD', 'direct'); -CONFIG; - file_put_contents($config_path, $config); + $config_path = pg_write_managed_test_config($extra_defines, $table_prefix, $database_type); if ($harness_autoload !== '' && is_readable($harness_autoload)) { pg_preload_wp_cli_namespaced_functions($harness_autoload); require_once $harness_autoload; @@ -967,28 +1034,91 @@ function pg_run_install_stage(array $cfg) { } } +function pg_run_preinstalled_wordpress_stage(array $cfg): void { + global $argv, $pg_stage_output_buffering, $wp_rewrite; + pg_stage_begin('install'); + try { + $config_path = $cfg['config_path']; + $tests_dir = $cfg['tests_dir']; + require_once $config_path; + tests_reset__SERVER(); + $GLOBALS['PHP_SELF'] = '/index.php'; + $_SERVER['PHP_SELF'] = '/index.php'; + tests_add_filter('wp_die_handler', '_wp_die_handler_filter_exit'); + $pg_stage_output_buffering = true; + ob_start(); + require_once ABSPATH . 'wp-settings.php'; + while (ob_get_level() > 0) { + @ob_end_clean(); + } + $pg_stage_output_buffering = false; + if (is_dir($tests_dir . '/data/themedir1')) { + register_theme_directory($tests_dir . '/data/themedir1'); + } + pg_log('NOTICE:using canonical preinstalled multisite schema'); + pg_stage_ok('install'); + } catch (Throwable $e) { + $pg_stage_output_buffering = false; + pg_stage_fail('install', $e); + exit(1); + } +} + function pg_run_load_deps_stage(array $cfg): array { pg_stage_begin('load_deps'); try { - $loaded = array(); - foreach (explode("\n", (string) ($cfg['dep_mounts'] ?? '')) as $dep_mount) { - $dep_mount = trim($dep_mount); - if ($dep_mount === '') { - continue; - } - foreach (glob($dep_mount . '/*.php') ?: array() as $dep_file) { - if (basename($dep_file) === 'db.php') { - continue; + $result = array('loaded' => array(), 'activate' => array()); + $plugins = is_array($cfg['plugins'] ?? null) ? $cfg['plugins'] : array(); + if (empty($plugins)) { + foreach (explode("\n", (string) ($cfg['dep_mounts'] ?? '')) as $path) { + if (trim($path) !== '') { + $slug = basename(rtrim(str_replace('\\\\', '/', trim($path)), '/')); + $plugins[] = array('path' => $path, 'pluginFile' => $slug . '/' . $slug . '.php', 'activate' => true, 'loadAs' => 'plugin'); } - if (strpos(file_get_contents($dep_file), 'Plugin Name:') !== false) { - require_once $dep_file; - $loaded[] = $dep_file; + } + } + foreach ($plugins as $plugin) { + if (!is_array($plugin) || !is_string($plugin['path'] ?? null) || !is_string($plugin['pluginFile'] ?? null)) { + throw new RuntimeException('managed PHPUnit dependency metadata must contain a plugin path and entrypoint'); + } + $load_as = ($plugin['loadAs'] ?? 'plugin') === 'mu-plugin' ? 'mu-plugin' : 'plugin'; + $declared_plugin_root = $load_as === 'mu-plugin' ? WPMU_PLUGIN_DIR . '/contained-runtime' : WP_PLUGIN_DIR; + $plugin_root = realpath($declared_plugin_root); + if ($plugin_root === false || !is_dir($plugin_root)) { + throw new RuntimeException('WordPress dependency root is unavailable while loading managed PHPUnit dependencies'); + } + $dep_mount = rtrim(str_replace('\\\\', '/', trim($plugin['path'])), '/'); + $dep_real = realpath($dep_mount); + if ($dep_real === false || !is_dir($dep_real) || dirname($dep_real) !== $plugin_root) { + throw new RuntimeException('managed PHPUnit dependency must be a direct child of its declared WordPress plugin root: ' . $dep_mount); + } + if ($dep_mount !== $declared_plugin_root . '/' . basename($dep_mount)) { + throw new RuntimeException('managed PHPUnit dependency path is not canonical: ' . $dep_mount); + } + $plugin_file = trim(str_replace('\\\\', '/', $plugin['pluginFile'])); + $slug = basename($dep_mount); + if ($plugin_file === '' || str_starts_with($plugin_file, '/') || str_contains($plugin_file, '..') || !str_starts_with($plugin_file, $slug . '/') || !str_ends_with($plugin_file, '.php')) { + throw new RuntimeException('managed PHPUnit dependency entrypoint is unsafe or outside its plugin slug: ' . $plugin_file); + } + $entrypoint = $dep_real . '/' . substr($plugin_file, strlen($slug) + 1); + $loaded_file = realpath($entrypoint); + if ($loaded_file === false || !is_file($loaded_file) || !is_readable($loaded_file) || strpos($loaded_file, rtrim($dep_real, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR) !== 0) { + throw new RuntimeException('managed PHPUnit dependency entrypoint is not a readable contained file: ' . $plugin_file); + } + foreach (array($dep_real . '/vendor/autoload_packages.php', $dep_real . '/vendor/autoload.php') as $composer_autoload) { + if (is_file($composer_autoload) && is_readable($composer_autoload)) { + require_once $composer_autoload; break; } } + require_once $loaded_file; + $result['loaded'][] = $loaded_file; + if ($load_as === 'plugin' && !empty($plugin['activate'])) { + $result['activate'][] = $loaded_file; + } } pg_stage_ok('load_deps'); - return $loaded; + return $result; } catch (Throwable $e) { pg_stage_fail('load_deps', $e); exit(1); @@ -1058,7 +1188,7 @@ function pg_activate_plugin_file(string $plugin_file, bool $network_wide): void pg_log('PLUGIN_ACTIVATE_BEGIN ' . $plugin_basename . ' ' . pg_diagnostic_context()); do_action('activate_' . $plugin_basename, $network_wide); pg_mark_plugin_active($plugin_basename, $network_wide); - do_action('activated_plugin', $plugin_basename, false, $network_wide); + do_action('activated_plugin', $plugin_basename, $network_wide); pg_log('PLUGIN_ACTIVATE_OK ' . $plugin_basename . ' ' . pg_diagnostic_context()); } @@ -1163,14 +1293,19 @@ tests_add_filter('muplugins_loaded', function () use ($plugin_slug, $plugin_path $deferred_install_init_callbacks = pg_defer_new_wordpress_hook_callbacks('init', $pre_component_init_callbacks); }); -pg_run_install_stage(array('config_path' => $config_path, 'tests_dir' => $tests_dir, 'multisite' => $multisite)); +if ($managed_multisite_preinstalled) { + pg_run_preinstalled_wordpress_stage(array('config_path' => $config_path, 'tests_dir' => $tests_dir)); +} else { + 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)); +$dependency_load = pg_run_load_deps_stage(array('dep_mounts' => $dep_mounts, 'plugins' => $dependency_plugins)); +$loaded_dep_files = $dependency_load['loaded']; $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; +$activation_files = $dependency_load['activate']; if ($loaded_component_file !== null) { $activation_files[] = $loaded_component_file; } @@ -1377,7 +1512,8 @@ try { $runner = new PHPUnit\\TextUI\\TestRunner(); $result = $runner->run($suite, $phpunit_args); pg_log($result->wasSuccessful() ? 'ALL TESTS PASSED' : 'SOME TESTS FAILED'); - pg_log('TESTS: ' . $result->count() . ' FAILURES: ' . count($result->failures()) . ' ERRORS: ' . count($result->errors())); + $assertions = class_exists('PHPUnit\\Framework\\Assert') && method_exists('PHPUnit\\Framework\\Assert', 'getCount') ? PHPUnit\\Framework\\Assert::getCount() : 0; + pg_log('TESTS: ' . $result->count() . ' ASSERTIONS: ' . $assertions . ' FAILURES: ' . count($result->failures()) . ' ERRORS: ' . count($result->errors())); pg_stage_ok('run_tests'); exit($result->wasSuccessful() ? 0 : 1); } catch (Throwable $e) { diff --git a/packages/runtime-playground/src/phpunit-command-semantics.ts b/packages/runtime-playground/src/phpunit-command-semantics.ts new file mode 100644 index 00000000..12887663 --- /dev/null +++ b/packages/runtime-playground/src/phpunit-command-semantics.ts @@ -0,0 +1,28 @@ +import type { RuntimeCreateSpec } from "@automattic/wp-codebox-core" +import { argValue, booleanArg } from "./command-args.js" + +export interface PhpunitExecutionSemantics { + bootstrapMode: string + databaseType: "sqlite" | "mysql" + externalDatabase: boolean + multisite: boolean +} + +export function phpunitExecutionSemantics(args: string[], runtimeSpec: Pick): PhpunitExecutionSemantics { + const bootstrapMode = argValue(args, "bootstrap-mode")?.trim() || "managed" + const declaredDatabaseType = argValue(args, "database-type")?.trim() + if (declaredDatabaseType && declaredDatabaseType !== "sqlite" && declaredDatabaseType !== "mysql") { + throw new Error(`wordpress.phpunit does not support database-type=${declaredDatabaseType}; supported backends are sqlite and mysql`) + } + const externalDatabase = runtimeSpec.environment?.databaseSetup === "external" + const databaseType: "sqlite" | "mysql" = declaredDatabaseType === "mysql" || declaredDatabaseType === "sqlite" + ? declaredDatabaseType + : externalDatabase && runtimeSpec.runtimeEnv?.DB_HOST ? "mysql" : "sqlite" + + return { bootstrapMode, databaseType, externalDatabase, multisite: booleanArg(args, "multisite") } +} + +export function requiresManagedMysqlMultisitePreinstall(args: string[], runtimeSpec: Pick): boolean { + const semantics = phpunitExecutionSemantics(args, runtimeSpec) + return semantics.bootstrapMode === "managed" && semantics.databaseType === "mysql" && semantics.externalDatabase && semantics.multisite +} diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index 706de66a..a2fb246f 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -30,6 +30,7 @@ import { restPerformanceObservationInputFromArgs, restPerformanceObservationPhpCode, phpunitRunCode, + phpunitMultisitePreinstallCode, pluginStateInputFromArgs, pluginStatePhpCode, PLUGIN_PHPUNIT_RESULT_FILE, @@ -53,6 +54,7 @@ import { bootstrapAbilityPhpCode, bootstrapPhpCode, phpCodeFromArgs, splitLeadin import { assertPlaygroundResponseOk, attachPlaygroundDiagnostics, type PlaygroundRunResponse } from "./playground-command-errors.js" import type { PlaygroundCliServer } from "./preview-server.js" import { persistCorePhpunitResult, persistPluginPhpunitResult, persistVfsDiagnosticFileToHost, readCorePhpunitDiagnostic, readPluginPhpunitDiagnostic } from "./runtime-diagnostics.js" +import { phpunitExecutionSemantics, requiresManagedMysqlMultisitePreinstall } from "./phpunit-command-semantics.js" import type { RuntimeWpCliBridge } from "./runtime-wp-cli-bridge.js" import { COMMAND_DIAGNOSTICS_ARTIFACT_SCHEMA, PERFORMANCE_OBSERVATION_SCHEMA, commandDiagnosticsCaptureArgs, commandDiagnosticsCaptureSpecFromArgs, createRuntimeCommandResultEnvelope, redactJsonValue, type ExecutionSpec, type MountSpec, type PerformanceObservation, type RuntimeCommandResultEnvelope, type RuntimeCreateSpec, type RuntimeEpisodeTraceRef } from "@automattic/wp-codebox-core" import { wordpressUserSessionFromCommandArgs } from "./wordpress-user-sessions.js" @@ -910,15 +912,8 @@ export async function runPhpunitCommand({ const phpunitXmlArg = argValue(args, "phpunit-xml") const explicitCode = argValue(args, "code") || argValue(args, "code-file") const pluginSlug = argValue(args, "plugin-slug")?.trim() || "" - const bootstrapMode = argValue(args, "bootstrap-mode")?.trim() || "managed" + const { bootstrapMode, databaseType, externalDatabase, multisite } = phpunitExecutionSemantics(args, runtimeSpec) const declaredDatabaseType = argValue(args, "database-type")?.trim() - if (declaredDatabaseType && declaredDatabaseType !== "sqlite" && declaredDatabaseType !== "mysql") { - throw new Error(`wordpress.phpunit does not support database-type=${declaredDatabaseType}; supported backends are sqlite and mysql`) - } - const externalDatabase = runtimeSpec.environment?.databaseSetup === "external" - const databaseType: "sqlite" | "mysql" = declaredDatabaseType === "mysql" || declaredDatabaseType === "sqlite" - ? declaredDatabaseType - : externalDatabase && runtimeSpec.runtimeEnv?.DB_HOST ? "mysql" : "sqlite" if (databaseType === "mysql" && !externalDatabase) { throw new Error("wordpress.phpunit requires a managed external database service when database-type=mysql; refusing to substitute SQLite") } @@ -928,6 +923,7 @@ export async function runPhpunitCommand({ const autoloadFile = argValue(args, "autoload-file")?.trim() || (bootstrapMode === "project" ? "" : "/wp-codebox-vendor/autoload.php") const autoloadFileRole = argValue(args, "autoload-file-role")?.trim() === "harness" ? "harness" : undefined const processIdentity = boundedProcessIdentity(spec.processIdentity) + const managedMultisitePreinstalled = !explicitCode && requiresManagedMysqlMultisitePreinstall(args, runtimeSpec) const resultFile = processIdentity ? `/tmp/wp-codebox-phpunit-result-${processIdentity}.txt` : PLUGIN_PHPUNIT_RESULT_FILE const diagnosticHostFile = `/wordpress/wp-content/plugins/${pluginSlug}/.pg-test-result${processIdentity ? `-${processIdentity}` : ""}.txt` const code = explicitCode ? await phpCodeFromArgs(args, "wordpress.phpunit", false) : phpunitRunCode({ @@ -946,12 +942,22 @@ export async function runPhpunitCommand({ env: jsonObjectArg(args, "env-json"), wpConfigDefines: jsonObjectArg(args, "wp-config-defines-json"), dependencyMounts: commaListArg(args, "dependency-mounts"), + dependencyPlugins: jsonArrayArg(args, "dependency-plugins-json").map((plugin) => { + if (!plugin || typeof plugin !== "object" || Array.isArray(plugin)) throw new Error("dependency-plugins-json entries must be objects") + const metadata = plugin as Record + const path = typeof metadata.path === "string" ? metadata.path : "" + const pluginFile = typeof metadata.pluginFile === "string" ? metadata.pluginFile : "" + if (!path) throw new Error("dependency-plugins-json entries require path") + if (!pluginFile) throw new Error("dependency-plugins-json entries require pluginFile") + return { path, pluginFile, activate: metadata.activate !== false, loadAs: metadata.loadAs === "mu-plugin" ? "mu-plugin" as const : "plugin" as const } + }), bootstrapFiles: jsonArrayArg(args, "bootstrap-files-json").filter((value): value is string => typeof value === "string"), preloadFiles: jsonArrayArg(args, "preload-files-json").filter((value): value is string => typeof value === "string"), bootstrapMode, projectBootstrap: argValue(args, "project-bootstrap")?.trim() || "", - multisite: booleanArg(args, "multisite"), + multisite, databaseType, + managedMultisitePreinstalled, resultFile, }) if (!explicitCode && !pluginSlug) { @@ -960,6 +966,17 @@ export async function runPhpunitCommand({ let response: PlaygroundRunResponse try { const bootstrapArgs = explicitCode ? args : [...args, "bootstrap=runtime-only"] + if (managedMultisitePreinstalled) { + const preinstallCode = phpunitMultisitePreinstallCode({ + testsDir: argValue(args, "tests-dir")?.trim() || "/wp-codebox-vendor/wp-phpunit/wp-phpunit", + env: jsonObjectArg(args, "env-json"), + wpConfigDefines: jsonObjectArg(args, "wp-config-defines-json"), + databaseType, + resultFile, + }) + const preinstallResponse = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, preinstallCode, ["recipe-active-plugins=none", ...bootstrapArgs]) }) + assertPlaygroundResponseOk("wordpress.phpunit multisite preinstall", preinstallResponse) + } response = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, code, bootstrapArgs, undefined, resultFile) }) } catch (error) { await persistPluginPhpunitResult(server, resultFile, artifactRoot, processIdentity) diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts index 453adc8c..97ebedc4 100644 --- a/tests/disposable-mysql-mysqli.integration.test.ts +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -149,9 +149,34 @@ require_once ABSPATH . 'wp-settings.php'; assert.deepEqual(aggregate.entries.map((entry: { inputIndex: number }) => entry.inputIndex), [0, 1]) const multisitePlugin = join(directory, "managed-multisite-fixture") + const multisiteDependency = join(directory, "managed-multisite-dependency") + const multisiteMuDependency = join(directory, "managed-multisite-mu-dependency") const multisiteRecipePath = join(directory, "managed-multisite-recipe.json") const multisiteArtifacts = join(directory, "managed-multisite-artifacts") await mkdir(join(multisitePlugin, "tests"), { recursive: true }) + await mkdir(join(multisiteDependency, "vendor"), { recursive: true }) + await mkdir(multisiteMuDependency, { recursive: true }) + await writeFile(join(multisiteDependency, "decoy.php"), "blogs; +if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($network_table))) !== $network_table) { + throw new RuntimeException('Composer autoloader ran before multisite network tables existed'); +} +get_network_option(1, 'site_name', ''); +file_put_contents('/tmp/wp-codebox-composer-loaded-after-network', 'yes'); +`) + await writeFile(join(multisiteMuDependency, "mu-bootstrap.php"), `blogs; +if ($wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($network_table))) !== $network_table) { + throw new RuntimeException('MU dependency ran before multisite network tables existed'); +} +get_network_option(1, 'site_name', ''); +file_put_contents('/tmp/wp-codebox-mu-loaded-after-network', 'yes'); +`) await writeFile(join(multisitePlugin, "managed-multisite-fixture.php"), `assertInstanceOf(mysqli::class, $wpdb->dbh); $this->assertSame('1', (string) $wpdb->get_var("SELECT JSON_VALID('{\\"valid\\":true}')")); } + + public function test_network_schema_seed_and_dependency_are_materialized(): void { + global $wpdb; + foreach (array($wpdb->blogs, $wpdb->blogmeta, $wpdb->site, $wpdb->sitemeta, $wpdb->registration_log, $wpdb->signups) as $table) { + $this->assertSame($table, $wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $wpdb->esc_like($table)))); + } + $this->assertGreaterThanOrEqual(1, (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->blogs} WHERE blog_id = 1")); + $this->assertGreaterThan(0, (int) $wpdb->get_var("SELECT COUNT(*) FROM {$wpdb->sitemeta} WHERE site_id = 1")); + $this->assertTrue(update_network_option(1, 'wp_codebox_network_write', 'round-trip')); + $this->assertSame('round-trip', get_network_option(1, 'wp_codebox_network_write')); + $dependency = WP_PLUGIN_DIR . '/managed-multisite-dependency/bootstrap.php'; + $this->assertDirectoryExists(dirname($dependency)); + $this->assertFileExists($dependency); + require_once ABSPATH . 'wp-admin/includes/plugin.php'; + $this->assertTrue(is_plugin_active_for_network('managed-multisite-dependency/bootstrap.php')); + $this->assertSame(1, (int) get_network_option(1, 'wp_codebox_dependency_activated')); + $this->assertFileExists('/tmp/wp-codebox-composer-loaded-after-network'); + $this->assertFileExists('/tmp/wp-codebox-mu-loaded-after-network'); + } } `) const multisiteRecipe = buildWordPressPhpunitRecipe({ @@ -197,13 +241,22 @@ final class ManagedMultisiteTest extends WP_UnitTestCase { databaseType: "mysql", multisite: true, pluginSource: multisitePlugin, - dependencyMounts: ["/wordpress/wp-content/plugins/managed-multisite-fixture"], + extra_plugins: [ + { source: multisiteDependency, slug: "managed-multisite-dependency", pluginFile: "managed-multisite-dependency/bootstrap.php", activate: true }, + { source: multisiteMuDependency, slug: "managed-multisite-mu-dependency", pluginFile: "managed-multisite-mu-dependency/mu-bootstrap.php", activate: true, loadAs: "mu-plugin" }, + ], + dependencyMounts: ["/wordpress/wp-content/plugins/managed-multisite-dependency", "/wordpress/wp-content/plugins/managed-multisite-mu-dependency"], 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 })) + const latest = JSON.parse(await readFile(join(multisiteArtifacts, "latest-runtime.json"), "utf8")) as { paths?: { runtimeDirectory?: string } } + const diagnostic = await readFile(join(multisiteArtifacts, latest.paths?.runtimeDirectory ?? "", "files/phpunit/.pg-test-result.txt"), "utf8") + assert.match(diagnostic, /STAGE_BEGIN:run_tests[\s\S]*RUNNING [1-9][0-9]* TEST FILES/, "managed multisite diagnostics must prove test execution started") + const phpunitOutput = multisiteResult.executions.filter((execution) => execution.command === "wordpress.phpunit").map((execution) => execution.stdout).join("\n") + assert.match(phpunitOutput, /OK \([1-9][0-9]* tests?, [1-9][0-9]* assertions?\)/, "managed multisite regression must execute nonzero tests and assertions") console.log("disposable MySQL and MariaDB mysqli E2E passed") } finally { await rm(directory, { recursive: true, force: true }) diff --git a/tests/mount-artifact-capture-policy.test.ts b/tests/mount-artifact-capture-policy.test.ts index b9c32658..ae8859cd 100644 --- a/tests/mount-artifact-capture-policy.test.ts +++ b/tests/mount-artifact-capture-policy.test.ts @@ -52,6 +52,7 @@ try { recipe: setupRecipe, recipeDirectory: root, prepared, + runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, runtimeEnv: {} }, runtime: { async mount(spec: MountSpec) { mountedInput = spec }, } as never, diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index 78b4b958..abbef80d 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -5,14 +5,16 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.js" -import { corePhpunitRunCode, phpunitRunCode } from "../packages/runtime-playground/src/phpunit-command-handlers.js" +import { corePhpunitRunCode, phpunitMultisitePreinstallCode, phpunitRunCode } from "../packages/runtime-playground/src/phpunit-command-handlers.js" import { runPhpunitCommand } from "../packages/runtime-playground/src/wordpress-command-runners.js" +import { phpunitExecutionSemantics, requiresManagedMysqlMultisitePreinstall } from "../packages/runtime-playground/src/phpunit-command-semantics.js" import { recipePolicy } from "../packages/cli/src/recipe-validation.js" import { recipeExtraPluginSourceSubpath } from "../packages/cli/src/recipe-sources.js" import { recipeInputMountPathMap, rewriteInputMountPathArgs } from "../packages/cli/src/commands/recipe-runtime-setup.js" const woocommerceAutoload = "/wordpress/wp-content/plugins/woocommerce/vendor/autoload_packages.php" const phpunitRuntimeSpec = { + environment: { kind: "wordpress", name: "test", version: "latest" }, runtimeEnv: { TC_MYSQL_PORT: "3306" }, } as never @@ -122,6 +124,28 @@ echo "ok\n"; assert.equal(execFileSync("php", [scriptPath], { encoding: "utf8" }), "ok\n") } +function assertActivatedPluginHookSignature(source: string): void { + const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-activated-plugin-hook-")) + const scriptPath = join(tempDir, "assert-activated-plugin-hook.php") + writeFileSync(scriptPath, ` $db_host"), "managed PHPUnit writes the provisioned MySQL host into wp-tests-config.php") assert.ok(decodedMysqlCode.includes("getenv('DB_PASSWORD')"), "managed PHPUnit consumes the provisioned MySQL credentials") + +const inferredSemantics = phpunitExecutionSemantics(["multisite=true"], { + environment: { kind: "wordpress", name: "test", version: "latest", databaseSetup: "external" }, + runtimeEnv: { DB_HOST: "127.0.0.1" }, +}) +assert.deepEqual(inferredSemantics, { bootstrapMode: "managed", databaseType: "mysql", externalDatabase: true, multisite: true }, "omitted defaults, external DB inference, and boolean true strings use runner semantics") +assert.equal(requiresManagedMysqlMultisitePreinstall(["multisite=yes"], { environment: { kind: "wordpress", name: "test", version: "latest", databaseSetup: "external" }, runtimeEnv: { DB_HOST: "db" } }), true) +assert.equal(requiresManagedMysqlMultisitePreinstall(["multisite=false"], { environment: { kind: "wordpress", name: "test", version: "latest", databaseSetup: "external" }, runtimeEnv: { DB_HOST: "db" } }), false) + +const preinstallCode = phpunitMultisitePreinstallCode({ + testsDir: "/wp-codebox-vendor/wp-phpunit/wp-phpunit", + env: {}, + wpConfigDefines: { MULTISITE: true, DOMAIN_CURRENT_SITE: "example.org" }, + databaseType: "mysql", + resultFile: "/tmp/preinstall-result.txt", +}) +assert.ok(preinstallCode.includes("'run_ms_tests'"), "multisite preinstall selects wp-phpunit's network installer") +assert.ok(preinstallCode.includes("define('WP_TESTS_MULTISITE', true)"), "multisite preinstall enables wp-phpunit network installation") +assert.ok(preinstallCode.includes("require $tests_dir . '/includes/install.php'"), "multisite preinstall uses wp-phpunit's installer") +assert.ok(preinstallCode.includes("pg_write_managed_test_config($wp_config_defines, 'wptests_', $database_type)"), "multisite preinstall uses the managed runner's database config and prefix") +assert.equal(preinstallCode.includes("define('MULTISITE'"), false, "multisite preinstall must not bootstrap WordPress as an installed network") +assert.equal(preinstallCode.includes("DOMAIN_CURRENT_SITE"), false, "multisite preinstall must not define current-network constants") +assert.ok(preinstallCode.includes("/tmp/wp-codebox-preinstall-mu-plugins"), "multisite preinstall isolates generated and declared mu-plugins") +assert.ok(preinstallCode.includes("@file_put_contents($result_file, '');"), "multisite preinstall clears stale diagnostics") +assert.ok(preinstallCode.includes("STAGE_FAIL:preinstall:"), "multisite preinstall records throwable diagnostics") +assert.ok(preinstallCode.includes("STAGE_FATAL:preinstall:"), "multisite preinstall records fatal diagnostics") + +const mysqlMultisiteInvocations: string[] = [] +await runPhpunitCommand({ + artifactRoot: mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-mysql-multisite-")), + mounts: [], + runPlaygroundCommand: async (_command, _server, input) => { + mysqlMultisiteInvocations.push(decodedBootstrapWrapper(input.code)) + return { text: "ok", exitCode: 0 } + }, + runtimeSpec: { + environment: { kind: "wordpress", name: "test", version: "latest", databaseSetup: "external" }, + runtimeEnv: { DB_HOST: "127.0.0.1", DB_PORT: "3307", DB_NAME: "runtime", DB_USER: "runtime", DB_PASSWORD: "secret" }, + policy: { commands: ["wordpress.phpunit"] }, + metadata: { recipe: { inputs: { extra_plugins: [{ slug: "preinstall-sensitive", pluginFile: "preinstall-sensitive/bootstrap.php", target: "/wordpress/wp-content/plugins/preinstall-sensitive", activate: true, loadAs: "plugin" }] } } }, + } as never, + server: { playground: {} } as never, + spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin", "database-type=mysql", "multisite=1"] }, +}) +assert.equal(mysqlMultisiteInvocations.length, 2, "managed MySQL multisite runs a separate preinstall before PHPUnit") +assert.ok(mysqlMultisiteInvocations[0].includes("'run_ms_tests'")) +assert.equal(mysqlMultisiteInvocations[0].includes("preinstall-sensitive/bootstrap.php"), false, "canonical preinstall excludes recipe-active plugin and Composer preloading") +assert.ok(mysqlMultisiteInvocations[1].includes("$phpunit_argv = pg_build_phpunit_argv"), "normal managed PHPUnit behavior follows preinstall") +assert.ok(mysqlMultisiteInvocations[1].includes("$managed_multisite_preinstalled = true"), "main managed invocation receives the canonical preinstall result") +assert.ok(mysqlMultisiteInvocations[1].includes("pg_run_preinstalled_wordpress_stage"), "main managed invocation boots the canonical schema without reinstalling it") + +const failedPreinstallInvocations: string[] = [] +await assert.rejects( + runPhpunitCommand({ + artifactRoot: mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-mysql-multisite-failure-")), + mounts: [], + runPlaygroundCommand: async (_command, _server, input) => { + failedPreinstallInvocations.push(decodedBootstrapWrapper(input.code)) + return { text: "preinstall failed", exitCode: 1 } + }, + runtimeSpec: { + environment: { kind: "wordpress", name: "test", version: "latest", databaseSetup: "external" }, + runtimeEnv: { DB_HOST: "127.0.0.1", DB_PORT: "3307", DB_NAME: "runtime", DB_USER: "runtime", DB_PASSWORD: "secret" }, + policy: { commands: ["wordpress.phpunit"] }, + } as never, + server: { + playground: { + readFileAsText: async () => "STAGE_FAIL:preinstall:RuntimeException: network install failed", + }, + } as never, + spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin", "database-type=mysql", "multisite=1"] }, + }), + (error: Error) => { + assert.match(error.message, /wordpress\.phpunit multisite preinstall failed with exit code 1/) + assert.match(error.message, /wordpress\.phpunit structured diagnostics/) + assert.match(error.message, /network install failed/) + return true + }, +) +assert.equal(failedPreinstallInvocations.length, 1, "failed multisite preinstall prevents the main PHPUnit invocation") await assert.rejects(runPhpunitCommand({ artifactRoot: mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-mysql-reject-")), mounts: [], @@ -720,15 +824,16 @@ const managedModeCode = phpunitRunCode({ }) assertDeferredHookReplayUsesWordPressLifecycle(managedModeCode) +assertActivatedPluginHookSignature(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("foreach ($multisite_defines as $name => $value)"), "managed multisite establishes network constants for the post-install test bootstrap") 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) +const dependencyLoadStageIndex = managedModeCode.indexOf("$dependency_load = 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) @@ -757,6 +862,17 @@ assert.deepEqual(dependencyRecipe.inputs.extra_plugins, [{ activate: false, }]) assert.ok(dependencyRecipe.workflow.steps[0].args.includes("dependency-mounts=/wordpress/wp-content/plugins/dependency")) +assert.ok(dependencyRecipe.workflow.steps[0].args.includes('dependency-plugins-json=[{"path":"/wordpress/wp-content/plugins/dependency","pluginFile":"dependency/dependency.php","activate":false,"loadAs":"plugin"}]'), "validated dependency entrypoint and activation intent are carried into managed PHPUnit") +assert.ok(managedModeCode.includes("dirname($dep_real) !== $plugin_root"), "dependency loading rejects paths outside a direct WP_PLUGIN_DIR child") +assert.ok(managedModeCode.includes("managed PHPUnit dependency path is not canonical"), "dependency loading rejects non-canonical sandbox paths") +assert.equal(managedModeCode.includes("glob($dep_real"), false, "dependency loading never scans for an arbitrary plugin header") + +const explicitMuDependencyRecipe = buildWordPressPhpunitRecipe({ + pluginSlug: "demo-plugin", + extra_plugins: [{ source: "/workspace/mu-dependency", slug: "mu-dependency", pluginFile: "bootstrap.php", activate: true, loadAs: "mu-plugin" }], + dependencyMounts: ["/wordpress/wp-content/plugins/mu-dependency"], +}) +assert.ok(explicitMuDependencyRecipe.workflow.steps[0].args.includes('dependency-plugins-json=[{"path":"/wordpress/wp-content/mu-plugins/contained-runtime/mu-dependency","pluginFile":"mu-dependency/bootstrap.php","activate":true,"loadAs":"mu-plugin"}]'), "MU dependencies preserve their canonical explicit entrypoint and effective target") const multisiteRecipe = buildWordPressPhpunitRecipe({ pluginSlug: "network-plugin", diff --git a/tests/playground-phpunit-readonly-cache.integration.test.ts b/tests/playground-phpunit-readonly-cache.integration.test.ts index 24ec22f8..a4252840 100644 --- a/tests/playground-phpunit-readonly-cache.integration.test.ts +++ b/tests/playground-phpunit-readonly-cache.integration.test.ts @@ -37,7 +37,7 @@ try { }, { source: dependency, slug: "activation-dependency", - activate: false, + activate: true, }], dependencyMounts: ["/wordpress/wp-content/plugins/readonly-phpunit-fixture", "/wordpress/wp-content/plugins/activation-dependency"], mounts: [ diff --git a/tests/recipe-runtime-setup-staged-materialization.test.ts b/tests/recipe-runtime-setup-staged-materialization.test.ts index 51ec10db..6aaab6d4 100644 --- a/tests/recipe-runtime-setup-staged-materialization.test.ts +++ b/tests/recipe-runtime-setup-staged-materialization.test.ts @@ -21,11 +21,35 @@ const recipe: WorkspaceRecipe = { mode: "readonly", }], }, - workflow: { steps: [] }, + workflow: { + before: [{ command: "wordpress.run-php", args: ["code=echo 'preexisting setup';"] }], + steps: [{ command: "wordpress.phpunit", args: [ + "multisite=true", + 'dependency-plugins-json=[{"path":"/wordpress/wp-content/plugins/fixture-dependency","pluginFile":"fixture-dependency/fixture-dependency.php","activate":true,"loadAs":"plugin"}]', + ] }], + }, } const prepared: PreparedRecipeRuntimeSetup = { workspaceMounts: [], - extraPlugins: [], + extraPlugins: [{ + source: "/tmp/host-extra-plugin", + slug: "fixture-dependency", + target: "/wordpress/wp-content/plugins/fixture-dependency", + pluginFile: "fixture-dependency/fixture-dependency.php", + activate: true, + loadAs: "plugin", + cleanupPaths: [], + provenance: { kind: "local", original: "/tmp/host-extra-plugin" }, + }, { + source: "/tmp/host-unrelated-plugin", + slug: "unrelated-plugin", + target: "/wordpress/wp-content/plugins/unrelated-plugin", + pluginFile: "unrelated-plugin/unrelated-plugin.php", + activate: true, + loadAs: "plugin", + cleanupPaths: [], + provenance: { kind: "local", original: "/tmp/host-unrelated-plugin" }, + }], dependencyOverlays: [], overlays: [], inputMountBaselinePaths: [], @@ -55,6 +79,28 @@ const runtime = { assert.equal(this, runtime, "setup calls staged input materializer with runtime binding") calls.push(`materialize:${mounts.map((mount) => mount.target).join(",")}`) assert.deepEqual(mounts, [ + { + type: "directory", + source: "/tmp/host-extra-plugin", + target: "/wordpress/wp-content/plugins/fixture-dependency", + mode: "readonly", + metadata: { + kind: "extra-plugin", + slug: "fixture-dependency", + source: { kind: "local", original: "/tmp/host-extra-plugin" }, + }, + }, + { + type: "directory", + source: "/tmp/host-unrelated-plugin", + target: "/wordpress/wp-content/plugins/unrelated-plugin", + mode: "readonly", + metadata: { + kind: "extra-plugin", + slug: "unrelated-plugin", + source: { kind: "local", original: "/tmp/host-unrelated-plugin" }, + }, + }, { type: "directory", source: inputMountSource, @@ -76,7 +122,10 @@ const runtime = { ]) }, async materializeMounts() { throw new Error("setup should use materializeStagedInputs when available") }, - async execute(spec) { calls.push(`execute:${spec.command}`); throw new Error("setup should not execute commands in this fixture") }, + async execute(spec) { + calls.push(`execute:${spec.command}:${(spec.args ?? []).join(" ")}`) + return { id: "setup", command: spec.command, args: spec.args ?? [], exitCode: 0, stdout: "", stderr: "", startedAt: new Date().toISOString(), finishedAt: new Date().toISOString() } + }, async observe() { throw new Error("unused") }, async snapshot() { throw new Error("unused") }, async collectArtifacts() { throw new Error("unused") }, @@ -96,23 +145,33 @@ const phaseExecutor = { } try { - await applyRecipeRuntimeSetup({ recipe, recipeDirectory: process.cwd(), runtime, prepared, phaseExecutor: phaseExecutor as never }) + await applyRecipeRuntimeSetup({ + recipe, + recipeDirectory: process.cwd(), + runtime, + runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest", databaseSetup: "external" }, runtimeEnv: { DB_HOST: "127.0.0.1" } }, + prepared, + phaseExecutor: phaseExecutor as never, + }) } finally { await rm(inputMountSource, { recursive: true, force: true }) } const mountIndex = calls.indexOf("mount:/workspace/example/bundles/runtime-agent") +const extraPluginMountIndex = calls.indexOf("mount:/wordpress/wp-content/plugins/fixture-dependency") const inputMountIndex = calls.indexOf(`mount:${prepared.inputMountPathMap[0].canonicalTarget}`) const materializeOperationIndex = calls.indexOf("operation:input.materialize") -const materializeIndex = calls.indexOf(`materialize:${prepared.inputMountPathMap[0].canonicalTarget},/workspace/example/bundles/runtime-agent`) +const materializeIndex = calls.indexOf(`materialize:/wordpress/wp-content/plugins/fixture-dependency,/wordpress/wp-content/plugins/unrelated-plugin,${prepared.inputMountPathMap[0].canonicalTarget},/workspace/example/bundles/runtime-agent`) assert.ok(inputMountIndex >= 0, "input source is mounted") assert.match(prepared.inputMountPathMap[0].canonicalTarget, /^\/tmp\/wp-codebox-inputs\/0-public_html-[a-f0-9]{12}$/) assert.ok(calls.includes(`metadata:/home/example/public_html->${prepared.inputMountPathMap[0].canonicalTarget}`), "input mount metadata records original and canonical targets") assert.ok(mountIndex >= 0, "staged source is mounted") +assert.ok(extraPluginMountIndex >= 0, "prepared extra plugin is mounted") assert.ok(materializeOperationIndex > inputMountIndex, "materialization is scheduled after input mount") assert.ok(materializeOperationIndex > mountIndex, "materialization is scheduled after staged mount") +assert.ok(materializeOperationIndex > extraPluginMountIndex, "materialization is scheduled after extra-plugin mount") assert.ok(materializeIndex > materializeOperationIndex, "materialization runs inside the setup phase before commands") -assert.equal(calls.some((call) => call.startsWith("execute:")), false) +assert.equal(calls.filter((call) => call.includes("activation-recipe-plugin")).length, 1, "managed MySQL multisite defers only the dependency associated with the qualifying PHPUnit step") const executedWorkflowSpecs: ExecutionSpec[] = [] const workflowRuntime = {