Skip to content

Commit 9dd37ba

Browse files
committed
fix: honor MySQL contracts in managed PHPUnit
1 parent 46487a4 commit 9dd37ba

8 files changed

Lines changed: 117 additions & 4 deletions

File tree

docs/recipe-contract.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -759,7 +759,7 @@ The runtime provides:
759759
- `WP_TESTS_DIR` pointing at the configured WordPress tests library.
760760
- `WP_TESTS_CONFIG_FILE_PATH` and `WP_PHPUNIT__TESTS_CONFIG` pointing at the
761761
generated `wp-tests-config.php`.
762-
- An isolated SQLite test database via `DB_NAME=':memory:'`.
762+
- An isolated SQLite test database via `DB_NAME=':memory:'` by default.
763763
- A plugin working directory via `cwd=<sandbox path>`, matching the practical
764764
role of `wp-env run --env-cwd`.
765765
- Structured diagnostics in the recipe artifact bundle, including the raw test
@@ -774,13 +774,21 @@ created test tables and before test discovery and execution. Any dependency
774774
`plugins_loaded` callbacks registered during that load are invoked once after
775775
dependency activation.
776776

777+
Set `databaseType` to `mysql` when managed PHPUnit tests require MySQL semantics.
778+
The builder provisions a disposable MySQL service, maps its canonical `DB_*`
779+
outputs into the runtime, and writes those values into `wp-tests-config.php`.
780+
An explicit MySQL declaration fails before tests if the runtime cannot provide
781+
that service; WP Codebox never substitutes SQLite for it. Omitting
782+
`databaseType` preserves the default SQLite behavior.
783+
777784
Use `recipe build phpunit` when generating recipes for plugin CI or offloaded lab
778785
runners:
779786

