From 879fa6f4531f53979556e25d8bb1265f73e3491d Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 16 Aug 2026 23:34:27 +0000 Subject: [PATCH 1/2] feat(phpunit): expose canonical discovery-only results --- package.json | 1 + packages/cli/src/commands/recipe-build.ts | 2 + .../cli/src/commands/recipe-runtime-setup.ts | 19 +++- packages/runtime-core/src/command-registry.ts | 3 +- packages/runtime-core/src/recipe-builders.ts | 2 + .../src/phpunit-command-handlers.ts | 34 +++++++ .../src/runtime-diagnostics.ts | 29 ++++++ .../src/wordpress-command-runners.ts | 24 ++++- tests/phpunit-discovery-only.test.ts | 94 +++++++++++++++++++ ...phpunit-readonly-cache.integration.test.ts | 48 +++++++++- 10 files changed, 248 insertions(+), 8 deletions(-) create mode 100644 tests/phpunit-discovery-only.test.ts diff --git a/package.json b/package.json index 3f2d73c4f..5b852a5e6 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "test:host-node-heap": "tsx tests/host-node-heap.test.ts", "test:phpunit-structured-evidence": "tsx tests/phpunit-structured-evidence.test.ts", "test:phpunit-runtime-rejection": "tsx tests/phpunit-runtime-rejection.test.ts", + "test:phpunit-discovery-only": "tsx --test tests/phpunit-discovery-only.test.ts", "test:php-wasm-runtime-rejection-any-command": "tsx tests/php-wasm-runtime-rejection-any-command.test.ts", "test:playground-worker-runtime-rejection": "tsx tests/playground-worker-runtime-rejection.test.ts", "test:php-wasm-extension-manifests": "tsx tests/php-wasm-extension-manifests.test.ts", diff --git a/packages/cli/src/commands/recipe-build.ts b/packages/cli/src/commands/recipe-build.ts index b903480b7..aa5c09d38 100644 --- a/packages/cli/src/commands/recipe-build.ts +++ b/packages/cli/src/commands/recipe-build.ts @@ -24,6 +24,7 @@ interface WordPressPhpunitBuilderOptions { cwd?: string selectedTestFile?: string changedTestFiles?: string[] + discoveryOnly?: boolean env?: Record wpConfigDefines?: Record autoloadFile?: string @@ -96,6 +97,7 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word cwd: stringOrUndefined(phpunitOptions.cwd), selectedTestFile: stringOrUndefined(phpunitOptions.selectedTestFile), changedTestFiles: Array.isArray(phpunitOptions.changedTestFiles) ? phpunitOptions.changedTestFiles : [], + discoveryOnly: Boolean(phpunitOptions.discoveryOnly), env: plainObject(phpunitOptions.env), wpConfigDefines: plainObject(phpunitOptions.wpConfigDefines), autoloadFile: stringOrUndefined(phpunitOptions.autoloadFile), diff --git a/packages/cli/src/commands/recipe-runtime-setup.ts b/packages/cli/src/commands/recipe-runtime-setup.ts index f9c40e128..2e56d8e99 100644 --- a/packages/cli/src/commands/recipe-runtime-setup.ts +++ b/packages/cli/src/commands/recipe-runtime-setup.ts @@ -1,7 +1,7 @@ import { cp, mkdtemp, rm, stat } from "node:fs/promises" import { tmpdir } from "node:os" import { join, posix, resolve } from "node:path" -import { phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type RuntimeCreateSpec, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core" +import { booleanCommandArg, phpRuntimeRecipePluginPreloadFunction, type ExecutionResult, type MountSpec, type Runtime, type RuntimeCreateSpec, type WorkspaceRecipe, type WorkspaceRecipeMount, type WorkspaceRecipePluginRuntimeHealthProbe } from "@automattic/wp-codebox-core" import { requiresManagedMysqlMultisitePreinstall } from "@automattic/wp-codebox-playground" import { installMuPluginsCode, installPluginComposerAutoloadersCode, prepareRecipeDependencyOverlays, prepareRecipeExtraPlugins, prepareRecipeRuntimeOverlays, prepareRecipeStagedFiles, prepareRecipeWorkspacePreloads, prepareRecipeWorkspaces, recipeMountType, type PreparedDependencyOverlay, type PreparedExtraPlugin, type PreparedRuntimeOverlay, type PreparedStagedFile, type PreparedWorkspaceMount } from "../recipe-sources.js" import { pluginRuntimeHealthProbeStep, type RecipeWorkflowPhase } from "../recipe-validation.js" @@ -209,6 +209,13 @@ export async function applyRecipeRuntimeSetup(args: { interruption?.throwIfInterrupted() } + // Discovery inventories mounted files only. Running setup PHP here would + // activate dependencies before the discovery command can enforce its + // no-bootstrap boundary. + if (recipeHasPhpunitDiscoveryOnly(recipe)) { + return { executions } + } + const isolateManagedMultisitePreinstall = recipeHasManagedMysqlMultisitePhpunit(recipe, runtimeSpec) const muPluginInstallCode = isolateManagedMultisitePreinstall ? null : installMuPluginsCode(extraPlugins) if (muPluginInstallCode) { @@ -271,6 +278,16 @@ function recipeHasManagedMysqlMultisitePhpunit(recipe: WorkspaceRecipe, runtimeS .some((step) => step.command === "wordpress.phpunit" && requiresManagedMysqlMultisitePreinstall(step.args ?? [], runtimeSpec)) } +export function recipeHasPhpunitDiscoveryOnly(recipe: WorkspaceRecipe): boolean { + const steps = [...(recipe.workflow.before ?? []), ...recipe.workflow.steps, ...(recipe.workflow.after ?? [])] + const discoverySteps = steps.filter((step) => step.command === "wordpress.phpunit" && booleanCommandArg(step.args ?? [], "discovery-only")) + if (discoverySteps.length === 0) return false + if (steps.length !== 1 || discoverySteps.length !== 1) { + throw new Error("wordpress.phpunit discovery-only must be the recipe's sole workflow step") + } + return true +} + export async function cleanupInputMountBaselines(paths: string[]): Promise { await Promise.all(paths.map((path) => rm(path, { recursive: true, force: true }))) paths.length = 0 diff --git a/packages/runtime-core/src/command-registry.ts b/packages/runtime-core/src/command-registry.ts index 399ed9dba..222f2dd25 100644 --- a/packages/runtime-core/src/command-registry.ts +++ b/packages/runtime-core/src/command-registry.ts @@ -977,6 +977,7 @@ export const commandRegistry = [ { 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: "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: "discovery-only", description: "Return the canonical discovered test-file list without executing PHPUnit.", format: "boolean" }, { 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" }, @@ -987,7 +988,7 @@ export const commandRegistry = [ { 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.", + outputShape: "Raw PHPUnit runner JSON/log output plus normalized test-results artifact, or wp-codebox/phpunit-discovery/v1 JSON when discovery-only=true.", policyRequirement: "Runtime policy commands must include wordpress.phpunit.", recipe: true, handler: { kind: "playground", method: "runPhpunit" }, diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index 34f553926..818181de9 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -30,6 +30,7 @@ export interface WordPressPhpunitRecipeOptions { cwd?: string selectedTestFile?: string changedTestFiles?: string[] + discoveryOnly?: boolean env?: JsonObject wpConfigDefines?: JsonObject autoloadFile?: string @@ -108,6 +109,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio commandArg("cwd", options.cwd ?? pluginTarget), commandArg("test-file", options.selectedTestFile ?? ""), commandJsonArg("changed-tests-json", options.changedTestFiles ?? []), + commandArg("discovery-only", options.discoveryOnly ? "1" : ""), commandJsonArg("env-json", options.env ?? {}), commandJsonArg("wp-config-defines-json", options.wpConfigDefines ?? {}), commandArg("autoload-file", autoloadFile), diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index 0a3585ddd..501a25d7b 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -12,6 +12,7 @@ export interface PhpunitRunCodeOptions { phpunitXmlIsDefault: boolean selectedTestFile: string changedTestFiles: string[] + discoveryOnly?: boolean phpunitArgs: string[] env: Record wpConfigDefines: Record @@ -506,6 +507,7 @@ $test_root = ${JSON.stringify(options.testRoot || `/wordpress/wp-content/plugins $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)}; +$discovery_only = ${JSON.stringify(options.discoveryOnly ?? false)}; $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); @@ -1294,6 +1296,38 @@ $phpunit_args = wp_codebox_phpunit_args($phpunit_argv); $selected_testsuites = $phpunit_args['wpCodeboxTestsuites']; unset($phpunit_args['wpCodeboxTestsuites']); +if ($discovery_only) { + pg_stage_begin('discover_tests'); + try { + $test_dir = $test_root; + if (!is_dir($test_dir)) { + 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, $selected_testsuites); + $test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files); + 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( + 'schema' => 'wp-codebox/phpunit-discovery/v1', + 'plugin_slug' => $plugin_slug, + 'phpunit_xml' => ${JSON.stringify(options.phpunitXml)}, + 'test_root' => $test_dir, + 'selected_testsuites' => array_values($selected_testsuites), + 'files' => array_values($test_files), + ); + pg_log('DISCOVERY: dirs=' . implode(',', $directories) . ' files=' . count($configured_files) . ' suffixes=' . implode(',', $suffixes) . ' prefixes=' . implode(',', $prefixes) . ' excludes=' . count($excludes) . ' found=' . count($test_files)); + pg_log('DISCOVERY_RESULT_JSON:' . json_encode($discovery_result, JSON_UNESCAPED_SLASHES)); + pg_stage_ok('discover_tests'); + exit(0); + } catch (Throwable $e) { + pg_stage_fail('discover_tests', $e); + exit(1); + } +} + if (!is_array($wp_config_defines)) { $wp_config_defines = array(); } diff --git a/packages/runtime-playground/src/runtime-diagnostics.ts b/packages/runtime-playground/src/runtime-diagnostics.ts index 49c44d8f2..73e320401 100644 --- a/packages/runtime-playground/src/runtime-diagnostics.ts +++ b/packages/runtime-playground/src/runtime-diagnostics.ts @@ -4,6 +4,15 @@ import type { PlaygroundCliServer } from "./preview-server.js" import { extractPhpunitFailureMessage } from "./playground-command-errors.js" import { PHPUNIT_COMPLETED_RESULT_PREFIX, parsePhpunitCompletedResult, type PhpunitCompletedResult } from "./phpunit-test-results.js" +export interface PhpunitDiscoveryResult { + schema: "wp-codebox/phpunit-discovery/v1" + plugin_slug: string + phpunit_xml: string + test_root: string + selected_testsuites: string[] + files: string[] +} + export async function persistPluginPhpunitResult(server: PlaygroundCliServer, vfsPath: string, artifactRoot: string, namespace?: string): Promise { await persistPhpunitResult(server, vfsPath, join(artifactRoot, "files", "phpunit", ...(namespace ? [namespace] : []), ".pg-test-result.txt")) } @@ -57,6 +66,26 @@ export async function readPluginPhpunitCompletedResult(server: PlaygroundCliServ return contents ? parsePhpunitCompletedResult(contents) : undefined } +export async function readPluginPhpunitDiscoveryResult(server: PlaygroundCliServer, vfsPath: string): Promise { + const contents = await readPhpunitResult(server, vfsPath) + const line = contents?.split("\n").find((entry) => entry.startsWith("DISCOVERY_RESULT_JSON:")) + if (!line) return undefined + try { + const value = JSON.parse(line.slice("DISCOVERY_RESULT_JSON:".length)) as Partial + if (value.schema !== "wp-codebox/phpunit-discovery/v1" + || typeof value.plugin_slug !== "string" + || 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 === "")) { + return undefined + } + return value as PhpunitDiscoveryResult + } catch { + return undefined + } +} + export async function readCorePhpunitDiagnostic(server: PlaygroundCliServer, vfsPath: string): Promise { return readPhpunitDiagnostic(server, vfsPath) } diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index c03a7401b..b5e7f735c 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -53,7 +53,7 @@ import { import { bootstrapAbilityPhpCode, bootstrapPhpCode, phpCodeFromArgs, splitLeadingStrictTypesDeclare } from "./php-bootstrap.js" import { assertPlaygroundResponseOk, attachPlaygroundDiagnostics, completedPlaygroundCommandError, playgroundCommandDiagnosticText, type PlaygroundRunResponse } from "./playground-command-errors.js" import type { PlaygroundCliServer } from "./preview-server.js" -import { persistCorePhpunitResult, persistPluginPhpunitCompletedResult, persistPluginPhpunitResult, persistVfsDiagnosticFileToHost, readCorePhpunitDiagnostic, readPluginPhpunitCompletedResult, readPluginPhpunitDiagnostic } from "./runtime-diagnostics.js" +import { persistCorePhpunitResult, persistPluginPhpunitCompletedResult, persistPluginPhpunitResult, persistVfsDiagnosticFileToHost, readCorePhpunitDiagnostic, readPluginPhpunitCompletedResult, readPluginPhpunitDiagnostic, readPluginPhpunitDiscoveryResult } from "./runtime-diagnostics.js" import { phpunitExecutionSemantics, requiresManagedMysqlMultisitePreinstall } from "./phpunit-command-semantics.js" import { parsePhpunitOutput } from "./phpunit-test-results.js" import type { RuntimeWpCliBridge } from "./runtime-wp-cli-bridge.js" @@ -932,6 +932,13 @@ export async function runPhpunitCommand({ const phpunitXmlArg = argValue(args, "phpunit-xml") const explicitCode = argValue(args, "code") || argValue(args, "code-file") const pluginSlug = argValue(args, "plugin-slug")?.trim() || "" + const discoveryOnly = booleanArg(args, "discovery-only") + const changedTestFiles = changedTestFilesArg(args) + const selectedTestFile = argValue(args, "test-file")?.trim() || "" + const phpunitArgs = jsonArrayArg(args, "phpunit-args-json").filter((value): value is string => typeof value === "string") + if (discoveryOnly && (explicitCode || selectedTestFile || changedTestFiles.length > 0 || phpunitArgs.length > 0)) { + throw new Error("wordpress.phpunit discovery-only cannot be combined with code overrides, test selectors, or PHPUnit arguments") + } const { bootstrapMode, databaseType, externalDatabase, multisite } = phpunitExecutionSemantics(args, runtimeSpec) const declaredDatabaseType = argValue(args, "database-type")?.trim() if (databaseType === "mysql" && !externalDatabase) { @@ -943,7 +950,7 @@ export async function runPhpunitCommand({ 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) - const managedMultisitePreinstalled = !explicitCode && requiresManagedMysqlMultisitePreinstall(args, runtimeSpec) + const managedMultisitePreinstalled = !explicitCode && !discoveryOnly && requiresManagedMysqlMultisitePreinstall(args, runtimeSpec) const resultFile = processIdentity ? `/tmp/wp-codebox-phpunit-result-${processIdentity}.txt` : PLUGIN_PHPUNIT_RESULT_FILE const diagnosticHostFile = `/wordpress/wp-content/plugins/${pluginSlug}/.pg-test-result${processIdentity ? `-${processIdentity}` : ""}.txt` const code = explicitCode ? await phpCodeFromArgs(args, "wordpress.phpunit", false) : phpunitRunCode({ @@ -956,9 +963,10 @@ export async function runPhpunitCommand({ testRoot: argValue(args, "test-root")?.trim() || `/wordpress/wp-content/plugins/${pluginSlug}/tests`, 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: changedTestFilesArg(args), - phpunitArgs: jsonArrayArg(args, "phpunit-args-json").filter((value): value is string => typeof value === "string"), + selectedTestFile, + changedTestFiles, + discoveryOnly, + phpunitArgs, env: jsonObjectArg(args, "env-json"), wpConfigDefines: jsonObjectArg(args, "wp-config-defines-json"), dependencyMounts: commaListArg(args, "dependency-mounts"), @@ -1029,6 +1037,12 @@ export async function runPhpunitCommand({ throw error } + if (discoveryOnly) { + const discovery = await readPluginPhpunitDiscoveryResult(server, resultFile) + if (!discovery) throw new Error("wordpress.phpunit discovery-only completed without a valid discovery result") + return `${JSON.stringify(discovery)}\n` + } + return response.text } diff --git a/tests/phpunit-discovery-only.test.ts b/tests/phpunit-discovery-only.test.ts new file mode 100644 index 000000000..d394c4a78 --- /dev/null +++ b/tests/phpunit-discovery-only.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe-builders.js" +import { phpunitRunCode } from "../packages/runtime-playground/src/phpunit-command-handlers.js" +import { runPhpunitCommand } from "../packages/runtime-playground/src/wordpress-command-runners.js" +import { recipeHasPhpunitDiscoveryOnly } from "../packages/cli/src/commands/recipe-runtime-setup.js" + +test("wordpress.phpunit discovery-only returns canonical files before execution", () => { + const recipe = buildWordPressPhpunitRecipe({ + pluginSlug: "fixture", + pluginSource: "/workspace/fixture", + discoveryOnly: true, + }) + assert.ok(recipe.workflow.steps[0].args.includes("discovery-only=1")) + + 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: false, + selectedTestFile: "", + changedTestFiles: [], + discoveryOnly: true, + phpunitArgs: [], + env: {}, + wpConfigDefines: {}, + dependencyMounts: [], + bootstrapFiles: [], + bootstrapMode: "managed", + projectBootstrap: "", + multisite: false, + databaseType: "sqlite", + }) + + const discover = code.indexOf("$test_files = wp_codebox_phpunit_discover(") + const output = code.indexOf("DISCOVERY_RESULT_JSON:", discover) + const boot = code.indexOf("$config_path = pg_run_boot_stage", output) + assert.ok(discover >= 0 && output > discover) + assert.ok(boot > output, "discovery output must terminate before WordPress, component, or test bootstrap") + assert.match(code, /sort\(\$test_files, SORT_STRING\)/) + assert.match(code, /'schema' => 'wp-codebox\/phpunit-discovery\/v1'/) + assert.match(code, /DISCOVERY_RESULT_JSON:/) + assert.match(code, /\$discovery_only = true;/) +}) + +test("discovery-only rejects selectors before starting a runtime", async () => { + let invoked = false + await assert.rejects(runPhpunitCommand({ + artifactRoot: "/tmp/artifacts", + mounts: [], + runPlaygroundCommand: async () => { + invoked = true + return { exitCode: 0, errors: "", text: "" } + }, + runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, policy: { commands: ["wordpress.phpunit"] } } as never, + server: {} as never, + spec: { command: "wordpress.phpunit", args: ["plugin-slug=fixture", "discovery-only=1", "test-file=tests/FixtureTest.php"] }, + }), /discovery-only cannot be combined/) + assert.equal(invoked, false) +}) + +test("discovery-only returns its schema-bound result directly", async () => { + const payload = { + schema: "wp-codebox/phpunit-discovery/v1", + plugin_slug: "fixture", + phpunit_xml: "/wordpress/wp-content/plugins/fixture/phpunit.xml.dist", + test_root: "/wordpress/wp-content/plugins/fixture/tests", + selected_testsuites: [], + files: ["/wordpress/wp-content/plugins/fixture/tests/FixtureTest.php"], + } + const output = await runPhpunitCommand({ + artifactRoot: "/tmp/artifacts", + mounts: [], + runPlaygroundCommand: async () => ({ exitCode: 0, errors: "", text: "private runtime output" }), + runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, policy: { commands: ["wordpress.phpunit"] } } as never, + server: { playground: { readFileAsText: async () => `DISCOVERY_RESULT_JSON:${JSON.stringify(payload)}\n` } } as never, + spec: { command: "wordpress.phpunit", args: ["plugin-slug=fixture", "discovery-only=1"] }, + }) + assert.deepEqual(JSON.parse(output), payload) +}) + +test("recipe setup uses canonical boolean parsing and rejects mixed workflows", () => { + const recipe = buildWordPressPhpunitRecipe({ pluginSlug: "fixture", discoveryOnly: true }) + const args = recipe.workflow.steps[0].args + const index = args.indexOf("discovery-only=1") + args[index] = "discovery-only= true " + assert.equal(recipeHasPhpunitDiscoveryOnly(recipe), true) + + recipe.workflow.after = [{ command: "wordpress.wp-cli", args: ["command=plugin list"] }] + assert.throws(() => recipeHasPhpunitDiscoveryOnly(recipe), /must be the recipe's sole workflow step/) +}) diff --git a/tests/playground-phpunit-readonly-cache.integration.test.ts b/tests/playground-phpunit-readonly-cache.integration.test.ts index c70fb78a7..bb461d7f8 100644 --- a/tests/playground-phpunit-readonly-cache.integration.test.ts +++ b/tests/playground-phpunit-readonly-cache.integration.test.ts @@ -14,7 +14,9 @@ const plugin = join(root, "plugin") const dependency = join(root, "dependency") const harness = join(root, "harness") const recipePath = join(root, "recipe.json") +const discoveryRecipePath = join(root, "discovery-recipe.json") const artifactsPath = join(root, "artifacts") +const discoveryArtifactsPath = join(root, "discovery-artifacts") const failingArtifactsPath = join(root, "failing-artifacts") const sentinel = Buffer.from([0, 255, 1, 2, 3, 127, 128]) @@ -47,6 +49,45 @@ try { }) await writeFile(recipePath, `${JSON.stringify(recipe)}\n`) + const discoveryRecipe = buildWordPressPhpunitRecipe({ + pluginSlug: "readonly-phpunit-fixture", + multisite: true, + discoveryOnly: true, + phpunitXml: "/wordpress/wp-content/plugins/readonly-phpunit-fixture/phpunit.discovery.xml", + extra_plugins: [{ + source: plugin, + slug: "readonly-phpunit-fixture", + activate: false, + }, { + source: dependency, + slug: "activation-dependency", + activate: true, + }], + dependencyMounts: ["/wordpress/wp-content/plugins/readonly-phpunit-fixture", "/wordpress/wp-content/plugins/activation-dependency"], + mounts: [ + { source: join(harness, "vendor"), target: "/wp-codebox-vendor", mode: "readonly" }, + ], + }) + await writeFile(discoveryRecipePath, `${JSON.stringify(discoveryRecipe)}\n`) + const discoveryResult = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", discoveryRecipePath, "--artifacts", discoveryArtifactsPath, "--json"], { + cwd: process.cwd(), + timeout: 300_000, + maxBuffer: 2 * 1024 * 1024, + }) + assert.equal((JSON.parse(discoveryResult.stdout) as { success?: boolean }).success, true, discoveryResult.stdout) + assert.doesNotMatch(discoveryResult.stdout, /extra-plugin\.(?:activate|install)|install-composer-autoloaders/, "discovery recipe setup must remain mount-only") + const discoveryRuntime = JSON.parse(await readFile(join(discoveryArtifactsPath, "latest-runtime.json"), "utf8")) as { paths?: { runtimeDirectory?: string } } + const discoveryDiagnostic = await readFile(join(discoveryArtifactsPath, discoveryRuntime.paths?.runtimeDirectory ?? "", "files/phpunit/.pg-test-result.txt"), "utf8") + const discoveryLine = discoveryDiagnostic.split("\n").find((line) => line.startsWith("DISCOVERY_RESULT_JSON:")) + assert.ok(discoveryLine) + const discovery = JSON.parse(discoveryLine.slice("DISCOVERY_RESULT_JSON:".length)) as { schema?: string, files?: string[] } + assert.equal(discovery.schema, "wp-codebox/phpunit-discovery/v1") + assert.deepEqual(discovery.files, [ + "/wordpress/wp-content/plugins/readonly-phpunit-fixture/specs/ConfiguredSpec.php", + "/wordpress/wp-content/plugins/readonly-phpunit-fixture/tests/ExplicitCase.php", + ], "discovery-only must honor custom suffixes, explicit files, and exclusions") + assert.doesNotMatch(discoveryDiagnostic, /^STAGE_BEGIN:boot|^STAGE_BEGIN:project_bootstrap|^STAGE_BEGIN:load_component|^STAGE_BEGIN:load_tests|^STAGE_BEGIN:run_tests/m, "discovery-only mode must not bootstrap WordPress, the component, or test files") + const result = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], { cwd: process.cwd(), timeout: 300_000, @@ -99,16 +140,21 @@ async function readTestResults(artifactPath: string, runtimeDirectory?: string): async function writeFixture(): Promise { await mkdir(join(plugin, "tests"), { recursive: true }) + await mkdir(join(plugin, "specs"), { recursive: true }) await mkdir(dependency, { recursive: true }) await writeFile(join(plugin, "readonly-phpunit-fixture.php"), "\ntests\n") + await writeFile(join(plugin, "phpunit.discovery.xml"), "\nspecstests/ExplicitCase.phpspecs/ExcludedSpec.php\n") await writeFile(join(plugin, "source-sentinel.bin"), sentinel) await writeFile(join(plugin, "tests", "ReadonlyCacheTest.php"), "assertTrue(is_multisite()); } public function test_nested_init_callbacks_run_in_priority_order(): void { $this->assertSame(1, (int) get_option(\'wp_codebox_parent_init_ran\')); $this->assertSame(1, (int) get_option(\'wp_codebox_nested_init_ran\')); } 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(plugin, "tests", "ExplicitCase.php"), " 1)))); });\n") } async function digestTree(directory: string): Promise { - const files = ["readonly-phpunit-fixture.php", "phpunit.xml.dist", "source-sentinel.bin", "tests/ReadonlyCacheTest.php"] + const files = ["readonly-phpunit-fixture.php", "phpunit.xml.dist", "phpunit.discovery.xml", "source-sentinel.bin", "tests/ReadonlyCacheTest.php", "tests/ExplicitCase.php", "specs/ConfiguredSpec.php", "specs/ExcludedSpec.php"] const hash = createHash("sha256") for (const file of files) { hash.update(file) From 4d5c305cd3ced3c6bfdb8fc74e2abf7d2c8660c9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 16 Aug 2026 23:43:42 +0000 Subject: [PATCH 2/2] test: stabilize runtime rejection timing --- tests/phpunit-runtime-rejection.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/phpunit-runtime-rejection.test.ts b/tests/phpunit-runtime-rejection.test.ts index 8c35e9e53..7baee6c50 100644 --- a/tests/phpunit-runtime-rejection.test.ts +++ b/tests/phpunit-runtime-rejection.test.ts @@ -62,14 +62,13 @@ try { () => assert.fail("PHPUnit step unexpectedly completed"), (reason: unknown) => reason, ), - new Promise((_resolve, reject) => setTimeout(() => reject(new Error("PHPUnit runtime rejection did not terminalize within 500ms")), 500)), + new Promise((_resolve, reject) => setTimeout(() => reject(new Error("PHPUnit runtime rejection did not terminalize within 5s")), 5_000)), ]) const failure = recipeStepFailure(workflowStep, error, startedAt) const serialized = JSON.stringify(failure) assert.equal(failure.schema, "wp-codebox/recipe-step-failure/v1") assert.equal(failure.classification, "error") - assert.ok(failure.durationMs < 500, `expected immediate terminal failure, received ${failure.durationMs}ms`) assert.match(failure.error.message, /Recipe workflow steps\[0\] failed/) assert.match(serialized, /wp-codebox-php-wasm-runtime-rejection/) assert.match(serialized, /infrastructure-failure/)