Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/recipe-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<sandbox path>`, matching the practical
role of `wp-env run --env-cwd`.
- Structured diagnostics in the recipe artifact bundle, including the raw test
Expand All @@ -774,13 +774,21 @@ 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:

```json
{
"pluginSlug": "woocommerce",
"pluginSource": "../woocommerce/plugins/woocommerce",
"databaseType": "mysql",
"services": [
{
"id": "mysql",
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/recipe-build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ interface WordPressPhpunitBuilderOptions {
blueprint?: unknown
wordpressVersion?: string
phpVersion?: string
databaseType?: "sqlite" | "mysql"
wordpressInstallMode?: RuntimeWordPressInstallMode
extensions?: WorkspaceRecipePHPWasmExtensionManifest[]
backendPackage?: WorkspaceRecipeRuntimeBackendPackage
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-core/src/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
Expand Down
22 changes: 21 additions & 1 deletion packages/runtime-core/src/recipe-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface NormalizeRecipeMountsOptions {
export interface WordPressPhpunitRecipeOptions {
wordpressVersion?: string
phpVersion?: string
databaseType?: "sqlite" | "mysql"
wordpressInstallMode?: RuntimeWordPressInstallMode
blueprint?: unknown
extensions?: WorkspaceRecipePHPWasmExtensionManifest[]
Expand Down Expand Up @@ -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",
Expand All @@ -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 ?? []),
Expand Down Expand Up @@ -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") {
Expand Down
28 changes: 26 additions & 2 deletions packages/runtime-playground/src/phpunit-command-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -630,11 +632,33 @@ function pg_run_boot_stage(array $cfg = []): ?string {
$config = "<?php\n";
pg_append_wp_config_defines($config, $extra_defines);
$config .= '$table_prefix = ' . var_export($table_prefix, true) . ";\n";
$config .= <<<'CONFIG'
$database_type = (string) ($cfg['database_type'] ?? 'sqlite');
if ($database_type === 'mysql') {
$db_host = getenv('DB_HOST');
if (!is_string($db_host) || $db_host === '') {
throw new RuntimeException('Managed PHPUnit requires DB_HOST when database-type=mysql; declare a MySQL runtime service with canonical DB_* outputs.');
}
$db_port = getenv('DB_PORT');
if (is_string($db_port) && $db_port !== '') {
$db_host .= ':' . $db_port;
}
foreach (array(
'DB_NAME' => 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');
Expand Down Expand Up @@ -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();
Expand Down
15 changes: 15 additions & 0 deletions packages/runtime-playground/src/wordpress-command-runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -937,6 +951,7 @@ export async function runPhpunitCommand({
bootstrapMode,
projectBootstrap: argValue(args, "project-bootstrap")?.trim() || "",
multisite: booleanArg(args, "multisite"),
databaseType,
resultFile,
})
if (!explicitCode && !pluginSlug) {
Expand Down
34 changes: 34 additions & 0 deletions tests/phpunit-project-autoload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,6 +438,7 @@ const projectModeCode = phpunitRunCode({
bootstrapMode: "project",
projectBootstrap: "tests/legacy/bootstrap.php",
multisite: false,
databaseType: "sqlite",
})

const implicitProjectConfigCode = phpunitRunCode({
Expand All @@ -458,6 +459,7 @@ const implicitProjectConfigCode = phpunitRunCode({
bootstrapMode: "project",
projectBootstrap: "",
multisite: false,
databaseType: "sqlite",
})
assertConfiglessPluginUsesManagedDiscovery(implicitProjectConfigCode)

Expand Down Expand Up @@ -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;'))
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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")
Expand Down
9 changes: 9 additions & 0 deletions tests/runtime-services.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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",
Expand All @@ -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" })

Expand Down
Loading