Skip to content

Commit 3fad338

Browse files
committed
Require real scoped PHPUnit execution
1 parent 9025ac7 commit 3fad338

9 files changed

Lines changed: 104 additions & 19 deletions

packages/cli/src/recipe-validation.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -743,11 +743,13 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code:
743743
if (ids.has(service.id)) addIssue("duplicate-runtime-service-id", `${path}.id`, `Runtime service ids must be unique: ${service.id}`)
744744
ids.add(service.id)
745745
if (service.kind !== "mysql") addIssue("unsupported-runtime-service-kind", `${path}.kind`, `Unsupported managed runtime service kind: ${service.kind}`)
746-
for (const [output, name] of Object.entries(service.outputs)) {
746+
for (const [output, names] of Object.entries(service.outputs)) {
747747
if (!/^(host|port|username|password|database)$/.test(output)) addIssue("unknown-runtime-service-output", `${path}.outputs.${output}`, `Unsupported ${service.kind} service output: ${output}`)
748-
if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) addIssue("invalid-runtime-service-env", `${path}.outputs.${output}`, "Runtime service environment variable names must match /^[A-Z_][A-Z0-9_]*$/.")
749-
if (environment.has(name)) addIssue("duplicate-runtime-service-env", `${path}.outputs.${output}`, `Runtime service output environment variable is already declared: ${name}`)
750-
environment.add(name)
748+
for (const name of Array.isArray(names) ? names : [names]) {
749+
if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) addIssue("invalid-runtime-service-env", `${path}.outputs.${output}`, "Runtime service environment variable names must match /^[A-Z_][A-Z0-9_]*$/.")
750+
if (environment.has(name)) addIssue("duplicate-runtime-service-env", `${path}.outputs.${output}`, `Runtime service output environment variable is already declared: ${name}`)
751+
environment.add(name)
752+
}
751753
}
752754
}
753755
}

packages/cli/src/runtime-services.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ const defaultDependencies: RuntimeServiceDependencies = {
6161
randomBytes,
6262
}
6363

64-
export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback"; port: "ephemeral"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record<string, string> }> {
64+
export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback"; port: "ephemeral"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record<string, string | string[]> }> {
6565
return services.map((service) => {
6666
const provider = runtimeServiceProvider(service.kind)
6767
return { id: service.id, kind: service.kind, provider: provider.name, version: provider.version(service), bind: "loopback", port: "ephemeral", persistentVolume: false, ...(service.configuration ? { configuration: service.configuration } : {}), outputs: service.outputs }
@@ -177,7 +177,7 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic
177177
evidence.readiness = "ready"
178178
evidence.lifecycle = "provisioned"
179179
const values: Record<string, string> = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" }
180-
return { env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), evidence, async release() { await releaseService(container, evidence, dependencies) } }
180+
return { env: Object.fromEntries(Object.entries(service.outputs).flatMap(([output, names]) => (Array.isArray(names) ? names : [names]).map((name) => [name, values[output] ?? ""]))), evidence, async release() { await releaseService(container, evidence, dependencies) } }
181181
} catch (error) {
182182
evidence.readiness = "failed"
183183
evidence.lifecycle = "failed"

packages/runtime-core/src/recipe-schema.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -930,7 +930,12 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche
930930
outputs: {
931931
type: "object",
932932
minProperties: 1,
933-
additionalProperties: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" },
933+
additionalProperties: {
934+
anyOf: [
935+
{ type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" },
936+
{ type: "array", minItems: 1, uniqueItems: true, items: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" } },
937+
],
938+
},
934939
},
935940
},
936941
},

packages/runtime-core/src/runtime-contracts.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,8 +142,8 @@ export interface WorkspaceRecipeRuntimeService {
142142
rootAuthentication?: "generated-password" | "empty-password"
143143
foreignKeyTargetPolicy?: "unique-only" | "indexed"
144144
}
145-
/** Explicit map from a provider output (for example `port`) to a runtime env name. */
146-
outputs: Record<string, string>
145+
/** Explicit map from a provider output (for example `port`) to one or more runtime env names. */
146+
outputs: Record<string, string | string[]>
147147
}
148148

