From e7023219be4c5eefcb5f3c8ee4b5cc452445830b Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Wed, 21 Oct 2026 00:10:57 -0400 Subject: [PATCH] fix(phpunit): report empty discovery as an empty result Discovery-only mode threw "PHPUnit discovery found no test files" and the discovery reader rejected files: [], so a component whose suite is entirely outside PHPUnit (standalone PHP smokes) could not get a discovery answer at all. Discovery now reports what exists; consumers decide whether zero PHPUnit files is acceptable. Execution mode still refuses to run nothing. --- .../src/phpunit-command-handlers.ts | 5 +- .../src/runtime-diagnostics.ts | 2 +- tests/phpunit-discovery-only.test.ts | 73 +++++++++++++++++++ 3 files changed, 78 insertions(+), 2 deletions(-) diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index edb900d9..0eb9f13d 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -1384,9 +1384,12 @@ if ($discovery_only) { } list($directories, $suffixes, $prefixes, $excludes, $configured_files) = wp_codebox_phpunit_parse_config(${JSON.stringify(options.phpunitXml)}, $test_dir, $selected_testsuites); $test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files); + // Discovery reports what exists. An empty set is a valid answer for a + // component whose suite lives outside PHPUnit; the consumer decides + // whether zero PHPUnit files is acceptable. Execution still refuses to + // run nothing (see discover_tests below). if (empty($test_files)) { pg_log('NO_TEST_FILES'); - throw new RuntimeException('PHPUnit discovery found no test files'); } sort($test_files, SORT_STRING); $discovery_result = array( diff --git a/packages/runtime-playground/src/runtime-diagnostics.ts b/packages/runtime-playground/src/runtime-diagnostics.ts index f9b9b636..aaba9ff9 100644 --- a/packages/runtime-playground/src/runtime-diagnostics.ts +++ b/packages/runtime-playground/src/runtime-diagnostics.ts @@ -116,7 +116,7 @@ export async function readPluginPhpunitDiscoveryResult(server: PlaygroundCliServ || typeof value.phpunit_xml !== "string" || typeof value.test_root !== "string" || !Array.isArray(value.selected_testsuites) || value.selected_testsuites.some((entry) => typeof entry !== "string") - || !Array.isArray(value.files) || value.files.length === 0 || value.files.some((entry) => typeof entry !== "string" || entry === "")) { + || !Array.isArray(value.files) || value.files.some((entry) => typeof entry !== "string" || entry === "")) { return undefined } return value as PhpunitDiscoveryResult diff --git a/tests/phpunit-discovery-only.test.ts b/tests/phpunit-discovery-only.test.ts index 359f6aef..5ee17988 100644 --- a/tests/phpunit-discovery-only.test.ts +++ b/tests/phpunit-discovery-only.test.ts @@ -250,3 +250,76 @@ echo "ok\\n"; assert.equal(execFileSync("php", [scriptPath], { encoding: "utf8" }), "ok\n") }) }) + +// A component whose suite lives entirely outside PHPUnit (standalone PHP smokes) +// has no PHPUnit files. Discovery must report that as an empty set; the consumer +// decides whether zero PHPUnit files is acceptable. +test("discovery-only reports an empty test root as an empty result", async () => { + const code = phpunitRunCode({ + pluginSlug: "fixture", + cwd: "/wordpress/wp-content/plugins/fixture", + autoloadFile: "/wp-codebox-vendor/autoload.php", + testsDir: "/wp-codebox-vendor/wp-phpunit/wp-phpunit", + testRoot: "/wordpress/wp-content/plugins/fixture/tests", + phpunitXml: "/wordpress/wp-content/plugins/fixture/phpunit.xml.dist", + phpunitXmlIsDefault: true, + selectedTestFile: "", + changedTestFiles: [], + discoveryOnly: true, + phpunitArgs: [], + env: {}, + wpConfigDefines: {}, + dependencyMounts: [], + bootstrapFiles: [], + bootstrapMode: "managed", + projectBootstrap: "", + multisite: false, + databaseType: "sqlite", + }) + const blockStart = code.indexOf("if ($discovery_only) {") + const blockEnd = code.indexOf("if (!is_array($wp_config_defines))", blockStart) + assert.ok(blockStart >= 0 && blockEnd > blockStart, "expected the discovery-only block") + const discoveryBlock = code.slice(blockStart, blockEnd) + + const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-phpunit-empty-discovery-")) + const testsDir = join(tempDir, "tests") + mkdirSync(testsDir) + writeFileSync(join(testsDir, "profile-smoke.php"), "getMessage(), "\\n"; } +${extractPhpFunction(code, "wp_codebox_phpunit_parse_config")} +${extractPhpFunction(code, "wp_codebox_phpunit_discover")} +$discovery_only = true; +$plugin_slug = 'fixture'; +$test_root = ${phpString(testsDir)}; +$selected_testsuites = array(); +${discoveryBlock.replaceAll(JSON.stringify("/wordpress/wp-content/plugins/fixture/phpunit.xml.dist"), phpString(join(tempDir, "phpunit.xml.dist")))} +`, + ) + let output = "" + try { + output = execFileSync("php", [scriptPath], { encoding: "utf8" }) + } catch (error) { + assert.fail(`empty discovery must exit 0: ${(error as { stdout?: string }).stdout ?? error}`) + } + assert.doesNotMatch(output, /STAGE_FAIL/) + const line = output.split("\n").find((entry) => entry.startsWith("DISCOVERY_RESULT_JSON:")) + assert.ok(line, `expected a discovery result: ${output}`) + assert.deepEqual(JSON.parse(line.slice("DISCOVERY_RESULT_JSON:".length)).files, []) + + const result = await runPhpunitCommand({ + artifactRoot: "/tmp/artifacts", + mounts: [], + runPlaygroundCommand: async () => ({ exitCode: 0, errors: "", text: "" }), + runtimeSpec: wordpressRuntimeSpec({ commands: ["wordpress.phpunit"] }), + server: { playground: { readFileAsText: async () => `${line}\n` } } as never, + spec: { command: "wordpress.phpunit", args: ["plugin-slug=fixture", "discovery-only=1"] }, + }) + assert.deepEqual(JSON.parse(result).files, []) +})