diff --git a/package-lock.json b/package-lock.json index 3fbfba00..f710463f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "wp-codebox-workspace", - "version": "0.14.0", + "version": "0.15.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wp-codebox-workspace", - "version": "0.14.0", + "version": "0.15.0", "hasInstallScript": true, "license": "ISC", "workspaces": [ @@ -5159,7 +5159,7 @@ }, "packages/cli": { "name": "@automattic/wp-codebox-cli", - "version": "0.14.0", + "version": "0.15.0", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", "@automattic/wp-codebox-playground": "file:../runtime-playground" @@ -5181,14 +5181,14 @@ }, "packages/runtime-core": { "name": "@automattic/wp-codebox-core", - "version": "0.14.0", + "version": "0.15.0", "dependencies": { "ajv": "^8.20.0" } }, "packages/runtime-playground": { "name": "@automattic/wp-codebox-playground", - "version": "0.14.0", + "version": "0.15.0", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", "@php-wasm/node": "3.1.46", diff --git a/packages/cli/src/bounded-recipe-plan.ts b/packages/cli/src/bounded-recipe-plan.ts index aa8f99ee..69e8621f 100644 --- a/packages/cli/src/bounded-recipe-plan.ts +++ b/packages/cli/src/bounded-recipe-plan.ts @@ -1,6 +1,6 @@ -import { mkdir, readFile, writeFile } from "node:fs/promises" +import { mkdir, readFile, rename, writeFile } from "node:fs/promises" import { join } from "node:path" -import { commandArgValue, executeBoundedRuntimePlan, parseCommandJsonObject, type BoundedRuntimePlan, type BoundedRuntimePlanResult, type ExecutionResult, type Runtime } from "@automattic/wp-codebox-core" +import { commandArgValue, executeBoundedRuntimePlan, parseCommandJsonObject, type BoundedRuntimePlan, type BoundedRuntimePlanEntryResult, type BoundedRuntimePlanResult, type ExecutionResult, type Runtime } from "@automattic/wp-codebox-core" import { recipeExecutionSpec } from "./agent-sandbox.js" export interface BoundedRecipePlanExecutionOptions { @@ -8,7 +8,14 @@ export interface BoundedRecipePlanExecutionOptions { recipeDirectory: string } +const BOUNDED_RUNTIME_PLAN_PROGRESS_SCHEMA = "wp-codebox/bounded-runtime-plan-progress/v1" + export async function executeBoundedRecipePlan(runtime: Runtime, plan: BoundedRuntimePlan, options: BoundedRecipePlanExecutionOptions): Promise { + const progressPath = join(options.artifactRoot, "bounded-plan", "progress.json") + const completed = new Map() + let progressWrite = Promise.resolve() + await mkdir(join(options.artifactRoot, "bounded-plan"), { recursive: true }) + await writeBoundedPlanProgress(progressPath, plan, completed, false) const aggregate = await executeBoundedRuntimePlan(plan, { async materialize() { return { workspace: options.recipeDirectory, runtime } }, async startServices() { return undefined }, @@ -50,10 +57,15 @@ export async function executeBoundedRecipePlan(runtime: Runtime, plan: BoundedRu }, null, 2)}\n`, "utf8") return { success: exitCode === 0, exitCode, message: stderr, stdoutRef, stderrRef, resultRef, artifactRefs } }, + async onEntryResult(result) { + completed.set(result.id, result) + progressWrite = progressWrite.then(async () => writeBoundedPlanProgress(progressPath, plan, completed, false)) + await progressWrite + }, async stopServices() {}, async dispose() {}, }) - await mkdir(join(options.artifactRoot, "bounded-plan"), { recursive: true }) + await writeBoundedPlanProgress(progressPath, plan, completed, true) await writeFile(join(options.artifactRoot, "bounded-plan/result.json"), `${JSON.stringify(aggregate, null, 2)}\n`, "utf8") return aggregate } @@ -76,3 +88,27 @@ function runtimeArtifactRefs(execution: ExecutionResult | undefined): string[] { return typeof value === "string" && value ? [value] : [] }) } + +async function writeBoundedPlanProgress(path: string, plan: BoundedRuntimePlan, completed: Map, complete: boolean): Promise { + const entries = plan.entries.flatMap((entry) => { + const result = completed.get(entry.id) + return result ? [result] : [] + }) + const progress = { + schema: BOUNDED_RUNTIME_PLAN_PROGRESS_SCHEMA, + complete, + concurrency: Math.min(plan.concurrency, plan.entries.length), + counts: { + total: plan.entries.length, + succeeded: entries.filter((entry) => entry.status === "succeeded").length, + failed: entries.filter((entry) => entry.status === "failed").length, + timedOut: entries.filter((entry) => entry.status === "timed_out").length, + cancelled: entries.filter((entry) => entry.status === "cancelled").length, + unfinished: plan.entries.length - entries.length, + }, + entries, + } + const temporaryPath = `${path}.tmp` + await writeFile(temporaryPath, `${JSON.stringify(progress, null, 2)}\n`, "utf8") + await rename(temporaryPath, path) +} diff --git a/packages/cli/src/commands/recipe-build.ts b/packages/cli/src/commands/recipe-build.ts index d23c2111..d3bc8f42 100644 --- a/packages/cli/src/commands/recipe-build.ts +++ b/packages/cli/src/commands/recipe-build.ts @@ -1,5 +1,5 @@ import { readFile, writeFile } from "node:fs/promises" -import { buildGenericAbilityRuntimeRunRecipe, buildRuntimePackageRunRecipe, buildWordPressBenchRecipe, buildWordPressPhpunitRecipe, compileRecipeTemplate, type GenericAbilityRuntimeRunOptions, type RecipeTemplateInput, type RuntimePackageRunRecipeOptions, type RuntimeWordPressInstallMode, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipePHPWasmExtensionManifest, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeService, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core" +import { buildGenericAbilityRuntimeRunRecipe, buildRuntimePackageRunRecipe, buildWordPressBenchRecipe, buildWordPressPhpunitRecipe, compileRecipeTemplate, type GenericAbilityRuntimeRunOptions, type RecipeTemplateInput, type RuntimePackageRunRecipeOptions, type RuntimeWordPressInstallMode, type WorkspaceRecipe, type WorkspaceRecipeExtraPlugin, type WorkspaceRecipeMount, type WorkspaceRecipePHPWasmExtensionManifest, type WorkspaceRecipePluginRuntime, type WorkspaceRecipeRuntimeBackendPackage, type WorkspaceRecipeRuntimeService, type WorkspaceRecipeStep } from "@automattic/wp-codebox-core" interface RecipeBuildOptions { recipeType: "phpunit" | "bench" | "template" | "generic-ability-runtime-run" | "runtime-package-run" @@ -17,6 +17,7 @@ interface WordPressPhpunitBuilderOptions { backendPackage?: WorkspaceRecipeRuntimeBackendPackage mounts?: WorkspaceRecipeMount[] services?: WorkspaceRecipeRuntimeService[] + pluginRuntime?: WorkspaceRecipePluginRuntime extra_plugins?: WorkspaceRecipeExtraPlugin[] pluginSource?: string pluginSlug: string @@ -88,6 +89,7 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word backendPackage: phpunitOptions.backendPackage, mounts: Array.isArray(phpunitOptions.mounts) ? phpunitOptions.mounts : [], services: Array.isArray(phpunitOptions.services) ? phpunitOptions.services : [], + pluginRuntime: phpunitOptions.pluginRuntime ? plainObject(phpunitOptions.pluginRuntime) as WorkspaceRecipePluginRuntime : undefined, extra_plugins: Array.isArray(phpunitOptions.extra_plugins) ? phpunitOptions.extra_plugins : [], pluginSource: stringOrUndefined(phpunitOptions.pluginSource), pluginSlug: requiredString(phpunitOptions.pluginSlug, "pluginSlug"), diff --git a/packages/cli/src/recipe-validation.ts b/packages/cli/src/recipe-validation.ts index 1e3cb44f..e5b242fc 100644 --- a/packages/cli/src/recipe-validation.ts +++ b/packages/cli/src/recipe-validation.ts @@ -769,10 +769,12 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: const environment = new Set(exposedEnvironment) const outputOwners = new Map>() for (const [serviceIndex, service] of services.entries()) { - for (const [output, name] of Object.entries(service.outputs)) { - const owners = outputOwners.get(name) ?? [] - owners.push({ serviceIndex, output }) - outputOwners.set(name, owners) + for (const [output, names] of Object.entries(service.outputs)) { + for (const name of runtimeServiceOutputNames(names)) { + const owners = outputOwners.get(name) ?? [] + owners.push({ serviceIndex, output }) + outputOwners.set(name, owners) + } } } const connectorTargets = new Set() @@ -782,10 +784,11 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: if (ids.has(service.id)) addIssue("duplicate-runtime-service-id", `${path}.id`, `Runtime service ids must be unique: ${service.id}`) ids.add(service.id) if (!["mysql", "redis", "smtp", "http"].includes(service.kind)) addIssue("unsupported-runtime-service-kind", `${path}.kind`, `Unsupported managed runtime service kind: ${service.kind}`) - if (service.kind === "mysql" && service.outputs.password) { + const passwordTargets = runtimeServiceOutputNames(service.outputs.password) + if (service.kind === "mysql" && passwordTargets.length > 0) { const target = "DB_PASSWORD" if (exposedEnvironment.has(target)) addIssue("runtime-service-secret-target-collision", `${path}.outputs.password`, `Managed connector secret target is already injected by recipe environment: ${target}`) - const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === index && owner.output === "password" && service.outputs.password === target)) + const conflictingOutput = (outputOwners.get(target) ?? []).some((owner) => !(owner.serviceIndex === index && owner.output === "password" && passwordTargets.includes(target))) if (conflictingOutput) addIssue("runtime-service-secret-target-collision", `${path}.outputs.password`, `Managed connector secret target collides with a managed output: ${target}`) if (connectorTargets.has(target)) addIssue("ambiguous-runtime-service-secret-target", `${path}.outputs.password`, `Multiple managed connectors target the same runtime environment name: ${target}`) connectorTargets.add(target) @@ -819,15 +822,21 @@ function validateRecipeRuntimeServices(recipe: WorkspaceRecipe, addIssue: (code: smtp: /^(host|port|httpPort|url)$/, http: /^(host|port|url)$/, } - for (const [output, name] of Object.entries(service.outputs)) { + for (const [output, names] of Object.entries(service.outputs)) { if (!(supportedOutputs[service.kind] ?? /^$/).test(output)) addIssue("unknown-runtime-service-output", `${path}.outputs.${output}`, `Unsupported ${service.kind} service output: ${output}`) - 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_]*$/.") - if (environment.has(name)) addIssue("duplicate-runtime-service-env", `${path}.outputs.${output}`, `Runtime service output environment variable is already declared: ${name}`) - environment.add(name) + for (const name of runtimeServiceOutputNames(names)) { + 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_]*$/.") + if (environment.has(name)) addIssue("duplicate-runtime-service-env", `${path}.outputs.${output}`, `Runtime service output environment variable is already declared: ${name}`) + environment.add(name) + } } } } +function runtimeServiceOutputNames(value: string | string[] | undefined): string[] { + return value === undefined ? [] : Array.isArray(value) ? value : [value] +} + function policyHostListIncludes(policyHosts: readonly string[], boundaryHost: string): boolean { const normalizedBoundary = boundaryHost.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "") const boundaryHasPort = /:\d+$/.test(normalizedBoundary) diff --git a/packages/cli/src/runtime-services.ts b/packages/cli/src/runtime-services.ts index b218c540..0bd8ed5c 100644 --- a/packages/cli/src/runtime-services.ts +++ b/packages/cli/src/runtime-services.ts @@ -105,7 +105,7 @@ const defaultDependencies: RuntimeServiceDependencies = { environment: process.env, } -export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback" | "configured"; port: "ephemeral" | "configured"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record }> { +export function runtimeServicePlan(services: WorkspaceRecipeRuntimeService[]): Array<{ id: string; kind: string; provider: string; version: string; bind: "loopback" | "configured"; port: "ephemeral" | "configured"; persistentVolume: false; configuration?: WorkspaceRecipeRuntimeService["configuration"]; outputs: Record }> { return services.map((service) => { const provider = runtimeServiceProvider(service) const external = provider.name === "external" @@ -232,7 +232,9 @@ function mysqlDockerImage(service: WorkspaceRecipeRuntimeService): string { } function mysqlRuntimeServiceSecretTargets(service: WorkspaceRecipeRuntimeService): Record { - return service.outputs.password ? { DB_PASSWORD: service.outputs.password } : {} + const names = runtimeServiceOutputNames(service.outputs.password) + if (names.length === 0) return {} + return { DB_PASSWORD: names.includes("DB_PASSWORD") ? "DB_PASSWORD" : names[0] as string } } function runtimeServiceProvider(service: WorkspaceRecipeRuntimeService): RuntimeServiceProvider { @@ -280,7 +282,7 @@ async function provisionMysqlDockerService(service: WorkspaceRecipeRuntimeServic const values: Record = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" } return { env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])), - secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {}, + secretEnv: runtimeServiceSecretOutputEnvironment(service, "password", password), secretEnvTargets: mysqlRuntimeServiceSecretTargets(service), evidence, async control(action, options) { return await controlDockerService(container, evidence, dependencies, action, options, async (customAction) => { @@ -483,7 +485,7 @@ async function provisionMysqlNativeService(service: WorkspaceRecipeRuntimeServic const values: Record = { host: "127.0.0.1", port: String(port), username: "runtime", password, database: "runtime" } return { env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])), - secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {}, + secretEnv: runtimeServiceSecretOutputEnvironment(service, "password", password), secretEnvTargets: mysqlRuntimeServiceSecretTargets(service), evidence, async control(action) { @@ -1142,7 +1144,7 @@ async function provisionMysqlExternalService(service: WorkspaceRecipeRuntimeServ const values: Record = { host: connection.host, port: String(connection.port), username, password, database } return { env: runtimeServiceOutputEnvironment(service, values, new Set(["password"])), - secretEnv: service.outputs.password ? { [service.outputs.password]: password } : {}, + secretEnv: runtimeServiceSecretOutputEnvironment(service, "password", password), secretEnvTargets: mysqlRuntimeServiceSecretTargets(service), evidence, async control(action) { @@ -1212,17 +1214,27 @@ function mysqlConnectionArgs(host: string, port: number, username: string): stri function runtimeServiceOutputEnvironment(service: WorkspaceRecipeRuntimeService, values: Record, secretOutputs: ReadonlySet = new Set()): Record { return Object.fromEntries(Object.entries(service.outputs) .filter(([output]) => !secretOutputs.has(output)) - .map(([output, name]) => [name, values[output] ?? ""])) + .flatMap(([output, names]) => runtimeServiceOutputNames(names).map((name) => [name, values[output] ?? ""]))) +} + +function runtimeServiceSecretOutputEnvironment(service: WorkspaceRecipeRuntimeService, output: string, value: string): Record { + return Object.fromEntries(runtimeServiceOutputNames(service.outputs[output]).map((name) => [name, value])) +} + +function runtimeServiceOutputNames(value: string | string[] | undefined): string[] { + return value === undefined ? [] : Array.isArray(value) ? value : [value] } function validateDeclaredRuntimeServiceSecretTargets(services: readonly WorkspaceRecipeRuntimeService[], reservedEnvNames: readonly string[]): void { const reserved = new Set(reservedEnvNames) const outputOwners = new Map>() for (const [serviceIndex, service] of services.entries()) { - for (const [output, name] of Object.entries(service.outputs)) { - const owners = outputOwners.get(name) ?? [] - owners.push({ serviceIndex, output }) - outputOwners.set(name, owners) + for (const [output, names] of Object.entries(service.outputs)) { + for (const name of runtimeServiceOutputNames(names)) { + const owners = outputOwners.get(name) ?? [] + owners.push({ serviceIndex, output }) + outputOwners.set(name, owners) + } } } const targets = new Map() @@ -1345,7 +1357,7 @@ async function provisionSimpleDockerService( evidence.lifecycle = "provisioned" const values = spec.values(ports) return { - env: Object.fromEntries(Object.entries(service.outputs).map(([output, name]) => [name, values[output] ?? ""])), + env: runtimeServiceOutputEnvironment(service, values), secretEnv: {}, secretEnvTargets: {}, evidence, diff --git a/packages/runtime-core/src/bounded-runtime-plan.ts b/packages/runtime-core/src/bounded-runtime-plan.ts index bca5bea0..c7f3e7b2 100644 --- a/packages/runtime-core/src/bounded-runtime-plan.ts +++ b/packages/runtime-core/src/bounded-runtime-plan.ts @@ -64,6 +64,7 @@ export interface BoundedRuntimePlanAdapter startServices(context: { workspace: TWorkspace; runtime: TRuntime }): Promise execute(context: BoundedRuntimePlanExecution): Promise<{ success: boolean; exitCode?: number; message?: string; stdoutRef?: string; stderrRef?: string; resultRef?: string; artifactRefs?: string[] }> + onEntryResult?(result: BoundedRuntimePlanEntryResult): Promise stopServices(context: { workspace: TWorkspace; runtime: TRuntime; services: TServices }): Promise dispose(context: { workspace: TWorkspace; runtime: TRuntime }): Promise } @@ -134,10 +135,12 @@ async function executeEntries(plan: BoundedRunt const entry = plan.entries[index]! if (failed && plan.failFast) { results[index] = cancelledResult(entry) + await adapter.onEntryResult?.(results[index]) continue } const result = await executeEntry(entry, materialized, services, adapter) results[index] = result + await adapter.onEntryResult?.(result) if (!result.success) failed = true } } diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index 159db6b0..28c6ff2a 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -1,4 +1,4 @@ -import type { RuntimePreviewSpec, RuntimeWordPressInstallMode, WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipeMount, WorkspaceRecipePHPWasmExtensionManifest, WorkspaceRecipeRuntimeBackendPackage, WorkspaceRecipeRuntimeService, WorkspaceRecipeStep } from "./runtime-contracts.js" +import type { RuntimePreviewSpec, RuntimeWordPressInstallMode, WorkspaceRecipe, WorkspaceRecipeExtraPlugin, WorkspaceRecipeMount, WorkspaceRecipePHPWasmExtensionManifest, WorkspaceRecipePluginRuntime, WorkspaceRecipeRuntimeBackendPackage, WorkspaceRecipeRuntimeService, WorkspaceRecipeStep } from "./runtime-contracts.js" import { commandArg, commandJsonArg, commandStringListArg } from "./command-codecs.js" export { buildRuntimePackageRunRecipe, CODEBOX_RUN_RUNTIME_PACKAGE_ABILITY, RUNTIME_PACKAGE_ARTIFACT_DECLARATION_SCHEMA, RUNTIME_PACKAGE_EXECUTION_INPUT_SCHEMA, RUNTIME_PACKAGE_EXECUTION_RESULT_SCHEMA, RUNTIME_PACKAGE_OUTPUT_PROJECTION_SCHEMA, runtimePackageExecutionInput, type RuntimePackageArtifactDeclaration, type RuntimePackageExecutionInput, type RuntimePackageOutputProjection, type RuntimePackageRunRecipeOptions } from "./runtime-package-execution.js" export { RUNTIME_PACKAGE_DIAGNOSTIC_SCHEMA, RUNTIME_PACKAGE_RESULT_SCHEMA, RUNTIME_PACKAGE_TASK_SCHEMA, normalizeRuntimePackageResult, normalizeRuntimePackageTask, validateRuntimePackageTask, type RuntimePackageDiagnostic, type RuntimePackageResult, type RuntimePackageTask } from "./runtime-package-contracts.js" @@ -22,6 +22,7 @@ export interface WordPressPhpunitRecipeOptions { preview?: RuntimePreviewSpec mounts?: WorkspaceRecipeMount[] services?: WorkspaceRecipeRuntimeService[] + pluginRuntime?: WorkspaceRecipePluginRuntime extra_plugins?: WorkspaceRecipeExtraPlugin[] pluginSource?: string pluginSlug: string @@ -90,6 +91,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio inputs: { extra_plugins: normalizeExtraPlugins(options.extra_plugins), ...(services.length > 0 ? { services } : {}), + ...(options.pluginRuntime ? { pluginRuntime: options.pluginRuntime } : {}), mounts: normalizeRecipeMounts([ ...(options.pluginSource ? [{ source: options.pluginSource, target: pluginTarget } satisfies WorkspaceRecipeMount] : []), ...(options.mounts ?? []), diff --git a/packages/runtime-core/src/recipe-schema.ts b/packages/runtime-core/src/recipe-schema.ts index 83e96c2e..b67699cf 100644 --- a/packages/runtime-core/src/recipe-schema.ts +++ b/packages/runtime-core/src/recipe-schema.ts @@ -966,7 +966,12 @@ export function createWorkspaceRecipeJsonSchema(options: WorkspaceRecipeJsonSche outputs: { type: "object", minProperties: 1, - additionalProperties: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" }, + additionalProperties: { + anyOf: [ + { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" }, + { type: "array", minItems: 1, uniqueItems: true, items: { type: "string", pattern: "^[A-Z_][A-Z0-9_]*$" } }, + ], + }, }, }, }, diff --git a/packages/runtime-core/src/runtime-contracts.ts b/packages/runtime-core/src/runtime-contracts.ts index a93e9b82..381e5b20 100644 --- a/packages/runtime-core/src/runtime-contracts.ts +++ b/packages/runtime-core/src/runtime-contracts.ts @@ -153,8 +153,8 @@ export interface WorkspaceRecipeRuntimeService { responseStatus?: number responseBody?: string } - /** Explicit map from a provider output (for example `port`) to a runtime env name. */ - outputs: Record + /** Explicit map from a provider output (for example `port`) to one or more runtime env names. */ + outputs: Record } export interface WorkspaceRecipeRuntimeStack { diff --git a/packages/runtime-playground/src/mount-materialization.ts b/packages/runtime-playground/src/mount-materialization.ts index 59c5e470..5d376b62 100644 --- a/packages/runtime-playground/src/mount-materialization.ts +++ b/packages/runtime-playground/src/mount-materialization.ts @@ -567,7 +567,8 @@ function parseMaterializationJson(text: string, s try { parsed = JSON.parse(text || "{}") } catch (error) { - throw new Error(`${command} returned invalid JSON: ${errorMessage(error)}`) + const excerpt = text.trim().replace(/\s+/g, " ").slice(0, 500) + throw new Error(`${command} returned invalid JSON: ${errorMessage(error)}${excerpt ? `; response excerpt: ${excerpt}` : ""}`) } if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || (parsed as { schema?: unknown }).schema !== schema) { throw new Error(`${command} did not return ${schema}; received ${text.trim() || "empty response"}`) @@ -683,7 +684,7 @@ foreach (($payload['directories'] ?? array()) as $directory) { $skipped++; continue; } - if (is_dir($directory) || mkdir($directory, 0777, true) || is_dir($directory)) { + if (is_dir($directory) || @mkdir($directory, 0777, true) || is_dir($directory)) { $created++; continue; } @@ -698,12 +699,12 @@ foreach (($payload['files'] ?? array()) as $file) { continue; } $directory = dirname($target); - if (!is_dir($directory) && !mkdir($directory, 0777, true) && !is_dir($directory)) { + if (!is_dir($directory) && !@mkdir($directory, 0777, true) && !is_dir($directory)) { $skipped++; continue; } $decoded = base64_decode($contents, true); - if (false === $decoded || false === file_put_contents($target, $decoded)) { + if (false === $decoded || false === @file_put_contents($target, $decoded)) { $skipped++; continue; } @@ -795,7 +796,7 @@ foreach (($payload['directories'] ?? array()) as $directory) { $skipped++; continue; } - if (is_dir($directory) || mkdir($directory, 0777, true) || is_dir($directory)) { + if (is_dir($directory) || @mkdir($directory, 0777, true) || is_dir($directory)) { $created++; continue; } diff --git a/packages/runtime-playground/src/php-bootstrap.ts b/packages/runtime-playground/src/php-bootstrap.ts index aca86bf7..0d2c4f28 100644 --- a/packages/runtime-playground/src/php-bootstrap.ts +++ b/packages/runtime-playground/src/php-bootstrap.ts @@ -22,15 +22,17 @@ ${phpBody(code)}` } export function bootstrapPhpCode(spec: RuntimeCreateSpec, code: string, args: string[], wpCliBridge?: PhpBootstrapBridge, failureDiagnosticFile?: string): string { + const command = splitLeadingStrictTypesDeclare(code) const executionEnvironment = runtimeEnvOverride(args) assertRuntimeSecretEnvTargetsAvailable(spec.secretEnvTargets, spec.runtimeEnv ?? {}, executionEnvironment) const bootstrapMode = argValue(args, "bootstrap") if (bootstrapMode === "none") { - return code + return ` | undefined { function secretEnvPhp(spec: RuntimeCreateSpec): string { const secretEnv = { ...(spec.secretEnv ?? {}) } - if (spec.environment.databaseSetup === "external") { + if (spec.environment?.databaseSetup === "external") { for (const source of Object.values(spec.secretEnvTargets ?? {})) delete secretEnv[source] } return phpEnvAssignments(normalizeRuntimeEnvRecord(secretEnv, { field: "secretEnv" })) diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index b54a6bdb..37130a41 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -98,7 +98,7 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s 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);" - return `function ${options.functionName}($xml_path, $test_dir_default) { + return `function ${options.functionName}($xml_path, $test_dir_default, $requested_suites = array()) { $directories = array($test_dir_default); $suffixes = array('Test.php'); $prefixes = array('test-'); @@ -119,11 +119,23 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s $first = $errors ? trim($errors[0]->message) : 'unknown'; throw new RuntimeException('PHPUnit config could not be parsed at ' . $xml_path . ': ' . $first); } + $suite_nodes = $xml->xpath('//testsuite') ?: array(); + if (!empty($requested_suites)) { + $requested_lookup = array_fill_keys(array_map('strval', $requested_suites), true); + $suite_nodes = array_values(array_filter($suite_nodes, static function($suite) use ($requested_lookup) { + return isset($requested_lookup[(string) ($suite['name'] ?? '')]); + })); + if (empty($suite_nodes)) { + throw new RuntimeException('Requested PHPUnit testsuite was not found in config: ' . implode(',', array_keys($requested_lookup))); + } + } + $directories = array(); $base = ${options.basePathExpression}; $config_dirs = array(); $config_suffixes = array(); $config_prefixes = array(); - foreach ($xml->xpath('//testsuite/directory') ?: array() as $dir) { + foreach ($suite_nodes as $suite) { + foreach ($suite->xpath('./directory') ?: array() as $dir) { $raw = trim((string) $dir);${directoryRestriction} $config_dirs[] = $raw[0] === '/' ? rtrim($raw, '/') : rtrim($base . '/' . $raw, '/'); foreach (explode(',', (string) ($dir['suffix'] ?? '')) as $suffix) { @@ -138,18 +150,23 @@ function phpunitConfigDiscoveryPhp(options: PhpunitConfigDiscoveryPhpOptions): s $config_prefixes[] = $prefix; } } + } } - foreach ($xml->xpath('//testsuite/exclude') ?: array() as $exclude) { + foreach ($suite_nodes as $suite) { + foreach ($suite->xpath('./exclude') ?: array() as $exclude) { $raw = trim((string) $exclude); if ($raw !== '') { $excludes[] = $raw[0] === '/' ? rtrim($raw, '/') : rtrim($base . '/' . $raw, '/'); } + } } - foreach ($xml->xpath('//testsuite/file') ?: array() as $file) { + foreach ($suite_nodes as $suite) { + foreach ($suite->xpath('./file') ?: array() as $file) { $raw = trim((string) $file); if ($raw !== '') { $files[] = $raw[0] === '/' ? $raw : $base . '/' . $raw; } + } } if (!empty($config_dirs)) { $directories = $config_dirs; @@ -334,6 +351,49 @@ function ${functionName}(array $argv) { }` } +function phpunitClassHasTestsPhp(functionName: string): string { + return `function ${functionName}(ReflectionClass $class): bool { + if ($class->hasMethod('suite')) { + $suite_method = $class->getMethod('suite'); + if ($suite_method->isStatic() && $suite_method->getDeclaringClass()->getName() === $class->getName()) { + return true; + } + } + foreach ((new PHPUnit\\Util\\Reflection())->publicMethodsInTestClass($class) as $method) { + if (PHPUnit\\Util\\Test::isTestMethod($method)) { + return true; + } + } + return false; +}` +} + +function phpunitClassesOwnedByTestFilesPhp(functionName: string): string { + return `function ${functionName}(array $class_names): array { + $classes = array(); + $owners = array(); + foreach ($class_names as $class_name) { + try { + $class = new ReflectionClass($class_name); + $file = $class->getFileName(); + if ($file === false) { + continue; + } + $classes[$class_name] = array($class, $file); + if ($class->getShortName() === pathinfo($file, PATHINFO_FILENAME)) { + $owners[$file] = $class_name; + } + } catch (Throwable $e) { + continue; + } + } + return array_values(array_filter(array_keys($classes), static function($class_name) use ($classes, $owners) { + $file = $classes[$class_name][1]; + return !isset($owners[$file]) || $owners[$file] === $class_name; + })); +}` +} + export function phpunitRunCode(options: PhpunitRunCodeOptions): string { return `error_reporting(E_ALL); ini_set('display_errors', '1'); @@ -891,6 +951,14 @@ function pg_run_project_bootstrap_stage(array $cfg): void { $bootstrap = pg_project_bootstrap_from_config($phpunit_xml, $phpunit_xml_is_default); $from_config = $bootstrap !== ''; } + if ($bootstrap === '') { + if (!$phpunit_xml_is_default && !is_readable($phpunit_xml)) { + throw new RuntimeException('explicit PHPUnit config is not readable: ' . $phpunit_xml); + } + pg_log('NOTICE:project bootstrap not declared; continuing without one'); + pg_stage_ok('project_bootstrap'); + return; + } $bootstrap_real = pg_project_bootstrap_real_path($bootstrap, $phpunit_xml, $from_config); if ($bootstrap_real === null) { throw new RuntimeException('project bootstrap not found; pass project-bootstrap= or declare phpunit bootstrap'); @@ -1282,13 +1350,28 @@ ${phpunitConfigDiscoveryPhp({ ${phpunitDiscoveryPhp("wp_codebox_phpunit_discover", "pg_log")} +function wp_codebox_requested_phpunit_suites($args): array { + $suites = array(); + if (!is_array($args)) { + return $suites; + } + foreach ($args as $index => $arg) { + if ($arg === '--testsuite' && isset($args[$index + 1])) { + $suites = array_merge($suites, explode(',', (string) $args[$index + 1])); + } elseif (is_string($arg) && strpos($arg, '--testsuite=') === 0) { + $suites = array_merge($suites, explode(',', substr($arg, strlen('--testsuite=')))); + } + } + return array_values(array_filter(array_map('trim', $suites), static function($suite) { return $suite !== ''; })); +} + 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); + 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)); $test_files = wp_codebox_phpunit_discover($directories, $suffixes, $prefixes, $excludes, $configured_files); $test_files = pg_filter_changed_test_files($test_files, $changed_test_files_raw, $test_dir); if ($selected_test_file !== '') { @@ -1337,10 +1420,12 @@ try { exit(1); } $after_classes = get_declared_classes(); -foreach (array_diff($after_classes, $before_classes) as $class_name) { +${phpunitClassHasTestsPhp("pg_phpunit_class_has_tests")} +${phpunitClassesOwnedByTestFilesPhp("pg_phpunit_classes_owned_by_test_files")} +foreach (pg_phpunit_classes_owned_by_test_files(array_diff($after_classes, $before_classes)) as $class_name) { try { $ref = new ReflectionClass($class_name); - if (!$ref->isAbstract() && $ref->isSubclassOf('PHPUnit\\Framework\\TestCase')) { + if (!$ref->isAbstract() && $ref->isSubclassOf('PHPUnit\\Framework\\TestCase') && pg_phpunit_class_has_tests($ref)) { $suite->addTestSuite($ref); } } catch (Throwable $e) { @@ -1660,10 +1745,12 @@ try { exit(1); } $after_classes = get_declared_classes(); -foreach (array_diff($after_classes, $before_classes) as $class_name) { +${phpunitClassHasTestsPhp("core_pg_phpunit_class_has_tests")} +${phpunitClassesOwnedByTestFilesPhp("core_pg_phpunit_classes_owned_by_test_files")} +foreach (core_pg_phpunit_classes_owned_by_test_files(array_diff($after_classes, $before_classes)) as $class_name) { try { $ref = new ReflectionClass($class_name); - if (!$ref->isAbstract() && $ref->isSubclassOf('PHPUnit\\Framework\\TestCase')) { + if (!$ref->isAbstract() && $ref->isSubclassOf('PHPUnit\\Framework\\TestCase') && core_pg_phpunit_class_has_tests($ref)) { $suite->addTestSuite($ref); } } catch (Throwable $e) { diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 77591566..9e683e9e 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -82,11 +82,6 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: const wordpressInstallMode = spec.environment.wordpressInstallMode ?? "install-from-existing-files" const bootstrapIniEntries = runtimeBootstrapPhpIniEntries(spec) const useProgrammaticRunner = shouldUseProgrammaticPlaygroundRunner(spec, options) - const requestWorkerEndpoint = useProgrammaticRunner ? undefined : { - route: `/wp-codebox-execute-${randomBytes(12).toString("hex")}.php`, - token: randomBytes(32).toString("base64url"), - payloadDirectory: join(spec.artifactsDirectory ?? "artifacts", "playground-internal-shared"), - } usesArchiveCache = !wordpressDirectory && !spec.environment.assets?.wordpressZip readonlyMountStaging = await stageReadonlyPlaygroundMounts(mounts) emitProgress("preview:materializing-mounts", "complete", "Prepared mounted inputs", { @@ -142,7 +137,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: }, Boolean(spec.preview?.port)) : await startPlaygroundCliWithDynamicPortRetry(async (port) => { const { runCLI } = options.cliModule ?? (await import("@wp-playground/cli")) as unknown as PlaygroundCliModule const localAssetServer = wordpressStartupAsset?.localPath ? await serveLocalStartupAsset(wordpressStartupAsset.localPath) : undefined - const bootstrapSharedMounts = await pluginRuntimeBootstrapSharedMounts(spec, requestWorkerEndpoint) + const bootstrapSharedMounts = await pluginRuntimeBootstrapSharedMounts(spec) try { return await runCLI({ command: "server", @@ -185,7 +180,7 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: fixedPreviewPort: spec.preview?.port ?? null, }) - const proxiedServer = await withPreviewLeaseProvider(await withPreviewProxy({ ...server, ...(requestWorkerEndpoint ? { requestWorkerEndpoint } : {}) }, spec.preview?.port ?? 0, spec.preview?.bind), spec) + const proxiedServer = await withPreviewLeaseProvider(await withPreviewProxy(server, spec.preview?.port ?? 0, spec.preview?.bind), spec) emitProgress("preview:ready", "complete", "Preview ready", { localUrl: proxiedServer.serverUrl, upstreamUrl: server.serverUrl, @@ -309,9 +304,10 @@ export function shouldUseProgrammaticPlaygroundRunner(spec: RuntimeCreateSpec, o && (Boolean(runtimeBootstrapPhpIniEntries(spec)) || Boolean(spec.environment.extensions?.length)) } -async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec, requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string }): Promise> { +async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec): Promise> { const iniEntries = runtimeBootstrapPhpIniEntries(spec) - if (!iniEntries && !requestWorkerEndpoint) { + const externalWpConfig = externalDatabaseWpConfig(spec) + if (!iniEntries && !externalWpConfig) { return [] } @@ -321,8 +317,6 @@ async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec, reque await writeFile(join(directory, "php.ini"), phpIniContent(iniEntries), "utf8") await writeFile(join(directory, "wp-codebox-auto-prepend.php"), runtimeAutoPrependPhp(spec), "utf8") } - if (requestWorkerEndpoint) await writeFile(join(directory, "request-worker.php"), requestWorkerPhp(requestWorkerEndpoint.token), "utf8") - const externalWpConfig = externalDatabaseWpConfig(spec) if (externalWpConfig) await writeFile(join(directory, "wp-config.php"), externalWpConfig, "utf8") return [ @@ -330,46 +324,10 @@ async function pluginRuntimeBootstrapSharedMounts(spec: RuntimeCreateSpec, reque { hostPath: join(directory, "php.ini"), vfsPath: "/internal/shared/php.ini" }, { hostPath: join(directory, "wp-codebox-auto-prepend.php"), vfsPath: "/internal/shared/wp-codebox-auto-prepend.php" }, ] : []), - ...(requestWorkerEndpoint ? [ - { hostPath: directory, vfsPath: "/internal/wp-codebox" }, - { hostPath: join(directory, "request-worker.php"), vfsPath: `/wordpress${requestWorkerEndpoint.route}` }, - ] : []), ...(externalWpConfig ? [{ hostPath: join(directory, "wp-config.php"), vfsPath: "/wordpress/wp-config.php" }] : []), ] } -function requestWorkerPhp(token: string): string { - return ` $wp_codebox_value) { - if (!is_string($wp_codebox_name) || !is_string($wp_codebox_value)) { - http_response_code(400); - exit; - } - putenv($wp_codebox_name . '=' . $wp_codebox_value); - $_ENV[$wp_codebox_name] = $wp_codebox_value; - $_SERVER[$wp_codebox_name] = $wp_codebox_value; -} -eval('?>' . $wp_codebox_code); -` -} - function runtimeBootstrapPhpIniEntries(spec: RuntimeCreateSpec): Record | undefined { const entries = pluginRuntimeBootstrapPhpIniEntries(spec) ?? {} if (Object.keys(entries).length === 0 && !runtimeAutoPrependPhpBody(spec)) { @@ -380,16 +338,33 @@ function runtimeBootstrapPhpIniEntries(spec: RuntimeCreateSpec): Record | undefined { - return pluginRuntimePhpEntries(spec, "bootstrapIniEntries") + const memoryLimit = pluginRuntimePhpMemoryLimit(spec) + return { + ...(memoryLimit ? { memory_limit: memoryLimit } : {}), + ...(pluginRuntimePhpEntries(spec, "bootstrapIniEntries") ?? {}), + } } function pluginRuntimePhpIniEntries(spec: RuntimeCreateSpec): Record | undefined { + const memoryLimit = pluginRuntimePhpMemoryLimit(spec) return { ...DEFAULT_RUNTIME_PHP_INI_ENTRIES, + ...(memoryLimit ? { memory_limit: memoryLimit } : {}), ...(pluginRuntimePhpEntries(spec, "iniEntries") ?? {}), } } +function pluginRuntimePhpMemoryLimit(spec: RuntimeCreateSpec): string | undefined { + const pluginRuntime = spec.metadata?.recipe && typeof spec.metadata.recipe === "object" && !Array.isArray(spec.metadata.recipe) + ? (spec.metadata.recipe as { inputs?: { pluginRuntime?: unknown } }).inputs?.pluginRuntime + : undefined + const php = pluginRuntime && typeof pluginRuntime === "object" && !Array.isArray(pluginRuntime) + ? (pluginRuntime as { php?: Record }).php + : undefined + const memoryLimit = php?.memoryLimit + return typeof memoryLimit === "string" && /^[0-9]+[KMG]?$/.test(memoryLimit) ? memoryLimit : undefined +} + function pluginRuntimePhpEntries(spec: RuntimeCreateSpec, key: "iniEntries" | "bootstrapIniEntries"): Record | undefined { const pluginRuntime = spec.metadata?.recipe && typeof spec.metadata.recipe === "object" && !Array.isArray(spec.metadata.recipe) ? (spec.metadata.recipe as { inputs?: { pluginRuntime?: unknown } }).inputs?.pluginRuntime diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index e88eaeb5..0484f3ee 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -1,9 +1,9 @@ import { randomBytes } from "node:crypto" import { AsyncLocalStorage } from "node:async_hooks" -import { mkdir, readFile, realpath, unlink, writeFile } from "node:fs/promises" +import { mkdir, readFile, realpath, writeFile } from "node:fs/promises" import type { IncomingMessage, ServerResponse } from "node:http" import { dirname, join, resolve } from "node:path" -import { HostToolRegistry, PREVIEW_LEASE_SCHEMA, RUNTIME_EPISODE_OBSERVATION_SCHEMA, RUNTIME_EPISODE_SNAPSHOT_SCHEMA, RuntimeActionExecutionError, assertRuntimeCommandAllowed, assertRuntimeSecretEnvTargetsAvailable, commandAgentRunResultJson, createCommandAgentRunResult, createHostToolRegistry, createRuntimeCommandResultEnvelope, parseCommandAgentRunRequest, previewLease, resolveArtifactPath, resolveCommandPath, runtimeCommandResultEnvelopeFromOutput, runtimeEpisodeDigest } from "@automattic/wp-codebox-core" +import { HostToolRegistry, PREVIEW_LEASE_SCHEMA, RUNTIME_EPISODE_OBSERVATION_SCHEMA, RUNTIME_EPISODE_SNAPSHOT_SCHEMA, RuntimeActionExecutionError, assertRuntimeCommandAllowed, assertRuntimeSecretEnvTargetsAvailable, commandAgentRunResultJson, createCommandAgentRunResult, createHostToolRegistry, createRuntimeCommandResultEnvelope, parseCommandAgentRunRequest, previewLease, resolveArtifactPath, resolveCommandPath, resolveRuntimeSecretEnvTargets, runtimeCommandResultEnvelopeFromOutput, runtimeEpisodeDigest } from "@automattic/wp-codebox-core" import { now, sha256 } from "@automattic/wp-codebox-core/internals" import { recipeCommandDefinitions } from "@automattic/wp-codebox-core/contracts" import { browserArtifactFileManifest, browserReviewSummary as browserArtifactReviewSummary, type BrowserArtifact, type BrowserArtifactFiles } from "./browser-artifacts.js" @@ -251,8 +251,7 @@ class PlaygroundRuntime implements Runtime { private cliServerPromise?: Promise private readonly activeExecutionAbortControllers = new Set() private readonly executionSignals = new AsyncLocalStorage() - private readonly requestWorkerExecutions = new AsyncLocalStorage>() - private requestWorkerReady?: Promise + private readonly isolatedProcessExecutions = new AsyncLocalStorage() private reviewerAuthBootstrapRouteRegistered = false private readonly reviewerAuthBootstraps = new Map() @@ -367,9 +366,7 @@ class PlaygroundRuntime implements Runtime { try { const executionSpec = executionSpecWithEnvironment(spec) const output = await this.executionSignals.run(abortController.signal, async () => { - const executeCommand = async () => spec.processIdentity - ? await this.requestWorkerExecutions.run(spec.environment ?? {}, async () => await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController)) - : await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController) + const executeCommand = async () => await this.isolatedProcessExecutions.run(Boolean(spec.processIdentity), async () => await timeoutPlaygroundCommand(executePlaygroundCommand(this, executionSpec, this.hostTools), spec, abortController)) return spec.command === "wordpress.phpunit" ? await terminalizeOnPhpWasmRuntimeRejection(executeCommand, () => abortController.abort()) : await executeCommand() @@ -1771,11 +1768,14 @@ class PlaygroundRuntime implements Runtime { private async runPlaygroundCommand(command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }): Promise { try { - const requestWorkerEnvironment = this.requestWorkerExecutions.getStore() - if (requestWorkerEnvironment && "code" in options && server.requestWorkerEndpoint) { - await this.prepareRequestWorker(server) - const response = await this.executeRequestWorker(server, options.code, requestWorkerEnvironment, this.executionSignals.getStore()) - return { text: response.text, exitCode: response.ok ? 0 : 1, ...(!response.ok ? { errors: response.text } : {}) } + if (this.isolatedProcessExecutions.getStore() && "code" in options) { + if (!server.playground.runInFreshProcess) { + throw new Error("The Playground runtime does not support clean PHP process execution.") + } + return await abortable(server.playground.runInFreshProcess({ + ...options, + env: resolveRuntimeSecretEnvTargets(this.spec.secretEnv ?? {}, this.spec.secretEnvTargets), + }), this.executionSignals.getStore()) } return await abortable(server.playground.run(options), this.executionSignals.getStore()) } catch (error) { @@ -1788,36 +1788,6 @@ class PlaygroundRuntime implements Runtime { } } - private async prepareRequestWorker(server: PlaygroundCliServer): Promise { - if (!server.requestWorkerEndpoint) return - this.requestWorkerReady ??= (async () => { - const response = await this.executeRequestWorker(server, ", signal?: AbortSignal): Promise<{ ok: boolean; status: number; text: string }> { - const endpoint = server.requestWorkerEndpoint - if (!endpoint) throw new Error("Playground request worker endpoint is unavailable.") - const payloadId = randomBytes(16).toString("hex") - const payloadPath = join(endpoint.payloadDirectory, `execution-${payloadId}.json`) - await writeFile(payloadPath, JSON.stringify({ code, environment }), "utf8") - try { - const response = await fetch(new URL(endpoint.route, server.serverUrl), { - method: "POST", - headers: { - "X-WP-Codebox-Execution-Token": endpoint.token, - "X-WP-Codebox-Execution-Payload": payloadId, - }, - signal, - }) - return { ok: response.ok, status: response.status, text: await response.text() } - } finally { - await unlink(payloadPath).catch(() => undefined) - } - } - async inspectMountedInputs(): Promise { const server = await this.bootPlayground() const response = await server.playground.run({ @@ -1933,9 +1903,11 @@ echo json_encode(array('command' => 'inspect-mounted-inputs', 'mounts' => $inspe function executionSpecWithEnvironment(spec: ExecutionSpec): ExecutionSpec { if (!spec.environment || Object.keys(spec.environment).length === 0) return spec + const args = spec.args ?? [] + const runtimeEnvironment = JSON.parse(argValue(args, "runtime-env-json") ?? "{}") as Record return { ...spec, - args: [...(spec.args ?? []).filter((argument) => !argument.startsWith("runtime-env-json=")), `runtime-env-json=${JSON.stringify(spec.environment)}`], + args: [...args.filter((argument) => !argument.startsWith("runtime-env-json=")), `runtime-env-json=${JSON.stringify({ ...runtimeEnvironment, ...spec.environment })}`], } } diff --git a/packages/runtime-playground/src/preview-server.ts b/packages/runtime-playground/src/preview-server.ts index 055d6f12..2365556a 100644 --- a/packages/runtime-playground/src/preview-server.ts +++ b/packages/runtime-playground/src/preview-server.ts @@ -11,13 +11,13 @@ export interface PlaygroundServerRunResponse { export interface PlaygroundCliServer { playground: { run(options: ({ code: string } | { scriptPath: string }) & { env?: Record }): Promise + runInFreshProcess?(options: { code: string; env?: Record }): Promise onMessage?(listener: (data: string) => Promise | string | void): Promise<(() => Promise | void) | void> | (() => Promise | void) | void readFileAsText?(path: string): string | Promise unlink?(path: string): Promise | void writeFile?(path: string, contents: string): Promise } serverUrl: string - requestWorkerEndpoint?: { route: string; token: string; payloadDirectory: string } previewLease?: PreviewLease previewRoutes?: PlaygroundPreviewRouteRegistry previewProxyDiagnostics?: PlaygroundPreviewProxyDiagnostics diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index 706de66a..6350a49b 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -927,6 +927,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 phpunitArgs = jsonArrayArg(args, "phpunit-args-json").filter((value): value is string => typeof value === "string") const processIdentity = boundedProcessIdentity(spec.processIdentity) 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` @@ -942,7 +943,7 @@ export async function runPhpunitCommand({ 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"), + phpunitArgs, env: jsonObjectArg(args, "env-json"), wpConfigDefines: jsonObjectArg(args, "wp-config-defines-json"), dependencyMounts: commaListArg(args, "dependency-mounts"), @@ -982,10 +983,30 @@ export async function runPhpunitCommand({ } throw error } + if (structured) { + throw attachPlaygroundDiagnostics(new Error("wordpress.phpunit terminated before completing bootstrap"), "wordpress.phpunit structured diagnostics", structured) + } + if (!phpunitArgs.includes("--list-tests") && !hasSuccessfulPhpunitSummary(response.text)) { + const diagnostics = successfulPhpunitResponseDiagnostics(response) + throw attachPlaygroundDiagnostics(new Error("wordpress.phpunit exited successfully without a non-zero PHPUnit test summary"), "wordpress.phpunit successful response diagnostics", diagnostics) + } return response.text } +export function hasSuccessfulPhpunitSummary(output: string): boolean { + return /\bOK(?:, but (?:there were issues!|incomplete, skipped, or risky tests!))?(?: \([1-9]\d* tests?,|\s+Tests:\s*[1-9]\d*,\s*Assertions:)/m.test(output) + || (/##teamcity\[testStarted\b/m.test(output) && /##teamcity\[testSuiteFinished\b/m.test(output)) +} + +function successfulPhpunitResponseDiagnostics(response: PlaygroundRunResponse): string { + const boundedStream = (value: string): string => value.length > 9_000 ? `${value.slice(0, 9_000)}\n[stream truncated]` : value + return [ + response.errors?.trim() ? `--- stderr ---\n${boundedStream(response.errors.trim())}` : "", + response.text.trim() ? `--- stdout ---\n${boundedStream(response.text.trim())}` : "", + ].filter(Boolean).join("\n") +} + function boundedProcessIdentity(value: string | undefined): string { if (!value) return "" if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value)) throw new Error(`Invalid PHPUnit process identity: ${value}`) diff --git a/tests/bounded-recipe-plan.test.ts b/tests/bounded-recipe-plan.test.ts index 7d5769f7..56f3a960 100644 --- a/tests/bounded-recipe-plan.test.ts +++ b/tests/bounded-recipe-plan.test.ts @@ -57,6 +57,11 @@ try { assert.equal(await readFile(join(root, "entries/one/stdout.txt"), "utf8"), "password=[redacted]\n") assert.equal(await readFile(join(root, "entries/failed/stderr.txt"), "utf8"), "database [redacted] failed\n") assert.equal(JSON.parse(await readFile(join(root, "bounded-plan/result.json"), "utf8")).schema, "wp-codebox/bounded-runtime-plan-result/v1") + const progress = JSON.parse(await readFile(join(root, "bounded-plan/progress.json"), "utf8")) + assert.equal(progress.schema, "wp-codebox/bounded-runtime-plan-progress/v1") + assert.equal(progress.complete, true) + assert.deepEqual(progress.counts, { total: 2, succeeded: 1, failed: 1, timedOut: 0, cancelled: 0, unfinished: 0 }) + assert.deepEqual(progress.entries.map((entry: { id: string }) => entry.id), ["one", "failed"]) } finally { await rm(root, { recursive: true, force: true }) } diff --git a/tests/bounded-runtime-plan.test.ts b/tests/bounded-runtime-plan.test.ts index 88599de7..d232aa68 100644 --- a/tests/bounded-runtime-plan.test.ts +++ b/tests/bounded-runtime-plan.test.ts @@ -13,6 +13,7 @@ const plan: BoundedRuntimePlan = { } const lifecycle: string[] = [] +const completedEntries: string[] = [] let active = 0 let maximumActive = 0 const executed: string[] = [] @@ -31,6 +32,7 @@ const adapter: BoundedRuntimePlanAdapter<{ root: string }, { id: string }, { id: active-- } }, + async onEntryResult(result) { completedEntries.push(result.id) }, async stopServices() { lifecycle.push("stop-services") }, async dispose() { lifecycle.push("dispose") }, } @@ -44,6 +46,7 @@ assert.deepEqual(result.counts, { total: 4, succeeded: 2, failed: 1, timedOut: 1 assert.equal(result.entries.every((entry) => Number.isInteger(entry.durationMs) && entry.durationMs >= 0), true) assert.deepEqual(lifecycle, ["materialize", "start-services", "stop-services", "dispose"], "workspace, service, and runtime lifecycles are each owned once") assert.deepEqual(executed.sort(), ["failed", "first", "last", "slow"], "one failed entry does not isolate unrelated entries") +assert.deepEqual(completedEntries.sort(), ["failed", "first", "last", "slow"], "each terminal entry is reported to the adapter") const retry = retryBoundedRuntimePlan(plan, result) assert.deepEqual(retry.entries.map((entry) => entry.id), ["failed", "slow"], "retry selects only unsuccessful prior entries") diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts index 453adc8c..7b8eebd0 100644 --- a/tests/disposable-mysql-mysqli.integration.test.ts +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -77,7 +77,7 @@ if (!await dockerAvailable()) { await mkdir(join(plugin, "tests"), { recursive: true }) await writeFile(join(plugin, "bounded-phpunit-fixture.php"), "tests\n") - await writeFile(join(plugin, "tests", "bootstrap.php"), "SelectedTest.phpUnrelatedTest.php`) + writeFileSync(scriptPath, `name = $name; }` writeFileSync(testFile, `tests[] = $class->newInstance(); } + public function addTestSuite(\\ReflectionClass $class): void { + if ($class->getName() === 'ConcreteHelper') { throw new \\RuntimeException('concrete helper was scheduled as a test'); } + $this->tests[] = $class->newInstance(); + } public function tests(): array { return $this->tests; } public function count(): int { return count($this->tests); } } } +namespace PHPUnit\\Util { +final class Reflection { + public function publicMethodsInTestClass(\\ReflectionClass $class): array { return $class->getMethods(\\ReflectionMethod::IS_PUBLIC); } +} +final class Test { + public static function isTestMethod(\\ReflectionMethod $method): bool { + return strpos($method->getName(), 'test') === 0 || strpos((string) $method->getDocComment(), '@test') !== false; + } +} +} namespace PHPUnit\\TextUI { final class TestResult { private $count; @@ -396,7 +447,7 @@ final class TestRunner { } } namespace { -$test_files = array(${phpString(testFile)}); +$test_files = array(${phpString(testFile)}, ${phpString(legacyTestFile)}); $phpunit_argv = array('phpunit'); $argv = array('phpunit'); function ${stagePrefix}_stage_begin($stage) { file_put_contents(getenv('STAGE_LOG'), 'STAGE_BEGIN:' . $stage . "\\n", FILE_APPEND); } @@ -541,7 +592,8 @@ 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("NOTICE:project bootstrap not declared; continuing without one"), "project mode permits PHPUnit configurations that do not declare a bootstrap") +assert.ok(projectModeCode.includes("foreach ($suite->xpath('./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)")) @@ -549,6 +601,7 @@ assert.equal(projectModeCode.match(/return array\(\$directories, \$suffixes, \$p 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) +assertNamedTestsuiteScopesDiscovery(projectModeCode) assertChangedScopeNoOp(projectModeCode, "pg_filter_changed_test_files", "pg_component_relative_path") assertSelectedTestFileResolution(projectModeCode) @@ -604,7 +657,7 @@ await runPhpunitCommand({ mounts: [], runPlaygroundCommand: async (_command, _server, input) => { capturedCanonicalHarnessCode = input.code - return { text: "ok", exitCode: 0 } + return { text: "OK (1 test, 1 assertion)", exitCode: 0 } }, runtimeSpec: phpunitRuntimeSpec, server: { playground: {} } as never, @@ -632,7 +685,7 @@ await runPhpunitCommand({ mounts: [], runPlaygroundCommand: async (_command, _server, input) => { capturedExplicitCode = input.code - return { text: "ok", exitCode: 0 } + return { text: "OK (1 test, 1 assertion)", exitCode: 0 } }, runtimeSpec: phpunitRuntimeSpec, server: { playground: {} } as never, @@ -652,7 +705,7 @@ await runPhpunitCommand({ mounts: [], runPlaygroundCommand: async (_command, _server, input) => { capturedMysqlCode = input.code - return { text: "ok", exitCode: 0 } + return { text: "OK (1 test, 1 assertion)", exitCode: 0 } }, runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest", databaseSetup: "external" }, diff --git a/tests/phpunit-runtime-failure-diagnostics.test.ts b/tests/phpunit-runtime-failure-diagnostics.test.ts index 93ec3c23..04628af8 100644 --- a/tests/phpunit-runtime-failure-diagnostics.test.ts +++ b/tests/phpunit-runtime-failure-diagnostics.test.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile } from "node:fs/promises" import { tmpdir } from "node:os" import { join } from "node:path" import { PLUGIN_PHPUNIT_RESULT_FILE } from "../packages/runtime-playground/src/phpunit-command-handlers.js" -import { runPhpunitCommand } from "../packages/runtime-playground/src/wordpress-command-runners.js" +import { hasSuccessfulPhpunitSummary, runPhpunitCommand } from "../packages/runtime-playground/src/wordpress-command-runners.js" const artifactRoot = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-runtime-diagnostics-")) const secret = "sk-abcdefghijklmnopqrstuvwxyz" @@ -15,7 +15,7 @@ await assert.rejects( mounts: [], runPlaygroundCommand: async (_command, _server, input) => { submittedCode = input.code - return { exitCode: 1, errors: "", text: "" } + return { exitCode: 0, errors: "", text: "" } }, runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, policy: { commands: ["wordpress.phpunit"] } } as never, server: { @@ -29,7 +29,7 @@ await assert.rejects( spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin"] }, }), (error: Error) => { - assert.match(error.message, /wordpress\.phpunit failed with exit code 1/) + assert.match(error.message, /wordpress\.phpunit terminated before completing bootstrap/) assert.match(error.message, /wordpress\.phpunit structured diagnostics/) assert.match(error.message, /Bootstrap failed with token: \[redacted\]/) assert.match(error.message, /\[diagnostic truncated\]/) @@ -49,4 +49,38 @@ 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") +const emptySuccessArtifactRoot = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-empty-success-")) +const successfulResponseSecret = "ghp_abcdefghijklmnopqrstuvwxyz1234567890" +assert.equal(hasSuccessfulPhpunitSummary("OK (1 test, 1 assertion)"), true) +assert.equal(hasSuccessfulPhpunitSummary("OK, but incomplete, skipped, or risky tests!\nTests: 21, Assertions: 27398, Skipped: 1."), true) +assert.equal(hasSuccessfulPhpunitSummary("##teamcity[testSuiteStarted name='example']\n##teamcity[testStarted name='works']\n##teamcity[testFinished name='works']\n##teamcity[testSuiteFinished name='example']"), true) +assert.equal(hasSuccessfulPhpunitSummary("##teamcity[testSuiteStarted name='example']\n##teamcity[testSuiteFinished name='example']"), false) +assert.equal(hasSuccessfulPhpunitSummary("Tests: 21, Assertions: 27398, Failures: 1."), false) +await assert.rejects( + () => runPhpunitCommand({ + artifactRoot: emptySuccessArtifactRoot, + mounts: [], + runPlaygroundCommand: async () => ({ + exitCode: 0, + errors: `PHPUnit stderr token=${successfulResponseSecret}\n${"e".repeat(25_000)}`, + text: `WPCOM Codebox PHPUnit shutdown: mysql_port=unset\n${"x".repeat(25_000)}`, + }), + runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, policy: { commands: ["wordpress.phpunit"] } } as never, + server: { playground: { readFileAsText: async () => { throw new Error("missing") } } } as never, + spec: { command: "wordpress.phpunit", args: ["plugin-slug=demo-plugin"] }, + }), + (error: Error) => { + assert.match(error.message, /exited successfully without a non-zero PHPUnit test summary/) + assert.match(error.message, /wordpress\.phpunit successful response diagnostics/) + assert.match(error.message, /--- stderr ---/) + assert.match(error.message, /--- stdout ---/) + assert.match(error.message, /WPCOM Codebox \[redacted\] shutdown: mysql_port=unset/) + assert.match(error.message, /PHPUnit stderr token=\[redacted\]/) + assert.match(error.message, /\[stream truncated\]/) + assert.doesNotMatch(error.message, new RegExp(successfulResponseSecret)) + assert.ok(error.message.length < 19_000, "successful response diagnostics must remain bounded") + return true + }, +) + console.log("phpunit runtime failure diagnostics ok") diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index 1a438d43..e12c665c 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -51,7 +51,8 @@ try { inputs: { pluginRuntime: { php: { - iniEntries: { memory_limit: "512M" }, + memoryLimit: "2G", + iniEntries: { max_input_vars: 2048 }, bootstrapIniEntries: { "opcache.file_cache": "/tmp/opcache" }, }, }, @@ -75,21 +76,19 @@ try { await server[Symbol.asyncDispose]() assert.equal(calls.length, 1) - assert.equal(calls[0]["mount-before-install"]?.length, 6) + assert.equal(calls[0]["mount-before-install"]?.length, 4) assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/shared/php.ini") assert.equal(calls[0]["mount-before-install"]?.[1]?.vfsPath, "/internal/shared/wp-codebox-auto-prepend.php") - assert.equal(calls[0]["mount-before-install"]?.[2]?.vfsPath, "/internal/wp-codebox") - assert.match(calls[0]["mount-before-install"]?.[3]?.vfsPath ?? "", /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/) // A wordpress-develop checkout is the runtime root, not an ordinary post-startup mount. - assert.equal(calls[0]["mount-before-install"]?.[4]?.vfsPath, "/wordpress/wp-config.php") - assert.deepEqual(calls[0]["mount-before-install"]?.[5], { hostPath: wordpressDevelopDirectory, vfsPath: "/wordpress" }) + assert.equal(calls[0]["mount-before-install"]?.[2]?.vfsPath, "/wordpress/wp-config.php") + assert.deepEqual(calls[0]["mount-before-install"]?.[3], { hostPath: wordpressDevelopDirectory, vfsPath: "/wordpress" }) assert.deepEqual(calls[0].mount, []) assert.equal(calls[0].workers, 6) assert.equal(calls[0].wordpressInstallMode, "do-not-attempt-installing") assert.equal(calls[0].skipSqliteSetup, true) assert.equal(calls[0].phpEnv?.DB_PASSWORD, "secret") assert.equal(shouldUseProgrammaticPlaygroundRunner(spec), false) - assert.deepEqual(calls[0].phpIniEntries, { memory_limit: "512M" }) + assert.deepEqual(calls[0].phpIniEntries, { memory_limit: "2G", max_input_vars: "2048" }) assert.deepEqual(calls[0].phpExtension, ["/tmp/sodium/manifest.json"]) const sharedPhpIniPath = calls[0]["mount-before-install"]?.[0]?.hostPath const sharedAutoPrependPath = calls[0]["mount-before-install"]?.[1]?.hostPath @@ -99,17 +98,12 @@ try { assert.match(sharedPhpIni, /opcache\.file_cache = \/tmp\/opcache/) // The runtime default memory ceiling stays high enough for collect_artifacts to // base64 heavy snapshot/declared-artifact files without a hard PHP fatal. - assert.match(sharedPhpIni, /memory_limit=512M/) + assert.match(sharedPhpIni, /memory_limit = 2G/) assert.match(sharedPhpIni, /auto_prepend_file=\/internal\/shared\/wp-codebox-auto-prepend\.php/) const sharedAutoPrepend = await readFile(sharedAutoPrependPath as string, "utf8") assert.match(sharedAutoPrepend, /require_once '\/internal\/shared\/auto_prepend_file\.php'/) assert.match(sharedAutoPrepend, /putenv\("TC_MYSQL_PORT=33060"\);/) - assert.doesNotMatch(sharedAutoPrepend, /secret|DB_PASSWORD/) - assert.equal(runs[0]?.env?.DB_PASSWORD, undefined) - const requestWorkerPath = calls[0]["mount-before-install"]?.[3]?.hostPath - assert.equal(typeof requestWorkerPath, "string") - assert.doesNotMatch(await readFile(requestWorkerPath as string, "utf8"), /secret/) - const externalWpConfigPath = calls[0]["mount-before-install"]?.[4]?.hostPath + const externalWpConfigPath = calls[0]["mount-before-install"]?.[2]?.hostPath assert.equal(typeof externalWpConfigPath, "string") const externalWpConfig = await readFile(externalWpConfigPath as string, "utf8") assert.match(externalWpConfig, /define\('DB_HOST', "127\.0\.0\.1:33061"\)/) @@ -121,8 +115,6 @@ try { assert.equal((await passwordlessExternalServer.playground.run({ code: " mount.vfsPath === "/internal/wp-codebox"), true, "passwordless external databases retain isolated request workers") - assert.equal(calls[0]?.["mount-before-install"]?.some((mount) => /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/.test(mount.vfsPath)), true) calls.length = 0 const defaultRuntimeIniSpec: RuntimeCreateSpec = { @@ -154,9 +146,7 @@ try { await downloadedWordPressServer[Symbol.asyncDispose]() assert.equal(calls.length, 1) - assert.equal(calls[0]["mount-before-install"]?.length, 2) - assert.equal(calls[0]["mount-before-install"]?.[0]?.vfsPath, "/internal/wp-codebox") - assert.match(calls[0]["mount-before-install"]?.[1]?.vfsPath ?? "", /^\/wordpress\/wp-codebox-execute-[a-f0-9]{24}\.php$/) + assert.equal(calls[0]["mount-before-install"], undefined) assert.equal(calls[0].wordpressInstallMode, undefined) assert.equal(shouldUseProgrammaticPlaygroundRunner(downloadedWordPressSpec), false) diff --git a/tests/playground-phpunit-bootstrap-failure.integration.test.ts b/tests/playground-phpunit-bootstrap-failure.integration.test.ts index d1a3d140..a4c7031f 100644 --- a/tests/playground-phpunit-bootstrap-failure.integration.test.ts +++ b/tests/playground-phpunit-bootstrap-failure.integration.test.ts @@ -52,7 +52,7 @@ try { assert.match(commandLog, /PHPUnit bootstrap fixture token: \[redacted\]/) assert.doesNotMatch(commandLog, new RegExp(secret)) - await writeFile(fatalMuPlugin, ` { 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_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", "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(dependency, "activation-dependency.php"), " 1)))); });\n") } diff --git a/tests/runtime-services.test.ts b/tests/runtime-services.test.ts index a2e40bcc..0292f4b6 100644 --- a/tests/runtime-services.test.ts +++ b/tests/runtime-services.test.ts @@ -11,6 +11,7 @@ import { buildWordPressPhpunitRecipe } from "../packages/runtime-core/src/recipe import { validateWorkspaceRecipeJsonSchema, type WorkspaceRecipe } from "../packages/runtime-core/src/index.ts" const service = { id: "test-db", kind: "mysql", outputs: { host: "DB_HOST", port: "DB_PORT", password: "DB_PASSWORD" } } as const +const fanoutService = { ...service, outputs: { ...service.outputs, port: ["DB_PORT", "TC_MYSQL_PORT"] } } const plan = runtimeServicePlan([service]) assert.deepEqual(plan, [{ id: "test-db", kind: "mysql", provider: "docker", version: "mysql:8.4", bind: "loopback", port: "ephemeral", persistentVolume: false, outputs: service.outputs }]) assert.equal(parseLoopbackPort("127.0.0.1:44001\n"), 44001) @@ -25,6 +26,7 @@ assert.deepEqual(runtimeServicePlan(auxiliaryServices).map(({ kind, version }) = const valid = validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [service] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }) assert.equal(valid.valid, true) assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: auxiliaryServices }, workflow: { steps: [{ command: "wordpress.run-php" }] } }).valid, true) +assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [fanoutService] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }).valid, true) const unsafe = validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [{ ...service, outputs: { port: "bad-name" } }] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }) assert.equal(unsafe.valid, false) const emptyRootService = { ...service, configuration: { rootAuthentication: "empty-password" as const } } @@ -43,6 +45,7 @@ const sqlitePhpunitRecipe = buildWordPressPhpunitRecipe({ pluginSlug: "example" assert.equal(sqlitePhpunitRecipe.inputs?.services, undefined) assert.equal(sqlitePhpunitRecipe.workflow.steps[0].args?.some((arg) => arg.startsWith("database-type=")), false) assert.equal(buildWordPressPhpunitRecipe({ pluginSlug: "example", wordpressInstallMode: "do-not-attempt-installing" }).runtime?.wordpressInstallMode, "do-not-attempt-installing") +assert.deepEqual(buildWordPressPhpunitRecipe({ pluginSlug: "example", pluginRuntime: { php: { memoryLimit: "2G" } } }).inputs?.pluginRuntime, { php: { memoryLimit: "2G" } }) const builderDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-builder-")) try { const optionsPath = join(builderDirectory, "options.json") @@ -117,8 +120,11 @@ const dependencies: RuntimeServiceDependencies = { async waitForReady() {}, } const provisioned = await provisionRuntimeServices([service], { dependencies }) +const fanoutProvisioned = await provisionRuntimeServices([fanoutService], { dependencies }) const provisionedPassword = Buffer.alloc(24, 7).toString("base64url") assert.equal(provisioned.env.DB_PORT, "41001") +assert.equal(fanoutProvisioned.env.DB_PORT, "41001") +assert.equal(fanoutProvisioned.env.TC_MYSQL_PORT, "41001") assert.equal(provisioned.env.DB_PASSWORD, undefined, "password is excluded from the non-secret output channel") assert.equal(provisioned.secretEnv.DB_PASSWORD, provisionedPassword) assert.deepEqual(provisioned.secretEnvTargets, { DB_PASSWORD: "DB_PASSWORD" }) @@ -137,7 +143,8 @@ assert.equal(runCall?.env?.DOCKER_HOST, process.env.DOCKER_HOST, "Docker provide assert.equal(calls[0]?.args[0], "image", "the provider checks the image before starting the service") await provisioned.release() await provisioned.release() -assert.equal(calls.filter((call) => call.args[0] === "rm").length, 1, "release is idempotent") +await fanoutProvisioned.release() +assert.equal(calls.filter((call) => call.args[0] === "rm").length, 2, "each service is released exactly once") const emptyRootCalls: Array<{ args: string[]; env?: NodeJS.ProcessEnv }> = [] const emptyRootDependencies: RuntimeServiceDependencies = { diff --git a/tests/staged-input-materialization.test.ts b/tests/staged-input-materialization.test.ts index 0b3efb25..67abfb5f 100644 --- a/tests/staged-input-materialization.test.ts +++ b/tests/staged-input-materialization.test.ts @@ -118,6 +118,28 @@ await withTempDir("wp-codebox-staged-input-materialization-binary-", async (root assert.equal(result.skipped, 0) assert.equal(directWrites, 0, "arbitrary bytes bypass the UTF-8-only direct writer") assert.ok(binaryWriteCode.includes(contents.toString("base64")), "the PHP materializer receives the exact base64 payload") + assert.match(binaryWriteCode, /@mkdir\(/, "expected filesystem failures do not corrupt the JSON response channel") + assert.match(binaryWriteCode, /@file_put_contents\(/, "expected write failures remain structured skipped results") +}) + +await withTempDir("wp-codebox-staged-input-materialization-invalid-response-", async (root) => { + const source = join(root, "image.bin") + await writeFile(source, Buffer.from([0xff])) + const server = { + playground: { + async run({ code }: { code: string }) { + if (code.includes("wp-codebox/host-mount-materialization/v1")) { + return { text: "
\nFatal error: materialization failed" } + } + return { text: JSON.stringify({ schema: "wp-codebox/host-mount-directory-materialization/v1", created: 1, skipped: 0 }) } + }, + }, + } + + await assert.rejects( + materializePlaygroundStagedInputs(server as never, [{ type: "file", source, target: "/wordpress/image.bin", mode: "readwrite" }]), + /response excerpt:
Fatal error<\/b>: materialization failed/, + ) }) console.log("staged input materialization ok")