From 5ac38988da0747668c6d56f002c4b0e781595da3 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 21 Jul 2026 11:50:56 -0400 Subject: [PATCH 1/4] Capture malformed PHPUnit bootstrap payloads --- .../runtime-playground/src/php-bootstrap.ts | 14 ++++++++++++- ...hpunit-runtime-failure-diagnostics.test.ts | 7 +++++-- ...unit-bootstrap-failure.integration.test.ts | 21 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/runtime-playground/src/php-bootstrap.ts b/packages/runtime-playground/src/php-bootstrap.ts index 93a06f60..3995da35 100644 --- a/packages/runtime-playground/src/php-bootstrap.ts +++ b/packages/runtime-playground/src/php-bootstrap.ts @@ -27,7 +27,7 @@ export function bootstrapPhpCode(spec: RuntimeCreateSpec, code: string, args: st const command = splitLeadingStrictTypesDeclare(code) - return `' . base64_decode(${JSON.stringify(Buffer.from(code, "utf8").toString("base64"))})); +} catch (Throwable $wp_codebox_bootstrap_throwable) { + @file_put_contents(${JSON.stringify(path)}, 'STAGE_FAIL:bootstrap:' . get_class($wp_codebox_bootstrap_throwable) . ': ' . $wp_codebox_bootstrap_throwable->getMessage() . ' at ' . $wp_codebox_bootstrap_throwable->getFile() . ':' . $wp_codebox_bootstrap_throwable->getLine() . "\\n", FILE_APPEND); + throw $wp_codebox_bootstrap_throwable; +}` } function phpFailureDiagnosticFilePhp(path: string): string { diff --git a/tests/phpunit-runtime-failure-diagnostics.test.ts b/tests/phpunit-runtime-failure-diagnostics.test.ts index 266d54c6..93ec3c23 100644 --- a/tests/phpunit-runtime-failure-diagnostics.test.ts +++ b/tests/phpunit-runtime-failure-diagnostics.test.ts @@ -42,8 +42,11 @@ const captured = await readFile(join(artifactRoot, "files", "phpunit", ".pg-test assert.match(captured, /Bootstrap failed with token: \[redacted\]/) assert.doesNotMatch(captured, new RegExp(secret)) -const preBootstrapRecorder = submittedCode.indexOf("STAGE_FATAL:bootstrap:") -const wordpressBootstrap = submittedCode.indexOf("require_once '/wordpress/wp-load.php';") +const encodedBootstrap = submittedCode.match(/base64_decode\("([A-Za-z0-9+/=]+)"\)/)?.[1] +assert.ok(encodedBootstrap, "PHPUnit payload must execute inside the bootstrap diagnostic wrapper") +const decodedBootstrap = Buffer.from(encodedBootstrap, "base64").toString("utf8") +const preBootstrapRecorder = decodedBootstrap.indexOf("STAGE_FATAL:bootstrap:") +const wordpressBootstrap = decodedBootstrap.indexOf("require_once '/wordpress/wp-load.php';") assert.ok(preBootstrapRecorder >= 0 && preBootstrapRecorder < wordpressBootstrap, "fatal diagnostics must be recorded before the WordPress bootstrap boundary") console.log("phpunit runtime failure diagnostics ok") diff --git a/tests/playground-phpunit-bootstrap-failure.integration.test.ts b/tests/playground-phpunit-bootstrap-failure.integration.test.ts index 86287f80..d1a3d140 100644 --- a/tests/playground-phpunit-bootstrap-failure.integration.test.ts +++ b/tests/playground-phpunit-bootstrap-failure.integration.test.ts @@ -12,6 +12,7 @@ const fatalMuPlugin = join(root, "fatal-bootstrap.php") const recipePath = join(root, "recipe.json") const artifactsPath = join(root, "artifacts") const exitArtifactsPath = join(root, "exit-artifacts") +const parseArtifactsPath = join(root, "parse-artifacts") try { await writeFile(fatalMuPlugin, ` Date: Tue, 21 Jul 2026 13:00:19 -0400 Subject: [PATCH 2/4] Treat empty PHPUnit suites as successful --- packages/runtime-playground/src/phpunit-command-handlers.ts | 6 +++--- tests/phpunit-project-autoload.test.ts | 2 ++ 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index bf05da68..b3f7672c 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -1236,7 +1236,7 @@ try { pg_log('NO_TEST_FILES'); pg_log('NOTICE:tests directory not found at ' . $test_dir); pg_stage_ok('discover_tests'); - exit(1); + exit(0); } list($directories, $suffixes, $prefixes, $excludes, $configured_files) = wp_codebox_phpunit_parse_config(${JSON.stringify(options.phpunitXml)}, $test_dir); $test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files); @@ -1261,7 +1261,7 @@ try { if (empty($test_files)) { pg_log('NO_TEST_FILES'); pg_stage_ok('discover_tests'); - exit(1); + exit(0); } pg_stage_ok('discover_tests'); } catch (Throwable $e) { @@ -1587,7 +1587,7 @@ try { if (empty($test_files)) { core_pg_log('NO_TEST_FILES'); core_pg_stage_ok('discover_tests'); - exit(1); + exit(0); } core_pg_stage_ok('discover_tests'); } catch (Throwable $e) { diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index f141bcbb..8c34005b 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -358,6 +358,7 @@ assert.ok(projectModeCode.includes("$test_files = wp_codebox_phpunit_discover($d assert.ok(projectModeCode.includes("' files=' . count($configured_files)")) assert.equal(projectModeCode.match(/return array\(\$directories, \$suffixes, \$prefixes, \$excludes\);/g)?.length ?? 0, 0) assert.equal(projectModeCode.match(/return \$return_values\(\);/g)?.length, 3) +assert.match(projectModeCode, /if \(empty\(\$test_files\)\) \{\s+pg_log\('NO_TEST_FILES'\);\s+pg_stage_ok\('discover_tests'\);\s+exit\(0\);/) assertPhpunitParseConfigFallbacksReturnFiveTuple(projectModeCode, "wp_codebox_phpunit_parse_config", "pg_log") assertSelectedTestFileResolution(projectModeCode) @@ -466,6 +467,7 @@ assert.ok(coreModeCode.includes("list($directories, $suffixes, $prefixes, $exclu assert.ok(coreModeCode.includes("$test_files = core_pg_discover_tests($directories, $suffixes, $prefixes, $excludes, $configured_files);")) assert.equal(coreModeCode.match(/return array\(\$directories, \$suffixes, \$prefixes, \$excludes\);/g)?.length ?? 0, 0) assert.equal(coreModeCode.match(/return \$return_values\(\);/g)?.length, 3) +assert.match(coreModeCode, /if \(empty\(\$test_files\)\) \{\s+core_pg_log\('NO_TEST_FILES'\);\s+core_pg_stage_ok\('discover_tests'\);\s+exit\(0\);/) assertPhpunitParseConfigFallbacksReturnFiveTuple(coreModeCode, "core_pg_parse_phpunit_config", "core_pg_log") for (const privateConstructor of [true, false]) { assertDiscoveredTestExecutes(projectModeCode, "pg", privateConstructor) From f6321d196f0d2227a8f5728f6b70ce505320a9bd Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Tue, 21 Jul 2026 13:44:37 -0400 Subject: [PATCH 3/4] Fail closed on PHPUnit discovery errors --- packages/runtime-core/src/command-registry.ts | 6 +- packages/runtime-core/src/recipe-builders.ts | 1 + .../src/phpunit-command-handlers.ts | 77 +++------ .../src/wordpress-command-runners.ts | 21 ++- tests/phpunit-project-autoload.test.ts | 159 +++++++++++++++--- 5 files changed, 185 insertions(+), 79 deletions(-) diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index 759d54bd..d28048b6 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -944,8 +944,9 @@ export const commandRegistry = [ { name: "project-autoload-file", description: "Project/plugin autoload path loaded after the project bootstrap has provided WordPress functions.", format: "sandbox path" }, { name: "tests-dir", description: "WP PHPUnit tests directory inside the sandbox.", format: "sandbox path" }, { name: "phpunit-xml", description: "phpunit.xml path inside the plugin.", format: "path" }, + { name: "phpunit-xml-default", description: "Marks phpunit-xml as the standard default path, allowing an adjacent phpunit.xml fallback only when phpunit.xml.dist is absent.", format: "boolean" }, { name: "test-file", description: "Single test file to run.", format: "path" }, - { name: "changed-tests-json", description: "Changed test files for diagnostics.", format: "JSON array" }, + { name: "changed-tests-json", description: "Optional changed-scope selection. A non-empty JSON array of test file paths runs only matching files and may legitimately select zero tests.", format: "JSON array of non-empty paths" }, { name: "env-json", description: "PHPUnit environment values.", format: "JSON object" }, { name: "wp-config-defines-json", description: "wp-config.php constants for the run.", format: "JSON object" }, { name: "dependency-mounts", description: "Comma-separated mounted dependency paths loaded and activated after managed PHPUnit installation, before tests execute.", format: "comma-separated sandbox paths" }, @@ -1062,8 +1063,9 @@ export const commandRegistry = [ { name: "core-root", description: "WordPress develop checkout root inside the sandbox. Must contain vendor/ with Composer dev dependencies (PHPUnit + yoast/phpunit-polyfills) installed before mounting.", format: "sandbox path" }, { name: "tests-dir", description: "Core tests directory inside the sandbox (expects includes/bootstrap.php under it).", format: "sandbox path" }, { name: "phpunit-xml", description: "phpunit.xml path.", format: "path" }, + { name: "phpunit-xml-default", description: "Marks phpunit-xml as the standard default path, allowing an adjacent phpunit.xml fallback only when phpunit.xml.dist is absent.", format: "boolean" }, { name: "test-file", description: "Single test file to run.", format: "path" }, - { name: "changed-tests-json", description: "Changed test files for diagnostics.", format: "JSON array" }, + { name: "changed-tests-json", description: "Optional changed-scope selection. A non-empty JSON array of test file paths runs only matching files and may legitimately select zero tests.", format: "JSON array of non-empty paths" }, { name: "autoload-file", description: "Autoload path inside the sandbox (typically /vendor/autoload.php from a completed composer install).", format: "sandbox path" }, { name: "wp-config-defines-json", description: "wp-config.php constants for the run.", format: "JSON object" }, { name: "multisite", description: "Run as multisite.", format: "boolean" }, diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index e1e57421..da5b18e1 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -110,6 +110,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio commandArg("tests-dir", options.testsDir ?? "/wp-codebox-vendor/wp-phpunit/wp-phpunit"), commandArg("test-root", options.testRoot ?? `${pluginTarget}/tests`), commandArg("phpunit-xml", options.phpunitXml ?? `${pluginTarget}/phpunit.xml.dist`), + commandArg("phpunit-xml-default", options.phpunitXml === undefined ? "1" : ""), commandStringListArg("dependency-mounts", options.dependencyMounts ?? []), commandJsonArg("bootstrap-files-json", options.bootstrapFiles ?? []), commandJsonArg("preload-files-json", options.preloadFiles ?? []), diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index b3f7672c..961c2b61 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -9,8 +9,9 @@ export interface PhpunitRunCodeOptions { testsDir: string testRoot?: string phpunitXml: string + phpunitXmlIsDefault: boolean selectedTestFile: string - changedTestFiles: unknown[] + changedTestFiles: string[] phpunitArgs: string[] env: Record wpConfigDefines: Record @@ -32,8 +33,9 @@ export interface CorePhpunitRunCodeOptions { coreRoot: string testsDir: string phpunitXml: string + phpunitXmlIsDefault: boolean selectedTestFile: string - changedTestFiles: unknown[] + changedTestFiles: string[] autoloadFile: string wpConfigDefines: Record multisite: boolean @@ -51,9 +53,6 @@ export const PLUGIN_PHPUNIT_RESULT_FILE = "/tmp/wp-codebox-phpunit-result.txt" interface PhpunitConfigDiscoveryPhpOptions { functionName: string logFunction: string - missingConfigMessage: string - parseFailureMessage: string - includeParseFailureDetail: boolean loadedConfigMessage: string fallbackXmlDist: boolean restrictDirectoriesToTests: boolean @@ -68,7 +67,6 @@ interface PhpunitChangedTestFilterPhpOptions { logFunction: string rootParameterName: string testsPathFallback: boolean - emptyWantedNotice: boolean } function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): string { @@ -90,9 +88,6 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s const returnValues = options.uniqueReturnValues ? "array(array_values(array_unique($directories)), array_values(array_unique($suffixes)), array_values(array_unique($prefixes)), $excludes, array_values(array_unique($files)))" : "array($directories, $suffixes, $prefixes, $excludes, $files)" - const parseFailureLog = options.includeParseFailureDetail - ? `${options.logFunction}('${options.parseFailureMessage}' . $first . '); using defaults');` - : `${options.logFunction}('${options.parseFailureMessage}');` const suffixAssignment = options.replaceDefaultMatchers ? "$suffixes = $config_suffixes;" : "$suffixes = array_merge($suffixes, $config_suffixes);" const prefixAssignment = options.replaceDefaultMatchers ? "$prefixes = $config_prefixes;" : "$prefixes = array_merge($prefixes, $config_prefixes);" @@ -106,8 +101,7 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s return ${returnValues}; };${fallbackXmlDist} if (!is_readable($xml_path)) { - ${options.logFunction}('${options.missingConfigMessage}' . $xml_path . '; using defaults'); - return $return_values(); + throw new RuntimeException('PHPUnit config is not readable: ' . $xml_path); } $prev = libxml_use_internal_errors(true); $xml = @simplexml_load_file($xml_path); @@ -116,8 +110,7 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s libxml_use_internal_errors($prev); if ($xml === false) { $first = $errors ? trim($errors[0]->message) : 'unknown'; - ${parseFailureLog} - return $return_values(); + throw new RuntimeException('PHPUnit config could not be parsed at ' . $xml_path . ': ' . $first); } $base = ${options.basePathExpression}; $config_dirs = array(); @@ -170,11 +163,10 @@ function phpunitDiscoveryPhp(functionName: string, logFunction: string): string $found = array(); foreach ($files as $file) { if (!is_string($file) || $file === '') { - continue; + throw new RuntimeException('configured PHPUnit test file is invalid'); } - if (!is_file($file) || pathinfo($file, PATHINFO_EXTENSION) !== 'php') { - ${logFunction}('NOTICE:test file does not exist: ' . $file); - continue; + if (!is_file($file) || !is_readable($file) || pathinfo($file, PATHINFO_EXTENSION) !== 'php') { + throw new RuntimeException('configured PHPUnit test file is not a readable PHP file: ' . $file); } foreach ($excludes as $exclude) { if (strpos($file, $exclude) === 0) { @@ -184,9 +176,8 @@ function phpunitDiscoveryPhp(functionName: string, logFunction: string): string $found[] = $file; } foreach ($directories as $dir) { - if (!is_dir($dir)) { - ${logFunction}('NOTICE:test directory does not exist: ' . $dir); - continue; + if (!is_dir($dir) || !is_readable($dir)) { + throw new RuntimeException('configured PHPUnit test directory is not a readable directory: ' . $dir); } $iterator = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, RecursiveDirectoryIterator::SKIP_DOTS), RecursiveIteratorIterator::LEAVES_ONLY); foreach ($iterator as $file) { @@ -229,12 +220,6 @@ function phpunitChangedTestFilterPhp(options: PhpunitChangedTestFilterPhpOptions const testsPathFallback = options.testsPathFallback ? ` elseif (strpos($path, '/tests/') !== false) { $path = substr($path, strpos($path, '/tests/') + 1); }` : "" - const emptyWantedNotice = options.emptyWantedNotice ? ` - if (empty($wanted)) { - ${options.logFunction}('NOTICE:changed tests did not contain usable test paths'); - return array(); - }` : "" - return `function ${options.filterFunctionName}(array $test_files, string $changed_files_json, string $${options.rootParameterName}): array { $decoded = json_decode($changed_files_json, true); if (!is_array($decoded) || empty($decoded)) { @@ -249,7 +234,7 @@ function phpunitChangedTestFilterPhp(options: PhpunitChangedTestFilterPhpOptions if ($normalized !== '') { $wanted[$normalized] = true; } - }${emptyWantedNotice} + } $filtered = array(); foreach ($test_files as $file) { if (isset($wanted[${options.relativeFunctionName}((string) $file, $${options.rootParameterName})])) { @@ -360,6 +345,7 @@ $tests_dir = ${JSON.stringify(options.testsDir)}; $test_root = ${JSON.stringify(options.testRoot || `/wordpress/wp-content/plugins/${options.pluginSlug}/tests`)}; $selected_test_file = ${JSON.stringify(options.selectedTestFile)}; $changed_test_files_raw = ${JSON.stringify(JSON.stringify(options.changedTestFiles))}; +$changed_test_scope = ${JSON.stringify(options.changedTestFiles.length > 0)}; $phpunit_args_raw = json_decode(${JSON.stringify(JSON.stringify(options.phpunitArgs))}, true); $bench_env = json_decode(${JSON.stringify(JSON.stringify(options.env))}, true); $wp_config_defines = json_decode(${JSON.stringify(JSON.stringify(options.wpConfigDefines))}, true); @@ -765,8 +751,8 @@ function pg_plugin_real_path(string $relative_path, string $kind): ?string { return $real; } -function pg_project_bootstrap_from_config(string $xml_path): string { - if (!is_readable($xml_path) && basename($xml_path) === 'phpunit.xml.dist') { +function pg_project_bootstrap_from_config(string &$xml_path, bool $xml_is_default): string { + if ($xml_is_default && !is_readable($xml_path) && basename($xml_path) === 'phpunit.xml.dist') { $alternate = dirname($xml_path) . '/phpunit.xml'; if (is_readable($alternate)) { $xml_path = $alternate; @@ -838,9 +824,10 @@ function pg_run_project_bootstrap_stage(array $cfg): void { try { $bootstrap = trim((string) ($cfg['project_bootstrap'] ?? '')); $phpunit_xml = (string) ($cfg['phpunit_xml'] ?? ''); + $phpunit_xml_is_default = !empty($cfg['phpunit_xml_is_default']); $from_config = false; if ($bootstrap === '') { - $bootstrap = pg_project_bootstrap_from_config($phpunit_xml); + $bootstrap = pg_project_bootstrap_from_config($phpunit_xml, $phpunit_xml_is_default); $from_config = $bootstrap !== ''; } $bootstrap_real = pg_project_bootstrap_real_path($bootstrap, $phpunit_xml, $from_config); @@ -1038,7 +1025,6 @@ ${phpunitChangedTestFilterPhp({ logFunction: "pg_log", rootParameterName: "plugin_path", testsPathFallback: true, - emptyWantedNotice: true, })} pg_install_diagnostics_handlers(); @@ -1089,7 +1075,7 @@ $config_path = pg_run_boot_stage(array('extra_defines' => $wp_config_defines, 't if ($bootstrap_mode === 'project') { pg_prepare_project_bootstrap_environment($config_path); pg_skip_project_bootstrap_shell_install(); - pg_run_project_bootstrap_stage(array('project_bootstrap' => $project_bootstrap, 'phpunit_xml' => ${JSON.stringify(options.phpunitXml)})); + pg_run_project_bootstrap_stage(array('project_bootstrap' => $project_bootstrap, 'phpunit_xml' => ${JSON.stringify(options.phpunitXml)}, 'phpunit_xml_is_default' => ${JSON.stringify(options.phpunitXmlIsDefault)})); pg_run_project_autoload_stage($project_autoload_file !== '' ? $project_autoload_file : $legacy_project_autoload_file); } else { if ($bootstrap_mode !== 'managed') { @@ -1216,11 +1202,8 @@ try { ${phpunitConfigDiscoveryPhp({ functionName: "wp_codebox_phpunit_parse_config", logFunction: "pg_log", - missingConfigMessage: "NOTICE:phpunit.xml.dist not readable at ", - parseFailureMessage: "NOTICE:phpunit.xml.dist parse failed (", - includeParseFailureDetail: true, loadedConfigMessage: "NOTICE:phpunit.xml.dist loaded from ", - fallbackXmlDist: true, + fallbackXmlDist: options.phpunitXmlIsDefault, restrictDirectoriesToTests: !options.testRoot, basePathExpression: "dirname($xml_path)", uniqueReturnValues: false, @@ -1233,10 +1216,7 @@ pg_stage_begin('discover_tests'); try { $test_dir = $test_root; if (!is_dir($test_dir)) { - pg_log('NO_TEST_FILES'); - pg_log('NOTICE:tests directory not found at ' . $test_dir); - pg_stage_ok('discover_tests'); - exit(0); + throw new RuntimeException('configured PHPUnit test root is not a readable directory: ' . $test_dir); } list($directories, $suffixes, $prefixes, $excludes, $configured_files) = wp_codebox_phpunit_parse_config(${JSON.stringify(options.phpunitXml)}, $test_dir); $test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files); @@ -1258,10 +1238,9 @@ try { $test_files = array($selected_abs); } pg_log('DISCOVERY: dirs=' . implode(',', $directories) . ' files=' . count($configured_files) . ' suffixes=' . implode(',', $suffixes) . ' prefixes=' . implode(',', $prefixes) . ' excludes=' . count($excludes) . ' found=' . count($test_files)); - if (empty($test_files)) { + if (empty($test_files) && !$changed_test_scope) { pg_log('NO_TEST_FILES'); - pg_stage_ok('discover_tests'); - exit(0); + throw new RuntimeException('PHPUnit discovery found no test files'); } pg_stage_ok('discover_tests'); } catch (Throwable $e) { @@ -1347,6 +1326,7 @@ $tests_dir = rtrim(${JSON.stringify(options.testsDir)}, '/'); $phpunit_xml = ${JSON.stringify(options.phpunitXml)}; $selected_test_file = ${JSON.stringify(options.selectedTestFile)}; $changed_test_files_raw = ${JSON.stringify(JSON.stringify(options.changedTestFiles))}; +$changed_test_scope = ${JSON.stringify(options.changedTestFiles.length > 0)}; $autoload_file = ${JSON.stringify(options.autoloadFile)}; $wp_config_defines = json_decode(${JSON.stringify(JSON.stringify(options.wpConfigDefines))}, true); $multisite = ${JSON.stringify(options.multisite)}; @@ -1482,11 +1462,8 @@ function core_pg_write_tests_config(string $core_root, array $extra_defines): st ${phpunitConfigDiscoveryPhp({ functionName: "core_pg_parse_phpunit_config", logFunction: "core_pg_log", - missingConfigMessage: "NOTICE:phpunit config not readable at ", - parseFailureMessage: "NOTICE:phpunit config parse failed; using defaults", - includeParseFailureDetail: false, loadedConfigMessage: "NOTICE:phpunit config loaded from ", - fallbackXmlDist: false, + fallbackXmlDist: options.phpunitXmlIsDefault, restrictDirectoriesToTests: false, basePathExpression: "dirname($xml_path)", uniqueReturnValues: true, @@ -1501,7 +1478,6 @@ ${phpunitChangedTestFilterPhp({ logFunction: "core_pg_log", rootParameterName: "core_root", testsPathFallback: false, - emptyWantedNotice: false, })} ${phpunitArgsPhp("core_pg_phpunit_args", "core_pg_log")} @@ -1584,10 +1560,9 @@ try { $test_files = array($selected_abs); } core_pg_log('DISCOVERY: dirs=' . implode(',', $directories) . ' files=' . count($configured_files) . ' suffixes=' . implode(',', $suffixes) . ' prefixes=' . implode(',', $prefixes) . ' excludes=' . count($excludes) . ' found=' . count($test_files)); - if (empty($test_files)) { + if (empty($test_files) && !$changed_test_scope) { core_pg_log('NO_TEST_FILES'); - core_pg_stage_ok('discover_tests'); - exit(0); + throw new RuntimeException('PHPUnit discovery found no test files'); } core_pg_stage_ok('discover_tests'); } catch (Throwable $e) { diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index c9a9ff92..a85fccf1 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -907,6 +907,7 @@ export async function runPhpunitCommand({ spec: ExecutionSpec }): Promise { const args = spec.args ?? [] + 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" @@ -923,9 +924,10 @@ export async function runPhpunitCommand({ projectAutoloadFile: argValue(args, "project-autoload-file")?.trim() || "", testsDir: argValue(args, "tests-dir")?.trim() || "/wp-codebox-vendor/wp-phpunit/wp-phpunit", testRoot: argValue(args, "test-root")?.trim() || `/wordpress/wp-content/plugins/${pluginSlug}/tests`, - phpunitXml: argValue(args, "phpunit-xml")?.trim() || `/wordpress/wp-content/plugins/${pluginSlug}/phpunit.xml.dist`, + phpunitXml: phpunitXmlArg?.trim() || `/wordpress/wp-content/plugins/${pluginSlug}/phpunit.xml.dist`, + phpunitXmlIsDefault: phpunitXmlArg === undefined || booleanArg(args, "phpunit-xml-default"), selectedTestFile: argValue(args, "test-file")?.trim() || "", - changedTestFiles: jsonArrayArg(args, "changed-tests-json"), + changedTestFiles: changedTestFilesArg(args), phpunitArgs: jsonArrayArg(args, "phpunit-args-json").filter((value): value is string => typeof value === "string"), env: jsonObjectArg(args, "env-json"), wpConfigDefines: jsonObjectArg(args, "wp-config-defines-json"), @@ -974,6 +976,15 @@ function boundedProcessIdentity(value: string | undefined): string { return value } +function changedTestFilesArg(args: string[]): string[] { + return jsonArrayArg(args, "changed-tests-json").map((value) => { + if (typeof value !== "string" || value.trim() === "") { + throw new Error("changed-tests-json must be a JSON array of non-empty test file paths") + } + return value.trim() + }) +} + export async function runCorePhpunitCommand({ artifactRoot, runPlaygroundCommand, @@ -986,6 +997,7 @@ export async function runCorePhpunitCommand({ spec: ExecutionSpec }): Promise { const args = spec.args ?? [] + const phpunitXmlArg = argValue(args, "phpunit-xml") const explicitCode = argValue(args, "code") || argValue(args, "code-file") // Write structured diagnostics to a sandbox-internal /tmp path rather than inside // the (often read-only) core mount, so the result survives read-only mounts and a @@ -994,9 +1006,10 @@ export async function runCorePhpunitCommand({ const code = explicitCode ? await phpCodeFromArgs(args, "wordpress.core-phpunit") : normalizePhpCode(corePhpunitRunCode({ coreRoot: argValue(args, "core-root")?.trim() || "/wordpress", testsDir: argValue(args, "tests-dir")?.trim() || "/wordpress/tests/phpunit", - phpunitXml: argValue(args, "phpunit-xml")?.trim() || "/wordpress/tests/phpunit/phpunit.xml.dist", + phpunitXml: phpunitXmlArg?.trim() || "/wordpress/tests/phpunit/phpunit.xml.dist", + phpunitXmlIsDefault: phpunitXmlArg === undefined || booleanArg(args, "phpunit-xml-default"), selectedTestFile: argValue(args, "test-file")?.trim() || "", - changedTestFiles: jsonArrayArg(args, "changed-tests-json"), + changedTestFiles: changedTestFilesArg(args), autoloadFile: argValue(args, "autoload-file")?.trim() || "/wordpress/vendor/autoload.php", wpConfigDefines: jsonObjectArg(args, "wp-config-defines-json"), multisite: booleanArg(args, "multisite"), diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index 8c34005b..d51f53d8 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -27,6 +27,7 @@ assert.ok(!projectRecipeWithoutAutoload.some((arg) => arg === "autoload-file-rol const managedRecipe = phpunitRecipeArgs({}) assert.ok(managedRecipe.includes("autoload-file=/wp-codebox-vendor/autoload.php")) assert.ok(managedRecipe.includes("autoload-file-role=harness")) +assert.ok(managedRecipe.includes("phpunit-xml-default=1"), "default recipe XML retains implicit fallback semantics") const explicitAutoloadRecipe = phpunitRecipeArgs({ bootstrapMode: "project", @@ -36,6 +37,7 @@ const explicitAutoloadRecipe = phpunitRecipeArgs({ assert.ok(explicitAutoloadRecipe.includes("autoload-file=/wp-codebox-vendor/autoload.php")) assert.ok(explicitAutoloadRecipe.includes("autoload-file-role=harness")) assert.ok(explicitAutoloadRecipe.includes("project-autoload-file=/workspace/project/vendor/autoload.php")) +assert.ok(explicitAutoloadRecipe.includes("phpunit-xml-default=1")) function extractPhpFunction(source: string, functionName: string): string { const start = source.indexOf(`function ${functionName}(`) @@ -63,32 +65,114 @@ function phpString(value: string): string { return `'${value.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}'` } -function assertPhpunitParseConfigFallbacksReturnFiveTuple(source: string, functionName: string, logFunctionName: string): void { +function assertPhpunitConfigurationAndDiscoveryFailures(source: string, functionName: string, discoveryFunctionName: string, logFunctionName: string, supportsImplicitFallback: boolean): void { const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-config-")) const malformedXml = join(tempDir, "phpunit.xml.dist") + const malformedAdjacentXml = join(tempDir, "phpunit.xml") + const explicitMissingXml = join(tempDir, "missing.xml.dist") + const explicitAdjacentXml = join(tempDir, "missing.xml") + const defaultXml = join(tempDir, "default", "phpunit.xml.dist") + const adjacentXml = join(tempDir, "default", "phpunit.xml") const scriptPath = join(tempDir, "assert-phpunit-config.php") writeFileSync(malformedXml, "") + writeFileSync(malformedAdjacentXml, "must-not-mask-malformed") + writeFileSync(explicitAdjacentXml, "must-not-fallback") + mkdirSync(join(tempDir, "default"), { recursive: true }) + writeFileSync(adjacentXml, "fallback-tests") const parseConfigFunction = extractPhpFunction(source, functionName) + const discoveryFunction = extractPhpFunction(source, discoveryFunctionName) writeFileSync(scriptPath, `getMessage(), $expected) !== false) { + return; + } + throw new RuntimeException($label . ' returned the wrong error: ' . $error->getMessage()); } + throw new RuntimeException($label . ' unexpectedly succeeded'); +} +${!supportsImplicitFallback ? `assert_phpunit_error(function() { ${functionName}(${phpString(explicitMissingXml)}, ${phpString(join(tempDir, "tests"))}); }, 'PHPUnit config is not readable', 'explicit missing config');` : ""} +assert_phpunit_error(function() { ${functionName}(${phpString(malformedXml)}, ${phpString(join(tempDir, "tests"))}); }, 'PHPUnit config could not be parsed', 'malformed config'); +assert_phpunit_error(function() { ${discoveryFunctionName}(array(${phpString(join(tempDir, "missing-tests"))}), array('Test.php'), array('test-'), array()); }, 'configured PHPUnit test directory is not a readable directory', 'missing configured directory'); +assert_phpunit_error(function() { ${discoveryFunctionName}(array(), array('Test.php'), array('test-'), array(), array(${phpString(join(tempDir, "MissingTest.php"))})); }, 'configured PHPUnit test file is not a readable PHP file', 'missing configured file'); +${supportsImplicitFallback ? ` +$fallback = ${functionName}(${phpString(defaultXml)}, ${phpString(join(tempDir, "tests"))}); +if ($fallback[0] !== array(${phpString(join(tempDir, "default", "fallback-tests"))})) { + throw new RuntimeException('implicit phpunit.xml fallback did not load the adjacent config'); } -assert_phpunit_config_tuple(${functionName}(${phpString(join(tempDir, "missing.xml.dist"))}, ${phpString(join(tempDir, "tests"))}), 'missing config'); -assert_phpunit_config_tuple(${functionName}(${phpString(malformedXml)}, ${phpString(join(tempDir, "tests"))}), 'parse failure'); +unlink(${phpString(adjacentXml)}); +assert_phpunit_error(function() { ${functionName}(${phpString(defaultXml)}, ${phpString(join(tempDir, "tests"))}); }, 'PHPUnit config is not readable', 'both default configs missing');` : ""} echo "ok\n"; `) assert.equal(execFileSync("php", [scriptPath], { encoding: "utf8" }), "ok\n") } +function assertChangedScopeNoOp(source: string, filterFunctionName: string, relativeFunctionName: string): void { + const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-changed-scope-")) + const scriptPath = join(tempDir, "assert-changed-scope.php") + const filterFunction = extractPhpFunction(source, filterFunctionName) + const relativeFunction = extractPhpFunction(source, relativeFunctionName) + writeFileSync(scriptPath, ``) + + writeFileSync(explicitScript, ` extractPhpFunction(explicitSource, name)).join("\n")} +pg_run_project_bootstrap_stage(array('project_bootstrap' => '', 'phpunit_xml' => ${phpString(missingDefault)}, 'phpunit_xml_is_default' => false)); +`) + assert.throws(() => execFileSync("php", [explicitScript], { encoding: "utf8" }), /Command failed/, "an explicit missing config must fail before an adjacent bootstrap can execute") + assert.equal(existsSync(marker), false, "explicit missing config must not execute the adjacent phpunit.xml bootstrap") + + writeFileSync(implicitScript, ` extractPhpFunction(implicitSource, name)).join("\n")} +pg_run_project_bootstrap_stage(array('project_bootstrap' => '', 'phpunit_xml' => ${phpString(missingDefault)}, 'phpunit_xml_is_default' => true)); +`) + execFileSync("php", [implicitScript], { encoding: "utf8" }) + assert.equal(readFileSync(marker, "utf8"), "ran", "an implicit default config must execute the adjacent phpunit.xml bootstrap") +} + +function decodedBootstrapWrapper(source: string): string { + const encoded = source.match(/base64_decode\("([A-Za-z0-9+/=]+)"\)/)?.[1] + assert.ok(encoded, "PHPUnit payload must execute inside the bootstrap diagnostic wrapper") + return Buffer.from(encoded, "base64").toString("utf8") +} + function assertSelectedTestFileResolution(source: string): void { const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-selected-test-file-")) const pluginRoot = join(tempDir, "demo-plugin") @@ -312,6 +396,7 @@ assert.ok(!rewriteInputMountPathArgs(recipe.workflow.steps[0].args, recipeInputM assert.ok(recipe.workflow.steps[0].args.includes("cwd=/home/example/public_html")) assert.ok(recipe.workflow.steps[0].args.includes("test-root=/home/example/public_html/bin/tests/phpunit")) assert.ok(recipe.workflow.steps[0].args.includes("phpunit-xml=/home/example/public_html/bin/tests/phpunit/phpunit.xml.dist")) +assert.ok(recipe.workflow.steps[0].args.includes("phpunit-xml-default="), "explicit phpunit-xml paths must not opt into adjacent-file fallback") const projectModeCode = phpunitRunCode({ pluginSlug: "woocommerce", @@ -320,6 +405,7 @@ const projectModeCode = phpunitRunCode({ testsDir: "/wp-codebox-vendor/wp-phpunit/wp-phpunit", testRoot: "/home/example/public_html/bin/tests/phpunit", phpunitXml: "/wordpress/wp-content/plugins/woocommerce/phpunit.xml.dist", + phpunitXmlIsDefault: false, selectedTestFile: "", changedTestFiles: [], phpunitArgs: ["--list-tests"], @@ -332,6 +418,26 @@ const projectModeCode = phpunitRunCode({ multisite: false, }) +const implicitProjectConfigCode = phpunitRunCode({ + pluginSlug: "woocommerce", + cwd: "/wordpress/wp-content/plugins/woocommerce", + autoloadFile: woocommerceAutoload, + testsDir: "/wp-codebox-vendor/wp-phpunit/wp-phpunit", + testRoot: "/home/example/public_html/bin/tests/phpunit", + phpunitXml: "/wordpress/wp-content/plugins/woocommerce/phpunit.xml.dist", + phpunitXmlIsDefault: true, + selectedTestFile: "", + changedTestFiles: [], + phpunitArgs: [], + env: {}, + wpConfigDefines: {}, + dependencyMounts: [], + bootstrapFiles: [], + bootstrapMode: "project", + projectBootstrap: "", + multisite: false, +}) + const bootIndex = projectModeCode.indexOf("$config_path = pg_run_boot_stage") const projectBootstrapIndex = projectModeCode.indexOf("pg_run_project_bootstrap_stage", bootIndex) const projectAutoloadIndex = projectModeCode.indexOf("pg_run_project_autoload_stage", projectBootstrapIndex) @@ -352,14 +458,17 @@ assert.ok(projectModeCode.includes("pg_resolve_selected_test_file")) assert.ok(projectModeCode.includes("function pg_project_bootstrap_real_path")) assert.ok(projectModeCode.includes("$base_dir = dirname($xml_real);")) assert.ok(projectModeCode.includes("$bootstrap_real = pg_project_bootstrap_real_path($bootstrap, $phpunit_xml, $from_config);")) +assert.ok(projectModeCode.includes("function pg_project_bootstrap_from_config(string &$xml_path, bool $xml_is_default): string")) +assertProjectBootstrapConfigResolution(projectModeCode, implicitProjectConfigCode) assert.ok(projectModeCode.includes("foreach ($xml->xpath('//testsuite/file') ?: array() as $file)")) assert.ok(projectModeCode.includes("list($directories, $suffixes, $prefixes, $excludes, $configured_files) = wp_codebox_phpunit_parse_config")) assert.ok(projectModeCode.includes("$test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files);")) assert.ok(projectModeCode.includes("' files=' . count($configured_files)")) assert.equal(projectModeCode.match(/return array\(\$directories, \$suffixes, \$prefixes, \$excludes\);/g)?.length ?? 0, 0) -assert.equal(projectModeCode.match(/return \$return_values\(\);/g)?.length, 3) -assert.match(projectModeCode, /if \(empty\(\$test_files\)\) \{\s+pg_log\('NO_TEST_FILES'\);\s+pg_stage_ok\('discover_tests'\);\s+exit\(0\);/) -assertPhpunitParseConfigFallbacksReturnFiveTuple(projectModeCode, "wp_codebox_phpunit_parse_config", "pg_log") +assert.equal(projectModeCode.match(/return \$return_values\(\);/g)?.length, 1) +assert.match(projectModeCode, /configured PHPUnit test root is not a readable directory/) +assertPhpunitConfigurationAndDiscoveryFailures(projectModeCode, "wp_codebox_phpunit_parse_config", "wp_codebox_phpunit_discover", "pg_log", false) +assertChangedScopeNoOp(projectModeCode, "pg_filter_changed_test_files", "pg_component_relative_path") assertSelectedTestFileResolution(projectModeCode) assert.ok(projectModeCode.includes("function pg_ensure_phpunit_harness_loaded(): void")) @@ -380,6 +489,7 @@ const canonicalHarnessProjectModeCode = phpunitRunCode({ testsDir: "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/wp-phpunit/wp-phpunit", testRoot: "/home/example/public_html/bin/tests/phpunit", phpunitXml: "/wordpress/wp-content/plugins/woocommerce/phpunit.xml.dist", + phpunitXmlIsDefault: false, selectedTestFile: "", changedTestFiles: [], phpunitArgs: [], @@ -428,10 +538,11 @@ await runPhpunitCommand({ ], }, }) -assert.ok(capturedCanonicalHarnessCode.includes('$autoload_file = "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php";')) -assert.ok(capturedCanonicalHarnessCode.includes('$autoload_file_role = "harness";')) -assert.ok(capturedCanonicalHarnessCode.includes('putenv("TC_MYSQL_PORT=3306");'), "runtime service environment is passed to the PHP executed by wordpress.phpunit") -assert.ok(capturedCanonicalHarnessCode.indexOf('putenv("TC_MYSQL_PORT=3306");') < capturedCanonicalHarnessCode.indexOf("require_once '/wordpress/wp-load.php';"), "runtime environment is available to project bootstrap code") +const decodedCanonicalHarnessCode = decodedBootstrapWrapper(capturedCanonicalHarnessCode) +assert.ok(decodedCanonicalHarnessCode.includes('$autoload_file = "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php";')) +assert.ok(decodedCanonicalHarnessCode.includes('$autoload_file_role = "harness";')) +assert.ok(decodedCanonicalHarnessCode.includes('putenv("TC_MYSQL_PORT=3306");'), "runtime service environment is passed to the PHP executed by wordpress.phpunit") +assert.ok(decodedCanonicalHarnessCode.indexOf('putenv("TC_MYSQL_PORT=3306");') < decodedCanonicalHarnessCode.indexOf("require_once '/wordpress/wp-load.php';"), "runtime environment is available to project bootstrap code") let capturedExplicitCode = "" await runPhpunitCommand({ @@ -448,14 +559,16 @@ await runPhpunitCommand({ args: ["code= Date: Tue, 21 Jul 2026 14:50:51 -0400 Subject: [PATCH 4/4] Add PHPUnit config to readonly cache fixture --- tests/playground-phpunit-readonly-cache.integration.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/playground-phpunit-readonly-cache.integration.test.ts b/tests/playground-phpunit-readonly-cache.integration.test.ts index 020635c2..8588ddd8 100644 --- a/tests/playground-phpunit-readonly-cache.integration.test.ts +++ b/tests/playground-phpunit-readonly-cache.integration.test.ts @@ -62,13 +62,14 @@ async function writeFixture(): Promise { await mkdir(join(plugin, "tests"), { recursive: true }) await mkdir(dependency, { recursive: true }) await writeFile(join(plugin, "readonly-phpunit-fixture.php"), "\ntests\n") await writeFile(join(plugin, "source-sentinel.bin"), sentinel) await writeFile(join(plugin, "tests", "ReadonlyCacheTest.php"), "assertTrue(is_multisite()); } public function test_sentinel_is_available(): void { $this->assertGreaterThan(0, filesize(dirname(__DIR__) . \'/source-sentinel.bin\')); } public function test_dependency_activation_runs_after_install(): void { $this->assertGreaterThanOrEqual(1, get_option(\'wp_codebox_dependency_activation_users\')); } public function test_dependency_plugins_loaded_runs_once(): void { $this->assertSame(1, (int) get_option(\'wp_codebox_dependency_plugins_loaded_count\')); } public function test_wp_cli_namespaced_stdout_is_available(): void { $this->assertTrue(eval(\'namespace cli; return is_resource(STDOUT);\')); } }\n") await writeFile(join(dependency, "activation-dependency.php"), " 1)))); });\n") } async function digestTree(directory: string): Promise { - const files = ["readonly-phpunit-fixture.php", "source-sentinel.bin", "tests/ReadonlyCacheTest.php"] + const files = ["readonly-phpunit-fixture.php", "phpunit.xml.dist", "source-sentinel.bin", "tests/ReadonlyCacheTest.php"] const hash = createHash("sha256") for (const file of files) { hash.update(file)