diff --git a/packages/cli/src/commands/recipe-build.ts b/packages/cli/src/commands/recipe-build.ts index 619abb731..6a55384d4 100644 --- a/packages/cli/src/commands/recipe-build.ts +++ b/packages/cli/src/commands/recipe-build.ts @@ -23,6 +23,8 @@ interface WordPressPhpunitBuilderOptions { autoloadFile?: string projectAutoloadFile?: string testsDir?: string + testRoot?: string + phpunitXml?: string dependencyMounts?: string[] bootstrapFiles?: string[] preloadFiles?: string[] @@ -87,6 +89,8 @@ function buildRecipe(recipeType: RecipeBuildOptions["recipeType"], options: Word autoloadFile: stringOrUndefined(phpunitOptions.autoloadFile), projectAutoloadFile: stringOrUndefined(phpunitOptions.projectAutoloadFile), testsDir: stringOrUndefined(phpunitOptions.testsDir), + testRoot: stringOrUndefined(phpunitOptions.testRoot), + phpunitXml: stringOrUndefined(phpunitOptions.phpunitXml), dependencyMounts: Array.isArray(phpunitOptions.dependencyMounts) ? phpunitOptions.dependencyMounts : [], bootstrapFiles: Array.isArray(phpunitOptions.bootstrapFiles) ? phpunitOptions.bootstrapFiles : [], preloadFiles: Array.isArray(phpunitOptions.preloadFiles) ? phpunitOptions.preloadFiles : [], diff --git a/packages/runtime-core/src/recipe-builders.ts b/packages/runtime-core/src/recipe-builders.ts index 4b825d223..8dda457cc 100644 --- a/packages/runtime-core/src/recipe-builders.ts +++ b/packages/runtime-core/src/recipe-builders.ts @@ -94,6 +94,7 @@ export function buildWordPressPhpunitRecipe(options: WordPressPhpunitRecipeOptio commandJsonArg("env-json", options.env ?? {}), commandJsonArg("wp-config-defines-json", options.wpConfigDefines ?? {}), commandArg("autoload-file", options.autoloadFile ?? "/wp-codebox-vendor/autoload.php"), + commandArg("autoload-file-role", "harness"), commandArg("project-autoload-file", options.projectAutoloadFile ?? ""), commandArg("tests-dir", options.testsDir ?? "/wp-codebox-vendor/wp-phpunit/wp-phpunit"), commandArg("test-root", options.testRoot ?? `${pluginTarget}/tests`), diff --git a/packages/runtime-playground/src/phpunit-command-handlers.ts b/packages/runtime-playground/src/phpunit-command-handlers.ts index 886e8bcc6..7d9982665 100644 --- a/packages/runtime-playground/src/phpunit-command-handlers.ts +++ b/packages/runtime-playground/src/phpunit-command-handlers.ts @@ -4,6 +4,7 @@ export interface PhpunitRunCodeOptions { pluginSlug: string cwd: string autoloadFile: string + autoloadFileRole?: "harness" projectAutoloadFile?: string testsDir: string testRoot?: string @@ -353,6 +354,7 @@ $result_file = ${JSON.stringify(options.resultFile ?? PLUGIN_PHPUNIT_RESULT_FILE $current_stage = 'preboot'; $pg_stage_output_buffering = false; $autoload_file = ${JSON.stringify(options.autoloadFile)}; +$autoload_file_role = ${JSON.stringify(options.autoloadFileRole ?? "")}; $project_autoload_file = ${JSON.stringify(options.projectAutoloadFile ?? "")}; $tests_dir = ${JSON.stringify(options.testsDir)}; $test_root = ${JSON.stringify(options.testRoot || `/wordpress/wp-content/plugins/${options.pluginSlug}/tests`)}; @@ -1062,7 +1064,7 @@ if ($multisite) { } $legacy_project_autoload_file = ''; -if ($bootstrap_mode === 'project' && $project_autoload_file === '' && $autoload_file !== '' && $autoload_file !== '/wp-codebox-vendor/autoload.php') { +if ($autoload_file_role === '' && $bootstrap_mode === 'project' && $project_autoload_file === '' && $autoload_file !== '' && $autoload_file !== '/wp-codebox-vendor/autoload.php') { $legacy_project_autoload_file = $autoload_file; } $harness_autoload_file = $legacy_project_autoload_file !== '' ? '/wp-codebox-vendor/autoload.php' : $autoload_file; diff --git a/packages/runtime-playground/src/wordpress-command-runners.ts b/packages/runtime-playground/src/wordpress-command-runners.ts index b88d2a8d4..c717d6a2a 100644 --- a/packages/runtime-playground/src/wordpress-command-runners.ts +++ b/packages/runtime-playground/src/wordpress-command-runners.ts @@ -909,11 +909,13 @@ export async function runPhpunitCommand({ const pluginSlug = argValue(args, "plugin-slug")?.trim() || "" const bootstrapMode = argValue(args, "bootstrap-mode")?.trim() || "managed" 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 resultFile = PLUGIN_PHPUNIT_RESULT_FILE const code = explicitCode ? await phpCodeFromArgs(args, "wordpress.phpunit") : normalizePhpCode(phpunitRunCode({ pluginSlug, cwd: argValue(args, "cwd")?.trim() || `/wordpress/wp-content/plugins/${pluginSlug}`, autoloadFile, + autoloadFileRole, projectAutoloadFile: argValue(args, "project-autoload-file")?.trim() || "", testsDir: argValue(args, "tests-dir")?.trim() || "/wp-codebox-vendor/wp-phpunit/wp-phpunit", testRoot: argValue(args, "test-root")?.trim() || `/wordpress/wp-content/plugins/${pluginSlug}/tests`, diff --git a/tests/phpunit-project-autoload.test.ts b/tests/phpunit-project-autoload.test.ts index 428e46985..527640ced 100644 --- a/tests/phpunit-project-autoload.test.ts +++ b/tests/phpunit-project-autoload.test.ts @@ -201,10 +201,12 @@ assert.deepEqual(recipe.workflow.steps[0].args.filter((arg) => arg.includes("aut "autoload-file=/wp-codebox-vendor/autoload.php", `project-autoload-file=${woocommerceAutoload}`, ]) +assert.ok(recipe.workflow.steps[0].args.includes("autoload-file-role=harness"), "modern PHPUnit recipes explicitly preserve harness autoload intent") assert.deepEqual(rewriteInputMountPathArgs(recipe.workflow.steps[0].args, recipeInputMountPathMap(recipe)).filter((arg) => arg.includes("autoload-file=")), [ "autoload-file=/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php", `project-autoload-file=${woocommerceAutoload}`, ]) +assert.ok(rewriteInputMountPathArgs(recipe.workflow.steps[0].args, recipeInputMountPathMap(recipe)).includes("autoload-file-role=harness"), "CLI path canonicalization preserves autoload intent") assert.ok(recipe.workflow.steps[0].args.includes("cwd=/home/example/public_html")) assert.ok(recipe.workflow.steps[0].args.includes("test-root=/home/example/public_html/bin/tests/phpunit")) assert.ok(recipe.workflow.steps[0].args.includes("phpunit-xml=/home/example/public_html/bin/tests/phpunit/phpunit.xml.dist")) @@ -236,6 +238,8 @@ assert.ok(projectBootstrapIndex > bootIndex) assert.ok(projectAutoloadIndex > projectBootstrapIndex) assert.ok(projectModeCode.includes("'autoload_required' => $bootstrap_mode !== 'project' || $harness_autoload_file !== ''")) assert.ok(projectModeCode.includes("$legacy_project_autoload_file = $autoload_file")) +assert.ok(projectModeCode.includes('$autoload_file_role = "";'), "direct callers without an explicit role retain the legacy compatibility path") +assert.ok(projectModeCode.includes("if ($autoload_file_role === '' && $bootstrap_mode === 'project'")) assert.ok(projectModeCode.includes("configured PHPUnit harness autoload file is not readable")) assert.ok(projectModeCode.includes("NOTICE:project bootstrap mode continuing without configured PHPUnit harness autoload")) assert.ok(projectModeCode.includes("$test_root = \"/home/example/public_html/bin/tests/phpunit\";")) @@ -262,12 +266,47 @@ assert.ok(verifyHarnessIndex > 0, "verify_harness stage must be present") assert.ok(projectModeTestsuiteIndex > verifyHarnessIndex, "harness verification must precede TestSuite construction") assertProjectBootstrapHarnessGuard(projectModeCode) -let capturedDefaultProjectCode = "" +const canonicalHarnessProjectModeCode = phpunitRunCode({ + pluginSlug: "woocommerce", + cwd: "/wordpress/wp-content/plugins/woocommerce", + autoloadFile: "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php", + autoloadFileRole: "harness", + projectAutoloadFile: woocommerceAutoload, + testsDir: "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/wp-phpunit/wp-phpunit", + testRoot: "/home/example/public_html/bin/tests/phpunit", + phpunitXml: "/wordpress/wp-content/plugins/woocommerce/phpunit.xml.dist", + selectedTestFile: "", + changedTestFiles: [], + phpunitArgs: [], + env: {}, + wpConfigDefines: {}, + dependencyMounts: [], + bootstrapFiles: [], + bootstrapMode: "project", + projectBootstrap: "tests/legacy/bootstrap.php", + multisite: false, +}) +assert.ok(canonicalHarnessProjectModeCode.includes('$autoload_file_role = "harness";')) +assert.ok(canonicalHarnessProjectModeCode.includes('$harness_autoload_file = $legacy_project_autoload_file !== \'\' ? \'/wp-codebox-vendor/autoload.php\' : $autoload_file;')) +const canonicalHarnessResolution = canonicalHarnessProjectModeCode.match(/\$legacy_project_autoload_file = '';[\s\S]*?\$harness_autoload_file = [^;]+;/)?.[0] +assert.ok(canonicalHarnessResolution, "generated project-mode code must resolve harness autoload intent") +const canonicalHarnessProbe = join(mkdtempSync(join(tmpdir(), "wp-codebox-canonical-harness-")), "probe.php") +writeFileSync(canonicalHarnessProbe, ` { - capturedDefaultProjectCode = input.code + capturedCanonicalHarnessCode = input.code return { text: "ok", exitCode: 0 } }, server: { playground: {} } as never, @@ -276,13 +315,15 @@ await runPhpunitCommand({ args: [ "plugin-slug=ai-provider-for-openai", "bootstrap-mode=project", + "autoload-file=/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php", + "autoload-file-role=harness", "phpunit-xml=phpunit.xml.dist", "test-file=tests/unit/Models/OpenAiEmbeddingGenerationModelTest.php", ], }, }) -assert.ok(capturedDefaultProjectCode.includes('$autoload_file = "";')) -assert.ok(capturedDefaultProjectCode.includes("NOTICE:project bootstrap mode continuing without configured PHPUnit harness autoload")) +assert.ok(capturedCanonicalHarnessCode.includes('$autoload_file = "/tmp/wp-codebox-inputs/0-wp-codebox-vendor-73845ca47d2f/autoload.php";')) +assert.ok(capturedCanonicalHarnessCode.includes('$autoload_file_role = "harness";')) const coreModeCode = corePhpunitRunCode({ coreRoot: "/wordpress", diff --git a/tests/runtime-services.test.ts b/tests/runtime-services.test.ts index 840d16a71..ca5f3b409 100644 --- a/tests/runtime-services.test.ts +++ b/tests/runtime-services.test.ts @@ -1,5 +1,9 @@ import assert from "node:assert/strict" +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises" import { createServer } from "node:net" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { runRecipeBuildCommand } from "../packages/cli/src/commands/recipe-build.ts" import { parseLoopbackPort, provisionRuntimeServices, provisionRuntimeServicesForRecipe, RuntimeServiceProvisionError, runtimeServiceEvidenceFromError, runtimeServicePlan, waitForMysqlProtocol, type RuntimeServiceDependencies } from "../packages/cli/src/runtime-services.ts" import { planWorkspaceRecipe } from "../packages/cli/src/recipe-dry-run.ts" import { validateWorkspaceRecipeSemantics } from "../packages/cli/src/recipe-validation.ts" @@ -19,6 +23,22 @@ assert.equal(unsafe.valid, false) const emptyRootService = { ...service, configuration: { rootAuthentication: "empty-password" as const } } assert.equal(validateWorkspaceRecipeJsonSchema({ schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [emptyRootService] }, workflow: { steps: [{ command: "wordpress.run-php" }] } }).valid, true) assert.deepEqual(buildWordPressPhpunitRecipe({ pluginSlug: "example", services: [emptyRootService] }).inputs?.services, [emptyRootService]) +const builderDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-builder-")) +try { + const optionsPath = join(builderDirectory, "options.json") + const recipePath = join(builderDirectory, "recipe.json") + await writeFile(optionsPath, JSON.stringify({ + pluginSlug: "example", + testRoot: "/home/example/bin/tests/core", + phpunitXml: "/home/example/bin/tests/core/phpunit.xml", + })) + assert.equal(await runRecipeBuildCommand(["phpunit", "--options", optionsPath, "--output", recipePath]), 0) + const builtRecipe = JSON.parse(await readFile(recipePath, "utf8")) as WorkspaceRecipe + assert.ok(builtRecipe.workflow.steps[0].args?.includes("test-root=/home/example/bin/tests/core")) + assert.ok(builtRecipe.workflow.steps[0].args?.includes("phpunit-xml=/home/example/bin/tests/core/phpunit.xml")) +} finally { + await rm(builderDirectory, { recursive: true, force: true }) +} const recipe: WorkspaceRecipe = { schema: "wp-codebox/workspace-recipe/v1", inputs: { services: [service] }, workflow: { steps: [{ command: "wordpress.run-php", args: ["code=echo 'ok';"] }] } } assert.deepEqual(await validateWorkspaceRecipeSemantics(recipe, "recipe.json"), []) const dryRun = await planWorkspaceRecipe(recipe, process.cwd(), { recipePath: "recipe.json" }, {