diff --git a/package.json b/package.json
index 8720cbdf7..be0f4c7cc 100644
--- a/package.json
+++ b/package.json
@@ -106,6 +106,7 @@
"test:runtime-sources-playground-integration": "tsx tests/runtime-sources-playground-integration.test.ts",
"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-staged-upload-preview": "tsx tests/playground-staged-upload-preview.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:playground-custom-archive-cache": "tsx tests/playground-custom-archive-cache.test.ts && tsx tests/playground-custom-archive-cache-process.test.ts && tsx tests/playground-custom-archive-cache.integration.test.ts",
diff --git a/packages/runtime-playground/src/programmatic-playground-runner.ts b/packages/runtime-playground/src/programmatic-playground-runner.ts
index 3d80cdbb7..0c5a80a5d 100644
--- a/packages/runtime-playground/src/programmatic-playground-runner.ts
+++ b/packages/runtime-playground/src/programmatic-playground-runner.ts
@@ -50,6 +50,7 @@ export async function startProgrammaticPlaygroundServer(spec: RuntimeCreateSpec,
}
const requestHandler = await bootWordPressAndRequestHandler({
createPhpRuntime: () => loadNodeRuntime(phpVersion, programmaticNodeRuntimeOptions(spec, nextProcessId++)),
+ maxPhpInstances: 1,
phpVersion,
siteUrl: spec.preview?.siteUrl ?? "http://127.0.0.1",
documentRoot: "/wordpress",
diff --git a/tests/playground-staged-upload-preview.integration.test.ts b/tests/playground-staged-upload-preview.integration.test.ts
new file mode 100644
index 000000000..5698b9ba0
--- /dev/null
+++ b/tests/playground-staged-upload-preview.integration.test.ts
@@ -0,0 +1,138 @@
+import assert from "node:assert/strict"
+import { execFile } from "node:child_process"
+import { mkdtemp, rm, writeFile } from "node:fs/promises"
+import { tmpdir } from "node:os"
+import { join } from "node:path"
+import { promisify } from "node:util"
+
+import { PNG } from "pngjs"
+
+const execFileAsync = promisify(execFile)
+const root = await mkdtemp(join(tmpdir(), "wp-codebox-staged-upload-preview-"))
+const imageSource = join(root, "staged.png")
+const htmlSource = join(root, "index.html")
+const recipePath = join(root, "recipe.json")
+const artifactsPath = join(root, "artifacts")
+const uploadPath = "/wp-content/uploads/wp-codebox-staged-image/index.html"
+
+try {
+ const png = new PNG({ width: 2, height: 2 })
+ for (let offset = 0; offset < png.data.length; offset += 4) {
+ png.data.set([17, 34, 51, 255], offset)
+ }
+ await writeFile(imageSource, PNG.sync.write(png))
+ await writeFile(htmlSource, '
\n')
+ await writeFile(recipePath, `${JSON.stringify({
+ schema: "wp-codebox/workspace-recipe/v1",
+ runtime: { backend: "wordpress-playground", wp: "6.5", blueprint: { steps: [] } },
+ inputs: {
+ stagedFiles: [
+ { source: imageSource, target: "/wordpress/wp-content/uploads/wp-codebox-staged-image/staged.png" },
+ { source: htmlSource, target: "/wordpress/wp-content/uploads/wp-codebox-staged-image/index.html" },
+ ],
+ },
+ workflow: {
+ steps: [{
+ command: "wordpress.browser-probe",
+ args: [
+ `url=${uploadPath}`,
+ "wait-for=load",
+ "capture=html,screenshot",
+ `script=${browserPaintScript()}`,
+ ],
+ }],
+ },
+ })}\n`)
+
+ const output = await runRecipe()
+ if (output) {
+ assert.equal(output.success, true, JSON.stringify(output))
+ const execution = output.executions?.find((entry) => entry.command === "wordpress.browser-probe")
+ assert.ok(execution?.stdout, "browser probe output should be retained")
+ const probe = JSON.parse(execution.stdout) as BrowserProbeOutput
+ assert.match(probe.finalUrl ?? "", /\/wp-content\/uploads\/wp-codebox-staged-image\/index\.html$/)
+ assert.deepEqual(probe.summary?.scriptResult, {
+ complete: true,
+ naturalWidth: 2,
+ naturalHeight: 2,
+ width: 2,
+ height: 2,
+ pixel: [17, 34, 51, 255],
+ })
+ assert.ok(probe.files?.html, "browser probe should capture rendered HTML")
+ assert.ok(probe.files?.screenshot, "browser probe should capture painted pixels")
+ }
+} finally {
+ await rm(root, { recursive: true, force: true })
+}
+
+function browserPaintScript(): string {
+ return `
+const image = document.querySelector("#staged-image");
+if (!(image instanceof HTMLImageElement)) throw new Error("staged image element missing");
+await image.decode();
+const rect = image.getBoundingClientRect();
+const canvas = document.createElement("canvas");
+canvas.width = 1;
+canvas.height = 1;
+const context = canvas.getContext("2d");
+if (!context) throw new Error("2d canvas unavailable");
+context.drawImage(image, 0, 0);
+return {
+ complete: image.complete,
+ naturalWidth: image.naturalWidth,
+ naturalHeight: image.naturalHeight,
+ width: rect.width,
+ height: rect.height,
+ pixel: [...context.getImageData(0, 0, 1, 1).data],
+};`
+}
+
+async function runRecipe(): Promise {
+ 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 JSON.parse(result.stdout) as RecipeRunOutput
+ } catch (error) {
+ if (isUnavailableWordPressRuntimeSource(error)) {
+ console.log("playground staged upload preview integration skipped: WordPress runtime source was unavailable before runtime startup")
+ return undefined
+ }
+ throw error
+ }
+}
+
+function isUnavailableWordPressRuntimeSource(error: unknown): boolean {
+ const output = recipeRunOutput(error && typeof error === "object" && "stdout" in error ? error.stdout : undefined)
+ const startup = output?.phaseEvidence?.find((phase) => phase.name === "runtime_startup")
+ const message = startup?.error?.message ?? ""
+ return output?.schema === "wp-codebox/recipe-run/v1"
+ && startup?.status === "failed"
+ && /Unable to resolve Playground startup asset (wordpress-archive-cache|wordpress-release-metadata)/.test(message)
+ && /fetch failed|Could not resolve host|Connection timed out|network is unreachable/i.test(message)
+}
+
+function recipeRunOutput(value: unknown): RecipeRunOutput | undefined {
+ if (typeof value !== "string") return undefined
+ try {
+ return JSON.parse(value) as RecipeRunOutput
+ } catch {
+ return undefined
+ }
+}
+
+interface RecipeRunOutput {
+ schema?: string
+ success?: boolean
+ executions?: Array<{ command?: string; stdout?: string }>
+ phaseEvidence?: Array<{ name?: string; status?: string; error?: { message?: string } }>
+}
+
+interface BrowserProbeOutput {
+ finalUrl?: string
+ files?: { html?: string; screenshot?: string }
+ summary?: { scriptResult?: unknown }
+}