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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,7 @@
"test:wordpress-hotspots-contracts": "tsx tests/wordpress-hotspots-contracts.test.ts",
"test:cache-churn-observation-contracts": "tsx tests/cache-churn-observation-contracts.test.ts",
"test:wordpress-db-contracts": "tsx tests/wordpress-db-contracts.test.ts",
"test:wp-cli-temporary-script": "tsx tests/wp-cli-temporary-script.test.ts",
"test:browser-sdk-facade": "tsx tests/browser-sdk-facade.test.ts",
"check": "npm run test:production-boundary-enforcement && npm run smoke -- --group=check"
},
Expand Down
35 changes: 14 additions & 21 deletions packages/runtime-playground/src/playground-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { browserWordPressDiagnosticProvider, isBrowserCommandArtifactError, runB
import type { PluginCheckArtifact, ThemeCheckArtifact } from "./check-artifacts.js"
import { executePlaygroundCommand } from "./command-router.js"
import { firstCommandWordPressAdminAuthRequirement } from "./command-auth-requirements.js"
import { argValue, cleanWpCliOutput, shellArgv, wordpressBlockExerciseInputFromArgs, wordpressBlockExercisePhpCode, wordpressCrudOperationFromArgs, wordpressCrudOperationPhpCode, wordpressDbOperationFromArgs, wordpressDbOperationPhpCode, wpCliCommandFromArgs, wpCliPhpScript } from "./commands.js"
import { argValue, cleanWpCliOutput, runWithTemporaryWpCliScript, shellArgv, wordpressBlockExerciseInputFromArgs, wordpressBlockExercisePhpCode, wordpressCrudOperationFromArgs, wordpressCrudOperationPhpCode, wordpressDbOperationFromArgs, wordpressDbOperationPhpCode, wpCliCommandFromArgs } from "./commands.js"
import { bootstrapPhpCode } from "./php-bootstrap.js"
import { observeHttpResponse as observeHttpResponseArtifact, observeWordPressState as observeWordPressStateArtifact } from "./observation-artifacts.js"
import { PlaygroundCommandCrashError, assertPlaygroundResponseOk, errorMessage, type PlaygroundRunResponse } from "./playground-command-errors.js"
Expand Down Expand Up @@ -1054,13 +1054,7 @@ class PlaygroundRuntime implements Runtime {
throw new Error("wordpress.wp-cli requires a non-empty command")
}

if (!server.playground.writeFile) {
throw new Error("wordpress.wp-cli requires a Playground backend with writeFile support")
}

const scriptPath = `/tmp/wp-codebox-wp-cli-${this.commands.length}.php`
await server.playground.writeFile(scriptPath, wpCliPhpScript(argv))
const response = await this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath })
const response = await this.runWpCliCommand(server, argv)
assertPlaygroundResponseOk("wordpress.wp-cli", response)

return cleanWpCliOutput(response.text)
Expand Down Expand Up @@ -1471,13 +1465,18 @@ class PlaygroundRuntime implements Runtime {
}

private async runWpCliCommand(server: PlaygroundCliServer, argv: string[]): Promise<PlaygroundRunResponse> {
if (!server.playground.writeFile) {
throw new Error("WP-CLI commands require a Playground backend with writeFile support")
if (!server.playground.writeFile || !server.playground.unlink) {
throw new Error("WP-CLI commands require a Playground backend with writeFile and unlink support")
}

const scriptPath = `/tmp/wp-codebox-wp-cli-${this.commands.length}-${Date.now().toString(36)}.php`
await server.playground.writeFile(scriptPath, wpCliPhpScript(argv))
return this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath })
return runWithTemporaryWpCliScript(
{
writeFile: (path, contents) => server.playground.writeFile!(path, contents),
unlink: (path) => server.playground.unlink!(path),
},
this.runtimeId,
argv,
(scriptPath) => this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath }),
)
}

