diff --git a/docs/recipe-contract.md b/docs/recipe-contract.md index dc013969d..f9b9112f2 100644 --- a/docs/recipe-contract.md +++ b/docs/recipe-contract.md @@ -759,7 +759,7 @@ The runtime provides: - `WP_TESTS_DIR` pointing at the configured WordPress tests library. - `WP_TESTS_CONFIG_FILE_PATH` and `WP_PHPUNIT__TESTS_CONFIG` pointing at the generated `wp-tests-config.php`. -- An isolated SQLite test database via `DB_NAME=':memory:'`. +- An isolated SQLite test database via `DB_NAME=':memory:'` by default. - A plugin working directory via `cwd=`, matching the practical role of `wp-env run --env-cwd`. - Structured diagnostics in the recipe artifact bundle, including the raw test @@ -774,6 +774,13 @@ created test tables and before test discovery and execution. Any dependency `plugins_loaded` callbacks registered during that load are invoked once after dependency activation. +Set `databaseType` to `mysql` when managed PHPUnit tests require MySQL semantics. +The builder provisions a disposable MySQL service, maps its canonical `DB_*` +outputs into the runtime, and writes those values into `wp-tests-config.php`. +An explicit MySQL declaration fails before tests if the runtime cannot provide +that service; WP Codebox never substitutes SQLite for it. Omitting +`databaseType` preserves the default SQLite behavior. + Use `recipe build phpunit` when generating recipes for plugin CI or offloaded lab runners: @@ -781,6 +788,7 @@ runners: { "pluginSlug": "woocommerce", "pluginSource": "../woocommerce/plugins/woocommerce", + "databaseType": "mysql", "services": [ { "id": "mysql", diff --git a/packages/cli/src/commands/recipe-build.ts b/packages/cli/src/commands/recipe-build.ts index 40dee3eb9..d23c21118 100644 --- a/packages/cli/src/commands/recipe-build.ts +++ b/packages/cli/src/commands/recipe-build.ts @@ -11,6 +11,7 @@ interface WordPressPhpunitBuilderOptions { blueprint?: unknown wordpressVersion?: string phpVersion?: string + databaseType?: "sqlite" | "mysql" wordpressInstallMode?: RuntimeWordPressInstallMode extensions?: WorkspaceRecipePHPWasmExtensionManifest[] backendPackage?: WorkspaceRecipeRuntimeBackendPackage @@ -81,6 +82,7 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word blueprint: phpunitOptions.blueprint, wordpressVersion: stringOrUndefined(phpunitOptions.wordpressVersion), phpVersion: stringOrUndefined(phpunitOptions.phpVersion), + databaseType: phpunitOptions.databaseType, wordpressInstallMode: phpunitOptions.wordpressInstallMode, extensions: Array.isArray(phpunitOptions.extensions) ? phpunitOptions.extensions : [], backendPackage: phpunitOptions.backendPackage, diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index d28048b66..19514f9ba 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -955,6 +955,7 @@ export const commandRegistry = [ { name: "bootstrap-mode", description: "Bootstrap strategy: managed keeps WP Codebox-owned setup; project requires the plugin's native PHPUnit bootstrap.", format: "managed|project" }, { name: "project-bootstrap", description: "Plugin-relative PHPUnit bootstrap path used when bootstrap-mode=project. If omitted, the phpunit.xml bootstrap attribute is used.", format: "relative path" }, { name: "multisite", description: "Run as multisite.", format: "boolean" }, + { name: "database-type", description: "Required WordPress database backend. MySQL requires a managed external database service; omitted defaults to SQLite.", format: "sqlite|mysql" }, ], outputShape: "Raw PHPUnit runner JSON/log output plus normalized test-results artifact when artifacts are collected.", policyRequirement: "Runtime policy commands must include wordpress.phpunit.", diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index da5b18e14..2db4c0514 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -14,6 +14,7 @@ export interface NormalizeRecipeMountsOptions { export interface WordPressPhpunitRecipeOptions { wordpressVersion?: string phpVersion?: string + databaseType?: "sqlite" | "mysql" wordpressInstallMode?: RuntimeWordPressInstallMode blueprint?: unknown extensions?: WorkspaceRecipePHPWasmExtensionManifest[] @@ -73,6 +74,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio const pluginSlug = requiredPluginSlug(options.pluginSlug, "buildWordPressPhpunitRecipe") 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) return { schema: "wp-codebox/workspace-recipe/v1", @@ -87,7 +89,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio }, inputs: { extra_plugins: normalizeExtraPlugins(options.extra_plugins), - ...(options.services && options.services.length > 0 ? { services: options.services } : {}), + ...(services.length > 0 ? { services } : {}), mounts: normalizeRecipeMounts([ ...(options.pluginSource ? [{ source: options.pluginSource, target: pluginTarget } satisfies WorkspaceRecipeMount] : []), ...(options.mounts ?? []), @@ -118,12 +120,30 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio commandArg("bootstrap-mode", options.bootstrapMode ?? "managed"), commandArg("project-bootstrap", options.projectBootstrap ?? ""), commandArg("multisite", options.multisite ? "1" : "0"), + ...(options.databaseType ? [commandArg("database-type", options.databaseType)] : []), ], }], }, } } +function phpunitRuntimeServices(databaseType: WordPressPhpunitRecipeOptions["databaseType"], services: WorkspaceRecipeRuntimeService[] = []): WorkspaceRecipeRuntimeService[] { + if (databaseType !== undefined && databaseType !== "sqlite" && databaseType !== "mysql") { + throw new Error(`Unsupported PHPUnit database type: ${databaseType}`) + } + if (databaseType !== "mysql") { + return services + } + + const mysqlIndex = services.findIndex((service) => service.kind === "mysql") + const canonicalOutputs = { host: "DB_HOST", port: "DB_PORT", username: "DB_USER", password: "DB_PASSWORD", database: "DB_NAME" } + if (mysqlIndex === -1) { + return [...services, { id: "wordpress-database", kind: "mysql", outputs: canonicalOutputs }] + } + + return services.map((service, index) => index === mysqlIndex ? { ...service, outputs: { ...service.outputs, ...canonicalOutputs } } : service) +} + function normalizeRecipeSteps(steps: readonly WorkspaceRecipeStep[], label: string): WorkspaceRecipeStep[] { return steps.map((step, index) => { if (!step.command || typeof step.command !== "string") { diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index 75bee04c9..d1d1e1409 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -21,6 +21,7 @@ export interface PhpunitRunCodeOptions { bootstrapMode: string projectBootstrap: string multisite: boolean + databaseType: "sqlite" | "mysql" /** * 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 @@ -361,6 +362,7 @@ $preload_files = json_decode(${JSON.stringify(JSON.stringify(options.preloadFile $bootstrap_mode = ${JSON.stringify(options.bootstrapMode || "managed")}; $project_bootstrap = ${JSON.stringify(options.projectBootstrap)}; $multisite = ${JSON.stringify(options.multisite)}; +$database_type = ${JSON.stringify(options.databaseType)}; function pg_build_phpunit_argv($raw): array { $phpunit_argv = array('phpunit'); @@ -630,11 +632,33 @@ function pg_run_boot_stage(array $cfg = []): ?string { $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'); @@ -1075,7 +1099,7 @@ if ($autoload_file_role === '' && $bootstrap_mode === 'project' && $project_auto } $harness_autoload_file = $legacy_project_autoload_file !== '' ? '/wp-codebox-vendor/autoload.php' : $autoload_file; -$config_path = pg_run_boot_stage(array('extra_defines' => $wp_config_defines, 'table_prefix' => $bootstrap_mode === 'project' ? 'wp_' : 'wptests_', 'autoload_file' => $harness_autoload_file, 'autoload_required' => $bootstrap_mode !== 'project' || $harness_autoload_file !== '')); +$config_path = pg_run_boot_stage(array('extra_defines' => $wp_config_defines, 'table_prefix' => $bootstrap_mode === 'project' ? 'wp_' : 'wptests_', 'autoload_file' => $harness_autoload_file, 'autoload_required' => $bootstrap_mode !== 'project' || $harness_autoload_file !== '', 'database_type' => $database_type)); if ($bootstrap_mode === 'project') { pg_prepare_project_bootstrap_environment($config_path); pg_skip_project_bootstrap_shell_install(); diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index a85fccf1d..d69b27737 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -911,6 +911,20 @@ export async function runPhpunitCommand({ 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 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") + } + if (declaredDatabaseType === "sqlite" && externalDatabase) { + throw new Error("wordpress.phpunit declared database-type=sqlite but the runtime uses an external database; refusing backend substitution") + } 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) @@ -937,6 +951,7 @@ export async function runPhpunitCommand({ bootstrapMode, projectBootstrap: argValue(args, "project-bootstrap")?.trim() || "", multisite: booleanArg(args, "multisite"), + databaseType, resultFile, }) if (!explicitCode && !pluginSlug) { diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index 71ff862eb..34391670d 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -438,6 +438,7 @@ const projectModeCode = phpunitRunCode({ bootstrapMode: "project", projectBootstrap: "tests/legacy/bootstrap.php", multisite: false, + databaseType: "sqlite", }) const implicitProjectConfigCode = phpunitRunCode({ @@ -458,6 +459,7 @@ const implicitProjectConfigCode = phpunitRunCode({ bootstrapMode: "project", projectBootstrap: "", multisite: false, + databaseType: "sqlite", }) assertConfiglessPluginUsesManagedDiscovery(implicitProjectConfigCode) @@ -523,6 +525,7 @@ const canonicalHarnessProjectModeCode = phpunitRunCode({ bootstrapMode: "project", projectBootstrap: "tests/legacy/bootstrap.php", multisite: false, + databaseType: "sqlite", }) assert.ok(canonicalHarnessProjectModeCode.includes('$autoload_file_role = "harness";')) assert.ok(canonicalHarnessProjectModeCode.includes('$harness_autoload_file = $legacy_project_autoload_file !== \'\' ? \'/wp-codebox-vendor/autoload.php\' : $autoload_file;')) @@ -587,6 +590,35 @@ assert.equal((decodedExplicitCode.match(/declare\(strict_types=1\);/g) ?? []).le assert.ok(decodedExplicitCode.includes("echo getenv('TC_MYSQL_PORT');"), "explicit PHPUnit code receives the same runtime bootstrap") assert.ok(decodedExplicitCode.indexOf('putenv("TC_MYSQL_PORT=3306");') < decodedExplicitCode.lastIndexOf("TC_MYSQL_PORT"), "explicit env-json handling remains after runtime environment bootstrap") +let capturedMysqlCode = "" +await runPhpunitCommand({ + artifactRoot: mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-mysql-artifacts-")), + mounts: [], + runPlaygroundCommand: async (_command, _server, input) => { + capturedMysqlCode = 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"] }, + } as never, + server: { playground: {} } as never, + spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin", "database-type=mysql"] }, +}) +const decodedMysqlCode = decodedBootstrapWrapper(capturedMysqlCode) +assert.ok(decodedMysqlCode.includes('$database_type = "mysql";')) +assert.ok(decodedMysqlCode.includes("'DB_HOST' => $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") +await assert.rejects(runPhpunitCommand({ + artifactRoot: mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-mysql-reject-")), + mounts: [], + runPlaygroundCommand: async () => { throw new Error("workload must not execute") }, + runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, policy: { commands: ["wordpress.phpunit"] } } as never, + server: { playground: {} } as never, + spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin", "database-type=mysql"] }, +}), /requires a managed external database service.*refusing to substitute SQLite/) + const coreModeCode = corePhpunitRunCode({ coreRoot: "/wordpress", testsDir: "/wordpress/tests/phpunit", @@ -628,9 +660,11 @@ const managedModeCode = phpunitRunCode({ bootstrapMode: "managed", projectBootstrap: "", multisite: false, + databaseType: "sqlite", }) 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('$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") diff --git a/tests/runtime-services.test.ts b/tests/runtime-services.test.ts index 27552adcc..9887a8b3c 100644 --- a/tests/runtime-services.test.ts +++ b/tests/runtime-services.test.ts @@ -29,6 +29,12 @@ assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-r assert.equal(runtimeServicePlan([mariaDbService])[0]?.version, "mariadb:11.4") assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [{ ...service, configuration: { foreignKeyTargetPolicy: "anything" } }] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }).valid, false) assert.deepEqual(buildWordPressPhpunitRecipe({ pluginSlug: "example", services: [emptyRootService] }).inputs?.services, [emptyRootService]) +const mysqlPhpunitRecipe = buildWordPressPhpunitRecipe({ pluginSlug: "example", databaseType: "mysql" }) +assert.deepEqual(mysqlPhpunitRecipe.inputs?.services, [{ id: "wordpress-database", kind: "mysql", outputs: { host: "DB_HOST", port: "DB_PORT", username: "DB_USER", password: "DB_PASSWORD", database: "DB_NAME" } }]) +assert.ok(mysqlPhpunitRecipe.workflow.steps[0].args?.includes("database-type=mysql")) +const sqlitePhpunitRecipe = buildWordPressPhpunitRecipe({ pluginSlug: "example" }) +assert.equal(sqlitePhpunitRecipe.inputs?.services, undefined) +assert.equal(sqlitePhpunitRecipe.workflow.steps[0].args?.some((arg) => arg.startsWith("database-type=")), false) assert.equal(buildWordPressPhpunitRecipe({ pluginSlug: "example", wordpressInstallMode: "do-not-attempt-installing" }).runtime?.wordpressInstallMode, "do-not-attempt-installing") const builderDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-builder-")) try { @@ -37,6 +43,7 @@ try { await writeFile(optionsPath, JSON.stringify({ pluginSlug: "example", phpVersion: "8.3", + databaseType: "mysql", extensions: [{ manifest: "./sodium/manifest.json" }], backendPackage: { kind: "playground", source: "./playground-cli", package: "@wp-playground/cli" }, testRoot: "/home/example/bin/tests/core", @@ -47,6 +54,8 @@ try { assert.ok(builtRecipe.workflow.steps[0].args?.includes("test-root=/home/example/bin/tests/core")) assert.ok(builtRecipe.workflow.steps[0].args?.includes("phpunit-xml=/home/example/bin/tests/core/phpunit.xml")) assert.equal(builtRecipe.runtime?.phpVersion, "8.3") + assert.equal(builtRecipe.inputs?.services?.[0]?.kind, "mysql") + assert.ok(builtRecipe.workflow.steps[0].args?.includes("database-type=mysql")) assert.deepEqual(builtRecipe.runtime?.extensions, [{ manifest: "./sodium/manifest.json" }]) assert.deepEqual(builtRecipe.runtime?.backendPackage, { kind: "playground", source: "./playground-cli", package: "@wp-playground/cli" })