780787
```json
781788
{
782789
"pluginSlug": "woocommerce",
783790
"pluginSource": "../woocommerce/plugins/woocommerce",
791+
"databaseType": "mysql",
784792
"services": [
785793
{
786794
"id": "mysql",

packages/cli/src/commands/recipe-build.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface WordPressPhpunitBuilderOptions {
1111
blueprint?: unknown
1212
wordpressVersion?: string
1313
phpVersion?: string
14+
databaseType?: "sqlite" | "mysql"
1415
wordpressInstallMode?: RuntimeWordPressInstallMode
1516
extensions?: WorkspaceRecipePHPWasmExtensionManifest[]
1617
backendPackage?: WorkspaceRecipeRuntimeBackendPackage
@@ -81,6 +82,7 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word
8182
blueprint: phpunitOptions.blueprint,
8283
wordpressVersion: stringOrUndefined(phpunitOptions.wordpressVersion),
8384
phpVersion: stringOrUndefined(phpunitOptions.phpVersion),
85+
databaseType: phpunitOptions.databaseType,
8486
wordpressInstallMode: phpunitOptions.wordpressInstallMode,
8587
extensions: Array.isArray(phpunitOptions.extensions) ? phpunitOptions.extensions : [],
8688
backendPackage: phpunitOptions.backendPackage,

packages/runtime-core/src/command-registry.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,7 @@ export const commandRegistry = [
955955
{ name: "bootstrap-mode", description: "Bootstrap strategy: managed keeps WP Codebox-owned setup; project requires the plugin's native PHPUnit bootstrap.", format: "managed|project" },
956956
{ 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" },
957957
{ name: "multisite", description: "Run as multisite.", format: "boolean" },
958+
{ name: "database-type", description: "Required WordPress database backend. MySQL requires a managed external database service; omitted defaults to SQLite.", format: "sqlite|mysql" },
958959
],
959960
outputShape: "Raw PHPUnit runner JSON/log output plus normalized test-results artifact when artifacts are collected.",
960961
policyRequirement: "Runtime policy commands must include wordpress.phpunit.",

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

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export interface NormalizeRecipeMountsOptions {
1414
export interface WordPressPhpunitRecipeOptions {
1515
wordpressVersion?: string
1616
phpVersion?: string
17+
databaseType?: "sqlite" | "mysql"
1718
wordpressInstallMode?: RuntimeWordPressInstallMode
1819
blueprint?: unknown
1920
extensions?: WorkspaceRecipePHPWasmExtensionManifest[]
@@ -73,6 +74,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
7374
const pluginSlug = requiredPluginSlug(options.pluginSlug, "buildWordPressPhpunitRecipe")
7475
const pluginTarget = `/wordpress/wp-content/plugins/${pluginSlug}`
7576
const autoloadFile = options.autoloadFile ?? (options.bootstrapMode === "project" ? "" : "/wp-codebox-vendor/autoload.php")
77+
const services = phpunitRuntimeServices(options.databaseType, options.services)
7678

7779
return {
7880
schema: "wp-codebox/workspace-recipe/v1",
@@ -87,7 +89,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
8789
},
8890
inputs: {
8991
extra_plugins: normalizeExtraPlugins(options.extra_plugins),
90-
...(options.services && options.services.length > 0 ? { services: options.services } : {}),
92+
...(services.length > 0 ? { services } : {}),
9193
mounts: normalizeRecipeMounts([
9294
...(options.pluginSource ? [{ source: options.pluginSource, target: pluginTarget } satisfies WorkspaceRecipeMount] : []),
9395
...(options.mounts ?? []),
@@ -118,12 +120,30 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio
118120
commandArg("bootstrap-mode", options.bootstrapMode ?? "managed"),
119121
commandArg("project-bootstrap", options.projectBootstrap ?? ""),
120122
commandArg("multisite", options.multisite ? "1" : "0"),
123+
...(options.databaseType ? [commandArg("database-type", options.databaseType)] : []),
121124
],
122125
}],
123126
},
124127
}
125128
}
126129

130+
function phpunitRuntimeServices(databaseType: WordPressPhpunitRecipeOptions["databaseType"], services: WorkspaceRecipeRuntimeService[] = []): WorkspaceRecipeRuntimeService[] {
131+
if (databaseType !== undefined && databaseType !== "sqlite" && databaseType !== "mysql") {
132+
throw new Error(`Unsupported PHPUnit database type: ${databaseType}`)
133+
}
134+
if (databaseType !== "mysql") {
135+
return services
136+
}
137+
138+
const mysqlIndex = services.findIndex((service) => service.kind === "mysql")
139+
const canonicalOutputs = { host: "DB_HOST", port: "DB_PORT", username: "DB_USER", password: "DB_PASSWORD", database: "DB_NAME" }
140+
if (mysqlIndex === -1) {
141+
return [...services, { id: "wordpress-database", kind: "mysql", outputs: canonicalOutputs }]
142+
}
143+
144+
return services.map((service, index) => index === mysqlIndex ? { ...service, outputs: { ...service.outputs, ...canonicalOutputs } } : service)
145+
}
146+
127147
function normalizeRecipeSteps(steps: readonly WorkspaceRecipeStep[], label: string): WorkspaceRecipeStep[] {
128148
return steps.map((step, index) => {
129149
if (!step.command || typeof step.command !== "string") {

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

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ export interface PhpunitRunCodeOptions {
2121
bootstrapMode: string
2222
projectBootstrap: string
2323
multisite: boolean
24+
databaseType: "sqlite" | "mysql"
2425
/**
2526
* Sandbox-internal, writable path for the structured diagnostics log. Defaults
2627
* 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
361362
$bootstrap_mode = ${JSON.stringify(options.bootstrapMode || "managed")};
362363
$project_bootstrap = ${JSON.stringify(options.projectBootstrap)};
363364
$multisite = ${JSON.stringify(options.multisite)};
365+
$database_type = ${JSON.stringify(options.databaseType)};
364366
365367
function pg_build_phpunit_argv($raw): array {
366368
$phpunit_argv = array('phpunit');
@@ -630,11 +632,33 @@ function pg_run_boot_stage(array $cfg = []): ?string {
630632
$config = "<?php\n";
631633
pg_append_wp_config_defines($config, $extra_defines);
632634
$config .= '$table_prefix = ' . var_export($table_prefix, true) . ";\n";
633-
$config .= <<<'CONFIG'
635+
$database_type = (string) ($cfg['database_type'] ?? 'sqlite');
636+
if ($database_type === 'mysql') {
637+
$db_host = getenv('DB_HOST');
638+
if (!is_string($db_host) || $db_host === '') {
639+
throw new RuntimeException('Managed PHPUnit requires DB_HOST when database-type=mysql; declare a MySQL runtime service with canonical DB_* outputs.');
640+
}
641+
$db_port = getenv('DB_PORT');
642+
if (is_string($db_port) && $db_port !== '') {
643+
$db_host .= ':' . $db_port;
644+
}
645+
foreach (array(
646+
'DB_NAME' => getenv('DB_NAME') ?: 'runtime',
647+
'DB_USER' => getenv('DB_USER') ?: 'runtime',
648+
'DB_PASSWORD' => getenv('DB_PASSWORD') ?: '',
649+
'DB_HOST' => $db_host,
650+
) as $name => $value) {
651+
$config .= 'define(' . var_export($name, true) . ', ' . var_export($value, true) . ");\n";
652+
}
653+
} else {
654+
$config .= <<<'CONFIG'
634655
define('DB_NAME', ':memory:');
635656
define('DB_USER', 'root');
636657
define('DB_PASSWORD', '');
637658
define('DB_HOST', 'localhost');
659+
CONFIG;
660+
}
661+
$config .= <<<'CONFIG'
638662
define('DB_CHARSET', 'utf8');
639663
define('WP_TESTS_DOMAIN', 'example.org');
640664
define('WP_TESTS_EMAIL', 'admin@example.org');
@@ -1075,7 +1099,7 @@ if ($autoload_file_role === '' && $bootstrap_mode === 'project' && $project_auto
10751099
}
10761100
$harness_autoload_file = $legacy_project_autoload_file !== '' ? '/wp-codebox-vendor/autoload.php' : $autoload_file;
10771101
1078-
$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 !== ''));
1102+
$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));
10791103
if ($bootstrap_mode === 'project') {
10801104
pg_prepare_project_bootstrap_environment($config_path);
10811105
pg_skip_project_bootstrap_shell_install();

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -911,6 +911,20 @@ export async function runPhpunitCommand({
911911
const explicitCode = argValue(args, "code") || argValue(args, "code-file")
912912
const pluginSlug = argValue(args, "plugin-slug")?.trim() || ""
913913
const bootstrapMode = argValue(args, "bootstrap-mode")?.trim() || "managed"
914+
const declaredDatabaseType = argValue(args, "database-type")?.trim()
915+
if (declaredDatabaseType && declaredDatabaseType !== "sqlite" && declaredDatabaseType !== "mysql") {
916+
throw new Error(`wordpress.phpunit does not support database-type=${declaredDatabaseType}; supported backends are sqlite and mysql`)
917+
}
918+
const externalDatabase = runtimeSpec.environment?.databaseSetup === "external"
919+
const databaseType: "sqlite" | "mysql" = declaredDatabaseType === "mysql" || declaredDatabaseType === "sqlite"
920+
? declaredDatabaseType
921+
: externalDatabase && runtimeSpec.runtimeEnv?.DB_HOST ? "mysql" : "sqlite"
922+
if (databaseType === "mysql" && !externalDatabase) {
923+
throw new Error("wordpress.phpunit requires a managed external database service when database-type=mysql; refusing to substitute SQLite")
924+
}
925+
if (declaredDatabaseType === "sqlite" && externalDatabase) {
926+
throw new Error("wordpress.phpunit declared database-type=sqlite but the runtime uses an external database; refusing backend substitution")
927+
}
914928
const autoloadFile = argValue(args, "autoload-file")?.trim() || (bootstrapMode === "project" ? "" : "/wp-codebox-vendor/autoload.php")
915929
const autoloadFileRole = argValue(args, "autoload-file-role")?.trim() === "harness" ? "harness" : undefined
916930
const processIdentity = boundedProcessIdentity(spec.processIdentity)
@@ -937,6 +951,7 @@ export async function runPhpunitCommand({
937951
bootstrapMode,
938952
projectBootstrap: argValue(args, "project-bootstrap")?.trim() || "",
939953
multisite: booleanArg(args, "multisite"),
954+
databaseType,
940955
resultFile,
941956
})
942957
if (!explicitCode && !pluginSlug) {

tests/phpunit-project-autoload.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,6 +438,7 @@ const projectModeCode = phpunitRunCode({
438438
bootstrapMode: "project",
439439
projectBootstrap: "tests/legacy/bootstrap.php",
440440
multisite: false,
441+
databaseType: "sqlite",
441442
})
442443

443444
const implicitProjectConfigCode = phpunitRunCode({
@@ -458,6 +459,7 @@ const implicitProjectConfigCode = phpunitRunCode({
458459
bootstrapMode: "project",
459460
projectBootstrap: "",
460461
multisite: false,
462+
databaseType: "sqlite",
461463
})
462464
assertConfiglessPluginUsesManagedDiscovery(implicitProjectConfigCode)
463465

@@ -523,6 +525,7 @@ const canonicalHarnessProjectModeCode = phpunitRunCode({
523525
bootstrapMode: "project",
524526
projectBootstrap: "tests/legacy/bootstrap.php",
525527
multisite: false,
528+
databaseType: "sqlite",
526529
})
527530
assert.ok(canonicalHarnessProjectModeCode.includes('$autoload_file_role = "harness";'))
528531
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
587590
assert.ok(decodedExplicitCode.includes("echo getenv('TC_MYSQL_PORT');"), "explicit PHPUnit code receives the same runtime bootstrap")
588591
assert.ok(decodedExplicitCode.indexOf('putenv("TC_MYSQL_PORT=3306");') < decodedExplicitCode.lastIndexOf("TC_MYSQL_PORT"), "explicit env-json handling remains after runtime environment bootstrap")
589592

593+
let capturedMysqlCode = ""
594+
await runPhpunitCommand({
595+
artifactRoot: mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-mysql-artifacts-")),
596+
mounts: [],
597+
runPlaygroundCommand: async (_command, _server, input) => {
598+
capturedMysqlCode = input.code
599+
return { text: "ok", exitCode: 0 }
600+
},
601+
runtimeSpec: {
602+
environment: { kind: "wordpress", name: "test", version: "latest", databaseSetup: "external" },
603+
runtimeEnv: { DB_HOST: "127.0.0.1", DB_PORT: "3307", DB_NAME: "runtime", DB_USER: "runtime", DB_PASSWORD: "secret" },
604+
policy: { commands: ["wordpress.phpunit"] },
605+
} as never,
606+
server: { playground: {} } as never,
607+
spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin", "database-type=mysql"] },
608+
})
609+
const decodedMysqlCode = decodedBootstrapWrapper(capturedMysqlCode)
610+
assert.ok(decodedMysqlCode.includes('$database_type = "mysql";'))
611+
assert.ok(decodedMysqlCode.includes("'DB_HOST' => $db_host"), "managed PHPUnit writes the provisioned MySQL host into wp-tests-config.php")
612+
assert.ok(decodedMysqlCode.includes("getenv('DB_PASSWORD')"), "managed PHPUnit consumes the provisioned MySQL credentials")
613+
await assert.rejects(runPhpunitCommand({
614+
artifactRoot: mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-mysql-reject-")),
615+
mounts: [],
616+
runPlaygroundCommand: async () => { throw new Error("workload must not execute") },
617+
runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, policy: { commands: ["wordpress.phpunit"] } } as never,
618+
server: { playground: {} } as never,
619+
spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin", "database-type=mysql"] },
620+
}), /requires a managed external database service.*refusing to substitute SQLite/)
621+
590622
const coreModeCode = corePhpunitRunCode({
591623
coreRoot: "/wordpress",
592624
testsDir: "/wordpress/tests/phpunit",
@@ -628,9 +660,11 @@ const managedModeCode = phpunitRunCode({
628660
bootstrapMode: "managed",
629661
projectBootstrap: "",
630662
multisite: false,
663+
databaseType: "sqlite",
631664
})
632665

633666
assert.ok(managedModeCode.includes("configured PHPUnit harness autoload file is not readable"))
667+
assert.ok(managedModeCode.includes("define('DB_NAME', ':memory:');"), "default managed PHPUnit remains on SQLite")
634668
assert.ok(managedModeCode.includes("'cacheResult' => false"))
635669
assert.ok(managedModeCode.includes("global $argv, $pg_stage_output_buffering, $wp_rewrite;"), "managed WordPress installation must expose the rewrite global required by multisite setup")
636670
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")

tests/runtime-services.test.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,12 @@ assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-r
2929
assert.equal(runtimeServicePlan([mariaDbService])[0]?.version, "mariadb:11.4")
3030
assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [{ ...service, configuration: { foreignKeyTargetPolicy: "anything" } }] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }).valid, false)
3131
assert.deepEqual(buildWordPressPhpunitRecipe({ pluginSlug: "example", services: [emptyRootService] }).inputs?.services, [emptyRootService])
32+
const mysqlPhpunitRecipe = buildWordPressPhpunitRecipe({ pluginSlug: "example", databaseType: "mysql" })
33+
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" } }])
34+
assert.ok(mysqlPhpunitRecipe.workflow.steps[0].args?.includes("database-type=mysql"))
35+
const sqlitePhpunitRecipe = buildWordPressPhpunitRecipe({ pluginSlug: "example" })
36+
assert.equal(sqlitePhpunitRecipe.inputs?.services, undefined)
37+
assert.equal(sqlitePhpunitRecipe.workflow.steps[0].args?.some((arg) => arg.startsWith("database-type=")), false)
3238
assert.equal(buildWordPressPhpunitRecipe({ pluginSlug: "example", wordpressInstallMode: "do-not-attempt-installing" }).runtime?.wordpressInstallMode, "do-not-attempt-installing")
3339
const builderDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-builder-"))
3440
try {
@@ -37,6 +43,7 @@ try {
3743
await writeFile(optionsPath, JSON.stringify({
3844
pluginSlug: "example",
3945
phpVersion: "8.3",
46+
databaseType: "mysql",
4047
extensions: [{ manifest: "./sodium/manifest.json" }],
4148
backendPackage: { kind: "playground", source: "./playground-cli", package: "@wp-playground/cli" },
4249
testRoot: "/home/example/bin/tests/core",
@@ -47,6 +54,8 @@ try {
4754
assert.ok(builtRecipe.workflow.steps[0].args?.includes("test-root=/home/example/bin/tests/core"))
4855
assert.ok(builtRecipe.workflow.steps[0].args?.includes("phpunit-xml=/home/example/bin/tests/core/phpunit.xml"))
4956
assert.equal(builtRecipe.runtime?.phpVersion, "8.3")
57+
assert.equal(builtRecipe.inputs?.services?.[0]?.kind, "mysql")
58+
assert.ok(builtRecipe.workflow.steps[0].args?.includes("database-type=mysql"))
5059
assert.deepEqual(builtRecipe.runtime?.extensions, [{ manifest: "./sodium/manifest.json" }])
5160
assert.deepEqual(builtRecipe.runtime?.backendPackage, { kind: "playground", source: "./playground-cli", package: "@wp-playground/cli" })
5261

0 commit comments

Comments
 (0)