149149
export interface WorkspaceRecipeRuntimeStack {

packages/runtime-playground/src/phpunit-command-handlers.ts

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s
9797
const suffixAssignment = options.replaceDefaultMatchers ? "$suffixes = $config_suffixes;" : "$suffixes = array_merge($suffixes, $config_suffixes);"
9898
const prefixAssignment = options.replaceDefaultMatchers ? "$prefixes = $config_prefixes;" : "$prefixes = array_merge($prefixes, $config_prefixes);"
9999

100-
return `function ${options.functionName}($xml_path, $test_dir_default) {
100+
return `function ${options.functionName}($xml_path, $test_dir_default, $requested_suites = array()) {
101101
$directories = array($test_dir_default);
102102
$suffixes = array('Test.php');
103103
$prefixes = array('test-');
@@ -118,11 +118,23 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s
118118
$first = $errors ? trim($errors[0]->message) : 'unknown';
119119
throw new RuntimeException('PHPUnit config could not be parsed at ' . $xml_path . ': ' . $first);
120120
}
121+
$suite_nodes = $xml->xpath('//testsuite') ?: array();
122+
if (!empty($requested_suites)) {
123+
$requested_lookup = array_fill_keys(array_map('strval', $requested_suites), true);
124+
$suite_nodes = array_values(array_filter($suite_nodes, static function($suite) use ($requested_lookup) {
125+
return isset($requested_lookup[(string) ($suite['name'] ?? '')]);
126+
}));
127+
if (empty($suite_nodes)) {
128+
throw new RuntimeException('Requested PHPUnit testsuite was not found in config: ' . implode(',', array_keys($requested_lookup)));
129+
}
130+
}
131+
$directories = array();
121132
$base = ${options.basePathExpression};
122133
$config_dirs = array();
123134
$config_suffixes = array();
124135
$config_prefixes = array();
125-
foreach ($xml->xpath('//testsuite/directory') ?: array() as $dir) {
136+
foreach ($suite_nodes as $suite) {
137+
foreach ($suite->xpath('./directory') ?: array() as $dir) {
126138
$raw = trim((string) $dir);${directoryRestriction}
127139
$config_dirs[] = $raw[0] === '/' ? rtrim($raw, '/') : rtrim($base . '/' . $raw, '/');
128140
foreach (explode(',', (string) ($dir['suffix'] ?? '')) as $suffix) {
@@ -137,18 +149,23 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s
137149
$config_prefixes[] = $prefix;
138150
}
139151
}
152+
}
140153
}
141-
foreach ($xml->xpath('//testsuite/exclude') ?: array() as $exclude) {
154+
foreach ($suite_nodes as $suite) {
155+
foreach ($suite->xpath('./exclude') ?: array() as $exclude) {
142156
$raw = trim((string) $exclude);
143157
if ($raw !== '') {
144158
$excludes[] = $raw[0] === '/' ? rtrim($raw, '/') : rtrim($base . '/' . $raw, '/');
145159
}
160+
}
146161
}
147-
foreach ($xml->xpath('//testsuite/file') ?: array() as $file) {
162+
foreach ($suite_nodes as $suite) {
163+
foreach ($suite->xpath('./file') ?: array() as $file) {
148164
$raw = trim((string) $file);
149165
if ($raw !== '') {
150166
$files[] = $raw[0] === '/' ? $raw : $base . '/' . $raw;
151167
}
168+
}
152169
}
153170
if (!empty($config_dirs)) {
154171
$directories = $config_dirs;
@@ -1227,13 +1244,28 @@ ${phpunitConfigDiscoveryPhp({
12271244
12281245
${phpunitDiscoveryPhp("wp_codebox_phpunit_discover", "pg_log")}
12291246
1247+
function wp_codebox_requested_phpunit_suites($args): array {
1248+
$suites = array();
1249+
if (!is_array($args)) {
1250+
return $suites;
1251+
}
1252+
foreach ($args as $index => $arg) {
1253+
if ($arg === '--testsuite' && isset($args[$index + 1])) {
1254+
$suites = array_merge($suites, explode(',', (string) $args[$index + 1]));
1255+
} elseif (is_string($arg) && strpos($arg, '--testsuite=') === 0) {
1256+
$suites = array_merge($suites, explode(',', substr($arg, strlen('--testsuite='))));
1257+
}
1258+
}
1259+
return array_values(array_filter(array_map('trim', $suites), static function($suite) { return $suite !== ''; }));
1260+
}
1261+
12301262
pg_stage_begin('discover_tests');
12311263
try {
12321264
$test_dir = $test_root;
12331265
if (!is_dir($test_dir)) {
12341266
throw new RuntimeException('configured PHPUnit test root is not a readable directory: ' . $test_dir);
12351267
}
1236-
list($directories, $suffixes, $prefixes, $excludes, $configured_files) = wp_codebox_phpunit_parse_config(${JSON.stringify(options.phpunitXml)}, $test_dir);
1268+
list($directories, $suffixes, $prefixes, $excludes, $configured_files) = wp_codebox_phpunit_parse_config(${JSON.stringify(options.phpunitXml)}, $test_dir, wp_codebox_requested_phpunit_suites($phpunit_args_raw));
12371269
$test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files);
12381270
$test_files = pg_filter_changed_test_files($test_files, $changed_test_files_raw, $test_dir);
12391271
if ($selected_test_file !== '') {

packages/runtime-playground/src/wordpress-command-runners.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -913,6 +913,7 @@ export async function runPhpunitCommand({
913913
const bootstrapMode = argValue(args, "bootstrap-mode")?.trim() || "managed"
914914
const autoloadFile = argValue(args, "autoload-file")?.trim() || (bootstrapMode === "project" ? "" : "/wp-codebox-vendor/autoload.php")
915915
const autoloadFileRole = argValue(args, "autoload-file-role")?.trim() === "harness" ? "harness" : undefined
916+
const phpunitArgs = jsonArrayArg(args, "phpunit-args-json").filter((value): value is string => typeof value === "string")
916917
const processIdentity = boundedProcessIdentity(spec.processIdentity)
917918
const resultFile = processIdentity ? `/tmp/wp-codebox-phpunit-result-${processIdentity}.txt` : PLUGIN_PHPUNIT_RESULT_FILE
918919
const diagnosticHostFile = `/wordpress/wp-content/plugins/${pluginSlug}/.pg-test-result${processIdentity ? `-${processIdentity}` : ""}.txt`
@@ -928,7 +929,7 @@ export async function runPhpunitCommand({
928929
phpunitXmlIsDefault: phpunitXmlArg === undefined || booleanArg(args, "phpunit-xml-default"),
929930
selectedTestFile: argValue(args, "test-file")?.trim() || "",
930931
changedTestFiles: changedTestFilesArg(args),
931-
phpunitArgs: jsonArrayArg(args, "phpunit-args-json").filter((value): value is string => typeof value === "string"),
932+
phpunitArgs,
932933
env: jsonObjectArg(args, "env-json"),
933934
wpConfigDefines: jsonObjectArg(args, "wp-config-defines-json"),
934935
dependencyMounts: commaListArg(args, "dependency-mounts"),
@@ -969,6 +970,9 @@ export async function runPhpunitCommand({
969970
if (structured) {
970971
throw attachPlaygroundDiagnostics(new Error("wordpress.phpunit terminated before completing bootstrap"), "wordpress.phpunit structured diagnostics", structured)
971972
}
973+
if (!phpunitArgs.includes("--list-tests") && !/\bOK(?:, but there were issues!)? \([1-9]\d* tests?,/m.test(response.text)) {
974+
throw new Error("wordpress.phpunit exited successfully without a non-zero PHPUnit test summary")
975+
}
972976

973977
return response.text
974978
}

tests/phpunit-project-autoload.test.ts

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,27 @@ echo "ok\n";
136136
assert.equal(execFileSync("php", [scriptPath], { encoding: "utf8" }), "ok\n")
137137
}
138138

139+
function assertNamedTestsuiteScopesDiscovery(source: string): void {
140+
const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-named-testsuite-"))
141+
const selected = join(tempDir, "SelectedTest.php")
142+
const unrelated = join(tempDir, "UnrelatedTest.php")
143+
const config = join(tempDir, "phpunit.xml")
144+
const scriptPath = join(tempDir, "assert-named-testsuite.php")
145+
writeFileSync(selected, "<?php // selected\n")
146+
writeFileSync(unrelated, "<?php // unrelated\n")
147+
writeFileSync(config, `<phpunit><testsuites><testsuite name="selected"><file>SelectedTest.php</file></testsuite><testsuite name="unrelated"><file>UnrelatedTest.php</file></testsuite></testsuites></phpunit>`)
148+
writeFileSync(scriptPath, `<?php
149+
function pg_log($message) {}
150+
${extractPhpFunction(source, "wp_codebox_phpunit_parse_config")}
151+
list($directories, $suffixes, $prefixes, $excludes, $files) = wp_codebox_phpunit_parse_config(${phpString(config)}, ${phpString(tempDir)}, array('selected'));
152+
if ($directories !== array() || $files !== array(${phpString(selected)})) {
153+
throw new RuntimeException('named testsuite discovery escaped its configured scope: ' . json_encode(array($directories, $files)));
154+
}
155+
echo "ok\n";
156+
`)
157+
assert.equal(execFileSync("php", [scriptPath], { encoding: "utf8" }), "ok\n")
158+
}
159+
139160
function assertChangedScopeNoOp(source: string, filterFunctionName: string, relativeFunctionName: string): void {
140161
const tempDir = mkdtempSync(join(tmpdir(), "wp-codebox-changed-scope-"))
141162
const scriptPath = join(tempDir, "assert-changed-scope.php")
@@ -484,14 +505,15 @@ assert.ok(projectModeCode.includes("$bootstrap_real = pg_project_bootstrap_real_
484505
assert.ok(projectModeCode.includes("function pg_project_bootstrap_from_config(string &$xml_path, bool $xml_is_default): string"))
485506
assertProjectBootstrapConfigResolution(projectModeCode, implicitProjectConfigCode)
486507
assert.ok(projectModeCode.includes("NOTICE:project bootstrap not declared; continuing without one"), "project mode permits PHPUnit configurations that do not declare a bootstrap")
487-
assert.ok(projectModeCode.includes("foreach ($xml->xpath('//testsuite/file') ?: array() as $file)"))
508+
assert.ok(projectModeCode.includes("foreach ($suite->xpath('./file') ?: array() as $file)"))
488509
assert.ok(projectModeCode.includes("list($directories, $suffixes, $prefixes, $excludes, $configured_files) = wp_codebox_phpunit_parse_config"))
489510
assert.ok(projectModeCode.includes("$test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files);"))
490511
assert.ok(projectModeCode.includes("' files=' . count($configured_files)"))
491512
assert.equal(projectModeCode.match(/return array\(\$directories, \$suffixes, \$prefixes, \$excludes\);/g)?.length ?? 0, 0)
492513
assert.equal(projectModeCode.match(/return \$return_values\(\);/g)?.length, 1)
493514
assert.match(projectModeCode, /configured PHPUnit test root is not a readable directory/)
494515
assertPhpunitConfigurationAndDiscoveryFailures(projectModeCode, "wp_codebox_phpunit_parse_config", "wp_codebox_phpunit_discover", "pg_log", false)
516+
assertNamedTestsuiteScopesDiscovery(projectModeCode)
495517
assertChangedScopeNoOp(projectModeCode, "pg_filter_changed_test_files", "pg_component_relative_path")
496518
assertSelectedTestFileResolution(projectModeCode)
497519

@@ -546,7 +568,7 @@ await runPhpunitCommand({
546568
mounts: [],
547569
runPlaygroundCommand: async (_command, _server, input) => {
548570
capturedCanonicalHarnessCode = input.code
549-
return { text: "ok", exitCode: 0 }
571+
return { text: "OK (1 test, 1 assertion)", exitCode: 0 }
550572
},
551573
runtimeSpec: phpunitRuntimeSpec,
552574
server: { playground: {} } as never,
@@ -574,7 +596,7 @@ await runPhpunitCommand({
574596
mounts: [],
575597
runPlaygroundCommand: async (_command, _server, input) => {
576598
capturedExplicitCode = input.code
577-
return { text: "ok", exitCode: 0 }
599+
return { text: "OK (1 test, 1 assertion)", exitCode: 0 }
578600
},
579601
runtimeSpec: phpunitRuntimeSpec,
580602
server: { playground: {} } as never,

tests/phpunit-runtime-failure-diagnostics.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,4 +49,17 @@ const preBootstrapRecorder = decodedBootstrap.indexOf("STAGE_FATAL:bootstrap:")
4949
const wordpressBootstrap = decodedBootstrap.indexOf("require_once '/wordpress/wp-load.php';")
5050
assert.ok(preBootstrapRecorder >= 0 && preBootstrapRecorder < wordpressBootstrap, "fatal diagnostics must be recorded before the WordPress bootstrap boundary")
5151

52+
const emptySuccessArtifactRoot = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-empty-success-"))
53+
await assert.rejects(
54+
() => runPhpunitCommand({
55+
artifactRoot: emptySuccessArtifactRoot,
56+
mounts: [],
57+
runPlaygroundCommand: async () => ({ exitCode: 0, errors: "", text: "WPCOM Codebox PHPUnit shutdown: mysql_port=unset" }),
58+
runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, policy: { commands: ["wordpress.phpunit"] } } as never,
59+
server: { playground: { readFileAsText: async () => { throw new Error("missing") } } } as never,
60+
spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin"] },
61+
}),
62+
/exited successfully without a non-zero PHPUnit test summary/,
63+
)
64+
5265
console.log("phpunit runtime failure diagnostics ok")

tests/runtime-services.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ assert.throws(() => parseLoopbackPort("0.0.0.0:3306"), /loopback/)
1818

1919
const valid = validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [service] }, workflow: { steps: [{ command: "wordpress.run-php" }] } })
2020
assert.equal(valid.valid, true)
21+
const aliasedPortService = { ...service, outputs: { ...service.outputs, port: ["DB_PORT", "TC_MYSQL_PORT"] } }
22+
assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [aliasedPortService] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }).valid, true)
23+
assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [{ ...service, outputs: { port: [] } }] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }).valid, false)
2124
const unsafe = validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [{ ...service, outputs: { port: "bad-name" } }] }, workflow: { steps: [{ command: "wordpress.run-php" }] } })
2225
assert.equal(unsafe.valid, false)
2326
const emptyRootService = { ...service, configuration: { rootAuthentication: "empty-password" as const } }
@@ -103,6 +106,10 @@ const dependencies: RuntimeServiceDependencies = {
103106
const provisioned = await provisionRuntimeServices([service], { dependencies })
104107
assert.equal(provisioned.env.DB_PORT, "41001")
105108
assert.equal(provisioned.env.DB_PASSWORD, Buffer.alloc(24, 7).toString("base64url"))
109+
const aliasedPort = await provisionRuntimeServices([aliasedPortService], { dependencies })
110+
assert.equal(aliasedPort.env.DB_PORT, "41001")
111+
assert.equal(aliasedPort.env.TC_MYSQL_PORT, "41001")
112+
await aliasedPort.release()
106113
const runCall = calls.find((call) => call.args[0] === "run")
107114
assert.ok(runCall?.args.includes("MYSQL_PASSWORD"))
108115
assert.ok(runCall?.args.includes("127.0.0.1::3306"), "Docker publishes MySQL on a loopback ephemeral port")
@@ -118,7 +125,7 @@ assert.equal(runCall?.env?.DOCKER_HOST, process.env.DOCKER_HOST, "Docker provide
118125
assert.equal(calls[0]?.args[0], "image", "the provider checks the image before starting the service")
119126
await provisioned.release()
120127
await provisioned.release()
121-
assert.equal(calls.filter((call) => call.args[0] === "rm").length, 1, "release is idempotent")
128+
assert.equal(calls.filter((call) => call.args[0] === "rm").length, 2, "each service is released exactly once")
122129

123130
const emptyRootCalls: Array<{ args: string[]; env?: NodeJS.ProcessEnv }> = []
124131
const emptyRootDependencies: RuntimeServiceDependencies = {

0 commit comments

Comments
 (0)