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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
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 @@ -24,6 +24,7 @@ interface WordPressPhpunitBuilderOptions {
cwd?: string
selectedTestFile?: string
changedTestFiles?: string[]
discoveryOnly?: boolean
env?: Record<string, unknown>
wpConfigDefines?: Record<string, unknown>
autoloadFile?: string
Expand Down Expand Up @@ -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),
Expand Down
19 changes: 18 additions & 1 deletion packages/cli/src/commands/recipe-runtime-setup.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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<void> {
await Promise.all(paths.map((path) => rm(path, { recursive: true, force: true })))
paths.length = 0
Expand Down
3 changes: 2 additions & 1 deletion packages/runtime-core/src/command-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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" },
Expand Down
2 changes: 2 additions & 0 deletions packages/runtime-core/src/recipe-builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export interface WordPressPhpunitRecipeOptions {
cwd?: string
selectedTestFile?: string
changedTestFiles?: string[]
discoveryOnly?: boolean
env?: JsonObject
wpConfigDefines?: JsonObject
autoloadFile?: string
Expand Down Expand Up @@ -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),
Expand Down
34 changes: 34 additions & 0 deletions packages/runtime-playground/src/phpunit-command-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface PhpunitRunCodeOptions {
phpunitXmlIsDefault: boolean
selectedTestFile: string
changedTestFiles: string[]
discoveryOnly?: boolean
phpunitArgs: string[]
env: Record<string, unknown>
wpConfigDefines: Record<string, unknown>
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
Expand Down
29 changes: 29 additions & 0 deletions packages/runtime-playground/src/runtime-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
await persistPhpunitResult(server, vfsPath, join(artifactRoot, "files", "phpunit", ...(namespace ? [namespace] : []), ".pg-test-result.txt"))
}
Expand Down Expand Up @@ -57,6 +66,26 @@ export async function readPluginPhpunitCompletedResult(server: PlaygroundCliServ
return contents ? parsePhpunitCompletedResult(contents) : undefined
}

export async function readPluginPhpunitDiscoveryResult(server: PlaygroundCliServer, vfsPath: string): Promise<PhpunitDiscoveryResult | undefined> {
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<PhpunitDiscoveryResult>
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<string | undefined> {
return readPhpunitDiagnostic(server, vfsPath)
}
Expand Down
24 changes: 19 additions & 5 deletions packages/runtime-playground/src/wordpress-command-runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand All @@ -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({
Expand All @@ -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"),
Expand Down Expand Up @@ -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
}

Expand Down
Loading
Loading