private async createRuntimeWpCliBridge(server: PlaygroundCliServer): Promise<RuntimeWpCliBridge> {
Expand Down Expand Up @@ -1758,13 +1757,7 @@ class PlaygroundRuntime implements Runtime {
}

private async runWpCliArgv(server: PlaygroundCliServer, argv: string[]): Promise<PlaygroundRunResponse> {
if (!server.playground.writeFile) {
throw new Error("WP-CLI commands require a Playground backend with writeFile support")
}

const scriptPath = `/tmp/wp-codebox-wp-cli-${this.commands.length}-${Date.now().toString(36)}.php`
await server.playground.writeFile(scriptPath, wpCliPhpScript(argv))
return this.runPlaygroundCommand("wordpress.wp-cli", server, { scriptPath })
return this.runWpCliCommand(server, argv)
}

private async runPlaygroundCommand(command: string, server: PlaygroundCliServer, options: { code: string } | { scriptPath: string }): Promise<PlaygroundRunResponse> {
Expand Down
1 change: 1 addition & 0 deletions packages/runtime-playground/src/preview-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface PlaygroundCliServer {
run(options: { code: string } | { scriptPath: string }): Promise<PlaygroundServerRunResponse>
onMessage?(listener: (data: string) => Promise<string | void> | string | void): Promise<(() => Promise<void> | void) | void> | (() => Promise<void> | void) | void
readFileAsText?(path: string): string | Promise<string>
unlink?(path: string): Promise<void> | void
writeFile?(path: string, contents: string): Promise<void>
}
serverUrl: string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ interface ProgrammaticPHP {
onMessage(listener: (data: string) => Promise<string | void> | string | void): () => Promise<void>
readFileAsText(path: string): string
run(options: { code: string } | { scriptPath: string }): Promise<ProgrammaticPHPResponse>
unlink(path: string): void
writeFile(path: string, contents: string): void
}

Expand Down Expand Up @@ -91,6 +92,9 @@ export async function startProgrammaticPlaygroundServer(spec: RuntimeCreateSpec,
run: (runOptions) => runPhp(primaryPhp, runOptions),
onMessage: (listener) => primaryPhp.onMessage(listener),
readFileAsText: (path) => primaryPhp.readFileAsText(path),
unlink(path) {
primaryPhp.unlink(path)
},
async writeFile(path, contents) {
primaryPhp.writeFile(path, contents)
},
Expand Down
17 changes: 17 additions & 0 deletions packages/runtime-playground/src/wp-cli-command-handlers.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { randomBytes } from "node:crypto"
import { argValue } from "./command-args.js"
import { phpCliStreamConstants } from "./php-snippets.js"

interface WpCliTemporaryScriptFilesystem {
writeFile(path: string, contents: string): Promise<void>
unlink(path: string): Promise<void> | void
}

export function wpCliCommandFromArgs(args: string[]): string {
const explicit = argValue(args, "command")
if (explicit) {
Expand Down Expand Up @@ -58,6 +64,17 @@ require '/tmp/wp-cli.phar';
`
}

export async function runWithTemporaryWpCliScript<T>(filesystem: WpCliTemporaryScriptFilesystem, runtimeId: string, argv: string[], run: (scriptPath: string) => Promise<T>): Promise<T> {
const runtimeNamespace = runtimeId.replace(/[^A-Za-z0-9_-]/g, "-").slice(0, 80) || "runtime"
const scriptPath = `/tmp/wp-codebox-wp-cli-${runtimeNamespace}-${randomBytes(16).toString("hex")}.php`
await filesystem.writeFile(scriptPath, wpCliPhpScript(argv))
try {
return await run(scriptPath)
} finally {
await Promise.resolve(filesystem.unlink(scriptPath)).catch(() => undefined)
}
}

export function cleanWpCliOutput(output: string): string {
return output.replace(/^#!\/usr\/bin\/env php\r?\n/, "")
}
1 change: 1 addition & 0 deletions scripts/smoke-manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export const smokeGroups = {
tsxSmoke("replay-export-snapshot-scoping-smoke"),
tsxSmoke("runtime-overlay-validation-smoke"),
npmScript("test:runtime-php-snippets"),
npmScript("test:wp-cli-temporary-script"),
npmScript("test:php-runtime-provider-registry"),
tsxSmoke("composer-backed-source-hydration-smoke"),
tsxSmoke("composer-package-overlay-autoload-layout-smoke"),
Expand Down
106 changes: 106 additions & 0 deletions tests/wp-cli-temporary-script.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import assert from "node:assert/strict"
import { runWithTemporaryWpCliScript, shellArgv } from "../packages/runtime-playground/dist/wp-cli-command-handlers.js"

class MemoryFilesystem {
readonly files = new Map<string, string>()
readonly writtenPaths: string[] = []
readonly unlinkedPaths: string[] = []

async writeFile(path: string, contents: string): Promise<void> {
if (this.files.has(path)) {
throw new Error(`Could not write to ${JSON.stringify(path)}: File exists.`)
}
this.files.set(path, contents)
this.writtenPaths.push(path)
}

unlink(path: string): void {
this.files.delete(path)
this.unlinkedPaths.push(path)
}
}

const oldPaths = Array.from({ length: 16 }, () => "/tmp/wp-codebox-wp-cli-2.php")
assert.equal(new Set(oldPaths).size, 1, "the old command-count suffix reproduces the shared wrapper path")

const filesystem = new MemoryFilesystem()
let invocationCount = 0
for (const { concurrency, iterations, repetitions } of [
{ concurrency: 2, iterations: 128, repetitions: 3 },
{ concurrency: 6, iterations: 256, repetitions: 3 },
{ concurrency: 16, iterations: 512, repetitions: 2 },
{ concurrency: 64, iterations: 1_024, repetitions: 1 },
]) {
for (let repetition = 0; repetition < repetitions; repetition++) {
const markers = Array.from({ length: iterations }, (_, index) => `marker-${concurrency}-${repetition}-${index.toString().padStart(6, "0")}`)
const outputs = await runBounded(markers, concurrency, async (marker, index) => {
invocationCount++
return runWithTemporaryWpCliScript(
filesystem,
"runtime/shared/../../unsafe",
["eval", `echo ${JSON.stringify(marker)};`],
async (scriptPath) => {
await new Promise((resolve) => setTimeout(resolve, index % 5))
const script = filesystem.files.get(scriptPath)
assert.ok(script, `wrapper must exist while ${marker} executes`)
assert.match(scriptPath, /^\/tmp\/wp-codebox-wp-cli-runtime-shared-------unsafe-[a-f0-9]{32}\.php$/)
assert.ok(script.includes(marker), `${marker} must execute only its own payload`)
const otherMarker = markers[(index + 1) % markers.length]
assert.equal(script.includes(otherMarker), false, `${marker} must not execute ${otherMarker}`)
return marker
},
)
})
assert.deepEqual(outputs, markers)
assert.equal(filesystem.files.size, 0, `concurrency ${concurrency} must return the temp directory to baseline`)
}
}

assert.equal(new Set(filesystem.writtenPaths).size, invocationCount)
assert.deepEqual(new Set(filesystem.unlinkedPaths), new Set(filesystem.writtenPaths))
assert.throws(() => shellArgv("eval 'unterminated"), /Unclosed quote/, "malformed WP-CLI input must fail before allocating a wrapper")

const nonzero = await runWithTemporaryWpCliScript(filesystem, "runtime-nonzero", ["eval", "exit(7)"], async () => ({ exitCode: 7, text: "", errors: "intentional" }))
assert.deepEqual(nonzero, { exitCode: 7, text: "", errors: "intentional" }, "structured nonzero responses must remain compatible")
assert.equal(filesystem.files.size, 0, "structured nonzero responses must clean up their wrapper")

for (const failure of [
new Error("wrapper execution failure"),
new Error("runtime command exceeded timeout"),
new Error("runtime execution was aborted"),
new Error("adapter exception"),
]) {
await assert.rejects(
runWithTemporaryWpCliScript(filesystem, "runtime-failures", ["eval", "broken"], async () => { throw failure }),
failure,
)
assert.equal(filesystem.files.size, 0, `${failure.message} must clean up its owned wrapper`)
}

let unlinkedAfterRejectedWrite = false
await assert.rejects(
runWithTemporaryWpCliScript({
async writeFile() {
throw new Error("File exists")
},
unlink() {
unlinkedAfterRejectedWrite = true
},
}, "runtime-write-failure", ["version"], async () => "unreachable"),
/File exists/,
)
assert.equal(unlinkedAfterRejectedWrite, false, "a rejected write must not claim or remove another invocation's file")

console.log(`WP-CLI temporary wrapper stress ok: ${invocationCount} isolated invocations at concurrency 2, 6, 16, and 64`)

async function runBounded<T, R>(items: T[], concurrency: number, operation: (item: T, index: number) => Promise<R>): Promise<R[]> {
const outputs = new Array<R>(items.length)
let nextIndex = 0
await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, async () => {
while (nextIndex < items.length) {
const index = nextIndex++
outputs[index] = await operation(items[index], index)
}
}))
return outputs
}
Loading