Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,8 @@
"test:playground-readonly-mounts": "tsx tests/playground-readonly-mounts.test.ts",
"test:playground-readonly-mounts-integration": "tsx tests/playground-readonly-mounts-integration.test.ts",
"test:playground-phpunit-readonly-cache-integration": "tsx tests/playground-phpunit-readonly-cache.integration.test.ts",
"test:playground-phpunit-bootstrap-failure-integration": "tsx tests/playground-phpunit-bootstrap-failure.integration.test.ts",
"test:phpunit-runtime-failure-diagnostics": "tsx tests/phpunit-runtime-failure-diagnostics.test.ts",
"test:php-wasm-extension-manifests": "tsx tests/php-wasm-extension-manifests.test.ts",
"test:browser-task-builder": "tsx tests/browser-task-builder.test.ts",
"test:browser-runtime-generic-invoker": "tsx tests/browser-runtime-generic-invoker.test.ts",
Expand Down
13 changes: 12 additions & 1 deletion packages/runtime-playground/src/php-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ require_once '/wordpress/wp-load.php';
${phpBody(code)}`
}

export function bootstrapPhpCode(spec: RuntimeCreateSpec, code: string, args: string[], wpCliBridge?: PhpBootstrapBridge): string {
export function bootstrapPhpCode(spec: RuntimeCreateSpec, code: string, args: string[], wpCliBridge?: PhpBootstrapBridge, failureDiagnosticFile?: string): string {
if (argValue(args, "bootstrap") === "none") {
return code
}
Expand All @@ -29,6 +29,7 @@ export function bootstrapPhpCode(spec: RuntimeCreateSpec, code: string, args: st

return `<?php
${command.strictTypesDeclare ? `${command.strictTypesDeclare}\n` : ""}${phpFatalDiagnosticPhp()}
${failureDiagnosticFile ? phpFailureDiagnosticFilePhp(failureDiagnosticFile) : ""}
${phpCliStreamConstants()}
${pluginRuntimeBootstrapPhp(spec)}
${saveQueriesBootstrapPhp(args)}
Expand All @@ -43,6 +44,16 @@ putenv(${JSON.stringify(`WP_CODEBOX_TERMINAL_ACTION_TOKEN=${wpCliBridge.token}`)
${command.body}`
}

function phpFailureDiagnosticFilePhp(path: string): string {
return `register_shutdown_function(static function (): void {
$wp_codebox_failure = error_get_last();
if (!is_array($wp_codebox_failure) || !in_array($wp_codebox_failure['type'] ?? 0, array(E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR), true)) {
return;
}
@file_put_contents(${JSON.stringify(path)}, 'STAGE_FATAL:bootstrap:' . (string) ($wp_codebox_failure['message'] ?? '') . ' at ' . (string) ($wp_codebox_failure['file'] ?? '') . ':' . (int) ($wp_codebox_failure['line'] ?? 0) . "\\n", FILE_APPEND);
});`
}

export function splitLeadingStrictTypesDeclare(code: string): { strictTypesDeclare: string; body: string } {
const normalized = normalizePhpCode(code)
const match = normalized.match(/^<\?php\s*(declare\s*\(\s*strict_types\s*=\s*1\s*\)\s*;)\s*/i)
Expand Down
6 changes: 3 additions & 3 deletions packages/runtime-playground/src/playground-command-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,14 +396,14 @@ function playgroundOutputDiagnostic(raw: string): string {
if (fatal) {
const rawBytes = Buffer.byteLength(text, "utf8")
const decodedSuffix = decoded ? ` Decoded from serialized response bytes (${rawBytes} bytes).` : ""
return `${fatal}\n[Raw Playground output omitted from normal error output.${decodedSuffix} Inspect the thrown PlaygroundCommandError response for full diagnostics.]`
return redactDiagnosticText(`${fatal}\n[Raw Playground output omitted from normal error output.${decodedSuffix} Inspect the thrown PlaygroundCommandError response for full diagnostics.]`)
}

if (decoded) {
return `${truncateDiagnostic(decoded) ?? ""}\n[Raw serialized response bytes omitted from normal error output. Inspect the thrown PlaygroundCommandError response for full diagnostics.]`
return redactDiagnosticText(`${truncateDiagnostic(decoded) ?? ""}\n[Raw serialized response bytes omitted from normal error output. Inspect the thrown PlaygroundCommandError response for full diagnostics.]`)
}

return truncateDiagnostic(text) ?? ""
return redactDiagnosticText(truncateDiagnostic(text) ?? "")
}

function phpFatalSummary(text: string): string | undefined {
Expand Down
12 changes: 8 additions & 4 deletions packages/runtime-playground/src/wordpress-command-runners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,7 +940,7 @@ export async function runPhpunitCommand({
}
let response: PlaygroundRunResponse
try {
response = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, code, args) })
response = await runPlaygroundCommand("wordpress.phpunit", server, { code: bootstrapPhpCode(runtimeSpec, code, args, undefined, resultFile) })
} catch (error) {
await persistPluginPhpunitResult(server, resultFile, artifactRoot)
await persistVfsDiagnosticFileToHost(server, resultFile, `/wordpress/wp-content/plugins/${pluginSlug}/.pg-test-result.txt`, mounts)
Expand All @@ -953,10 +953,14 @@ export async function runPhpunitCommand({

await persistPluginPhpunitResult(server, resultFile, artifactRoot)
await persistVfsDiagnosticFileToHost(server, resultFile, `/wordpress/wp-content/plugins/${pluginSlug}/.pg-test-result.txt`, mounts)
assertPlaygroundResponseOk("wordpress.phpunit", response)
const structured = await readPluginPhpunitDiagnostic(server, resultFile)
if (structured) {
throw new Error(`wordpress.phpunit could not run: ${structured}`)
try {
assertPlaygroundResponseOk("wordpress.phpunit", response)
} catch (error) {
if (structured) {
throw attachPlaygroundDiagnostics(error, "wordpress.phpunit structured diagnostics", structured)
}
throw error
}

return response.text
Expand Down
49 changes: 49 additions & 0 deletions tests/phpunit-runtime-failure-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import assert from "node:assert/strict"
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"

const artifactRoot = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-runtime-diagnostics-"))
const secret = "sk-abcdefghijklmnopqrstuvwxyz"
let submittedCode = ""

await assert.rejects(
() => runPhpunitCommand({
artifactRoot,
mounts: [],
runPlaygroundCommand: async (_command, _server, input) => {
submittedCode = input.code
return { exitCode: 1, errors: "", text: "" }
},
runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, policy: { commands: ["wordpress.phpunit"] } } as never,
server: {
playground: {
readFileAsText: async (path: string) => {
assert.equal(path, PLUGIN_PHPUNIT_RESULT_FILE)
return `STAGE_FATAL:bootstrap:Bootstrap failed with token: ${secret} ${"x".repeat(25_000)}`
},
},
} as never,
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 structured diagnostics/)
assert.match(error.message, /Bootstrap failed with token: \[redacted\]/)
assert.match(error.message, /\[diagnostic truncated\]/)
assert.doesNotMatch(error.message, new RegExp(secret))
return true
},
)

const captured = await readFile(join(artifactRoot, "files", "phpunit", ".pg-test-result.txt"), "utf8")
assert.match(captured, /Bootstrap failed with token: \[redacted\]/)
assert.doesNotMatch(captured, new RegExp(secret))

const preBootstrapRecorder = submittedCode.indexOf("STAGE_FATAL:bootstrap:")
const wordpressBootstrap = submittedCode.indexOf("require_once '/wordpress/wp-load.php';")
assert.ok(preBootstrapRecorder >= 0 && preBootstrapRecorder < wordpressBootstrap, "fatal diagnostics must be recorded before the WordPress bootstrap boundary")

console.log("phpunit runtime failure diagnostics ok")
83 changes: 83 additions & 0 deletions tests/playground-phpunit-bootstrap-failure.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import assert from "node:assert/strict"
import { execFile } from "node:child_process"
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"
import { tmpdir } from "node:os"
import { join } from "node:path"
import { promisify } from "node:util"

const execFileAsync = promisify(execFile)
const secret = "sk-abcdefghijklmnopqrstuvwxyz"
const root = await mkdtemp(join(tmpdir(), "wp-codebox-phpunit-bootstrap-failure-"))
const fatalMuPlugin = join(root, "fatal-bootstrap.php")
const recipePath = join(root, "recipe.json")
const artifactsPath = join(root, "artifacts")

try {
await writeFile(fatalMuPlugin, `<?php\ntrigger_error('PHPUnit bootstrap fixture token: ${secret}', E_USER_ERROR);\n`)
await writeFile(recipePath, `${JSON.stringify({
schema: "wp-codebox/workspace-recipe/v1",
runtime: { backend: "wordpress-playground", wp: "6.5", blueprint: { steps: [] } },
inputs: {
mounts: [{
source: fatalMuPlugin,
target: "/wordpress/wp-content/mu-plugins/wp-codebox-bootstrap-failure.php",
mode: "readonly",
}],
},
workflow: {
steps: [{ command: "wordpress.phpunit", args: ["plugin-slug=bootstrap-failure-fixture"] }],
},
})}\n`)

const output = await runFailedRecipe()
assert.equal(output.success, false, JSON.stringify(output))
const message = output.error?.message ?? ""
assert.match(message, /wordpress\.phpunit crashed before producing a structured response/)
assert.match(message, /failureClassification=runtime-worker-failure/)
assert.match(message, /wordpress\.phpunit structured diagnostics/)
assert.match(message, /PHPUnit bootstrap fixture token: \[redacted\]/)
assert.doesNotMatch(message, new RegExp(secret))

const latest = JSON.parse(await readFile(join(artifactsPath, "latest-runtime.json"), "utf8")) as { paths?: { runtimeDirectory?: string } }
const runtimeDirectory = latest.paths?.runtimeDirectory
assert.ok(runtimeDirectory, "failed recipe must retain a runtime artifact directory")
const artifactDirectory = join(artifactsPath, runtimeDirectory)
const diagnostic = await readFile(join(artifactDirectory, "files", "phpunit", ".pg-test-result.txt"), "utf8")
const commandLog = await readFile(join(artifactDirectory, "logs", "commands.log"), "utf8")
assert.match(diagnostic, /STAGE_FATAL:bootstrap:PHPUnit bootstrap fixture token: \[redacted\]/)
assert.doesNotMatch(diagnostic, new RegExp(secret))
assert.match(commandLog, /wordpress\.phpunit structured diagnostics/)
assert.match(commandLog, /PHPUnit bootstrap fixture token: \[redacted\]/)
assert.doesNotMatch(commandLog, new RegExp(secret))
} finally {
await rm(root, { recursive: true, force: true })
}

async function runFailedRecipe(): Promise<RecipeRunOutput> {
try {
const result = await execFileAsync(process.execPath, ["packages/cli/dist/index.js", "recipe-run", "--recipe", recipePath, "--artifacts", artifactsPath, "--json"], {
cwd: process.cwd(),
timeout: 300_000,
maxBuffer: 2 * 1024 * 1024,
})
return recipeRunOutput(result.stdout)
} catch (error) {
const output = recipeRunOutput(error && typeof error === "object" && "stdout" in error ? error.stdout : undefined)
if (output) {
return output
}
throw error
}
}

interface RecipeRunOutput {
success?: boolean
error?: { message?: string }
}

function recipeRunOutput(value: unknown): RecipeRunOutput {
assert.equal(typeof value, "string", "recipe-run must return JSON output")
return JSON.parse(value) as RecipeRunOutput
}

console.log("playground phpunit bootstrap failure integration ok")
Loading