diff --git a/README.md b/README.md index 7df7d4e6..1a863303 100644 --- a/README.md +++ b/README.md @@ -697,6 +697,24 @@ npm run wp-codebox -- schema recipe --json The schema covers `runtime`, `inputs.mounts`, `inputs.workspaces`, `inputs.extra_plugins`, `inputs.secretEnv`, `workflow.steps`, and `artifacts`. +### `wordpress-source` + +Materialize WordPress core source files on the host without booting a site. Tools that load core directly (for example PHP smoke tests that require `wp-includes/html-api/*`) get the same WordPress WP Codebox boots in Playground. + +```bash +npm run wp-codebox -- wordpress-source --wp 7.1.2 --json +``` + +`--wp` accepts an exact version such as `7.1.2` or `latest` (the default). The query resolves through the same release resolver and validated archive cache Playground startup uses (`${version}.zip` in `WP_CODEBOX_PLAYGROUND_WORDPRESS_CACHE_DIR`, default `~/.wordpress-playground`). The archive is extracted once per version to `/source//wordpress`: extraction is staged in a sibling directory, verified against `wp-includes/version.php` (`$wp_version` must equal the resolved version), and renamed into place, so concurrent callers never observe a partial tree. Later calls reuse the verified tree. + +Without `--json`, stdout is the source root path only. With `--json`, stdout is exactly one object: + +```json +{"schema":"wp-codebox/wordpress-source/v1","version":"7.1.2","root":"/home/me/.wordpress-playground/source/7.1.2/wordpress","archive":"/home/me/.wordpress-playground/7.1.2.zip","sourceUrl":"https://wordpress.org/wordpress-7.1.2.zip"} +``` + +Diagnostics go to stderr. Failures (offline, unknown version, invalid archive) exit non-zero with a message on stderr and no stdout. + ### `run` Run one command in a disposable runtime. diff --git a/npm-shrinkwrap.json b/npm-shrinkwrap.json index cf985c18..750fefc1 100644 --- a/npm-shrinkwrap.json +++ b/npm-shrinkwrap.json @@ -4806,6 +4806,7 @@ "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", "@php-wasm/node": "3.1.46", + "@php-wasm/stream-compression": "3.1.46", "@php-wasm/universal": "3.1.46", "@types/pngjs": "^6.0.5", "@wp-playground/blueprints": "3.1.46", diff --git a/packages/cli/src/cli-entry.ts b/packages/cli/src/cli-entry.ts index ee8ebde9..c74ca482 100644 --- a/packages/cli/src/cli-entry.ts +++ b/packages/cli/src/cli-entry.ts @@ -11,6 +11,7 @@ import { runMcpRenderClientConfigsCommand } from "./commands/mcp.js" import { runPreviewLeaseReleaseCommand, runPreviewLeaseStatusCommand } from "./commands/preview-lease.js" import { runBootCommand, runRunCommand, runValidateBlueprintCommand } from "./commands/runtime.js" import { runFuzzDescriptorCommand, runFuzzMinimizeCaseCommand, runFuzzReadinessCommand, runFuzzSuiteCommand, runWordPressWorkloadCommand } from "./commands/wordpress-runtime.js" +import { runWordPressSourceCommand } from "./commands/wordpress-source.js" import { runRunsArtifactsCommand, runRunsCancelCommand, runRunsStatusCommand } from "./commands/runs.js" import { runTargetProvisionCommand } from "./commands/target.js" import { runWorkspacePolicyCheckCommand } from "./commands/workspace-policy.js" @@ -28,6 +29,7 @@ export async function runCli(args: string[]): Promise { runFuzzSuite: runFuzzSuiteCommand, fuzzMinimizeCase: runFuzzMinimizeCaseCommand, runWordPressWorkload: runWordPressWorkloadCommand, + wordpressSource: runWordPressSourceCommand, fuzzDescriptor: runFuzzDescriptorCommand, fuzzReadiness: runFuzzReadinessCommand, adversarialRun: runAdversarialRecipeCommand, diff --git a/packages/cli/src/command-router.ts b/packages/cli/src/command-router.ts index 8bd8b61c..a0a431f3 100644 --- a/packages/cli/src/command-router.ts +++ b/packages/cli/src/command-router.ts @@ -28,6 +28,7 @@ const cliCommandRoutes = { "run-fuzz-suite": "runFuzzSuite", "fuzz-minimize-case": "fuzzMinimizeCase", "run-wordpress-workload": "runWordPressWorkload", + "wordpress-source": "wordpressSource", fuzz: { descriptor: "fuzzDescriptor", readiness: "fuzzReadiness", diff --git a/packages/cli/src/commands/wordpress-source.ts b/packages/cli/src/commands/wordpress-source.ts new file mode 100644 index 00000000..86c9eff5 --- /dev/null +++ b/packages/cli/src/commands/wordpress-source.ts @@ -0,0 +1,64 @@ +import { materializePlaygroundWordPressSource, type PlaygroundWordPressSource, type PlaygroundWordPressSourceOptions } from "@automattic/wp-codebox-playground" + +interface WordPressSourceCommandOptions { + wp?: string + json: boolean +} + +type WordPressSourceMaterializer = (versionQuery: string | undefined, options: PlaygroundWordPressSourceOptions) => Promise + +/** + * `wp-codebox wordpress-source [--wp ] [--json]` + * + * Prints the host path of WordPress core source extracted from the validated + * Playground archive cache. stdout carries only the result (root path, or one + * JSON object with --json); diagnostics and failures go to stderr. + */ +export async function runWordPressSourceCommand(args: string[], materialize: WordPressSourceMaterializer = materializePlaygroundWordPressSource): Promise { + let options: WordPressSourceCommandOptions + try { + options = parseWordPressSourceOptions(args) + } catch (error) { + process.stderr.write(`wp-codebox wordpress-source: ${errorMessage(error)}\n`) + return 2 + } + + let source: PlaygroundWordPressSource + try { + source = await materialize(options.wp, { log: (message) => process.stderr.write(`[wp-codebox] ${message}\n`) }) + } catch (error) { + process.stderr.write(`wp-codebox wordpress-source: failed to materialize WordPress ${options.wp ?? "latest"} source: ${errorMessage(error)}\n`) + return 1 + } + + const output = options.json + ? JSON.stringify({ schema: source.schema, version: source.version, root: source.root, archive: source.archive, sourceUrl: source.sourceUrl }) + : source.root + process.stdout.write(`${output}\n`) + return 0 +} + +function parseWordPressSourceOptions(args: string[]): WordPressSourceCommandOptions { + const options: WordPressSourceCommandOptions = { json: false } + for (let index = 0; index < args.length; index++) { + const arg = args[index] + if (arg === "--json") { + options.json = true + continue + } + if (arg === "--wp" || arg.startsWith("--wp=")) { + const value = arg === "--wp" ? args[++index] : arg.slice("--wp=".length) + if (!value || value.startsWith("--")) { + throw new Error("--wp requires a version query such as 7.1.2 or latest") + } + options.wp = value === "latest" ? undefined : value + continue + } + throw new Error(`Unknown option: ${arg}`) + } + return options +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/cli/src/output.ts b/packages/cli/src/output.ts index 374988e8..5f0e5ea4 100644 --- a/packages/cli/src/output.ts +++ b/packages/cli/src/output.ts @@ -561,6 +561,7 @@ export function printHelp(): void { wp-codebox adversarial replay --recipe --replay [recipe-run options] wp-codebox fuzz-minimize-case --input-file [--format=json] [--dry-run] wp-codebox run-wordpress-workload --input-file [--format=json] [--dry-run] + wp-codebox wordpress-source [--wp ] [--json] wp-codebox run-agent-task --input-file [--json] [--preview-hold-seconds ] [--preview-hold-blocking] [--preview-port ] [--preview-bind ] [--preview-public-url ] [--preview-lease-json ] wp-codebox agent-task-run --input-file [--json] [--result-file ] [--preview-hold-seconds ] [--preview-hold-blocking] [--preview-port ] [--preview-bind ] [--preview-public-url ] [--preview-lease-json ] wp-codebox validate-blueprint --blueprint [options] @@ -675,6 +676,13 @@ Doctor and cleanup: --run-registry Run registry checked for active preview leases and recent run heartbeats. Repeatable. Defaults to ./artifacts/runs; additional defaults may be set with WP_CODEBOX_RUN_REGISTRY_ROOTS. +WordPress source: + wordpress-source Resolve --wp (exact version or latest; default latest) through the Playground + archive cache and print the host directory containing WordPress core source + (wp-includes/version.php). Extracted once per version to + /source//wordpress. --json prints one + wp-codebox/wordpress-source/v1 object; diagnostics go to stderr. + Workspace policy: --workspace-root Workspace root to inspect. Defaults to the current directory. diff --git a/packages/runtime-playground/package.json b/packages/runtime-playground/package.json index 15e88011..91c69ea5 100644 --- a/packages/runtime-playground/package.json +++ b/packages/runtime-playground/package.json @@ -23,6 +23,7 @@ "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", "@php-wasm/node": "3.1.46", + "@php-wasm/stream-compression": "3.1.46", "@php-wasm/universal": "3.1.46", "@wp-playground/blueprints": "3.1.46", "@wp-playground/cli": "3.1.46", diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 429eabb3..07e4189d 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -13,6 +13,7 @@ export { browserStorageStateCookieHostSummary, browserStorageStateFromWordPressA export { createHostCommandTool, type HostCommandToolConfig } from "./host-command-tool.js" export { PlaygroundRuntimeBackend, createPlaygroundRuntimeBackend, playgroundRuntimeBackendProvider } from "./playground-runtime.js" export { maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, type PlaygroundCustomArchiveCacheMaintenance, type PlaygroundCustomArchiveCacheMaintenanceOptions, type PlaygroundCustomArchiveCachePolicy } from "./playground-wordpress-archive-cache.js" +export { WORDPRESS_SOURCE_SCHEMA, materializePlaygroundWordPressSource, playgroundWordPressSourceDirectory, type PlaygroundWordPressSource, type PlaygroundWordPressSourceOptions } from "./playground-wordpress-source.js" export { collectBrowserArtifactMetrics, collectWordPressEpisodeArtifacts, collectWordPressRuntimeArtifacts, createWordPressEpisode, createWordPressRuntime, runWordPressEpisodeActions, type WordPressEpisodeSpec, type WordPressRuntimeActionHooks, type WordPressRuntimeSpec } from "./public.js" export { preflightPhpWasmRuntimeAssets, assertPhpWasmExtensionAbi, phpWasmExtensionMissingAbiSymbols, PhpWasmRuntimeAssetIntegrityError, PhpWasmExtensionAbiError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.js" export { assertPlaywrightBrowserReady, playwrightBrowserProvenance, playwrightBrowserReadiness, type PlaywrightBrowserProvenance, type PlaywrightBrowserReadiness } from "./playwright-browser-provenance.js" diff --git a/packages/runtime-playground/src/playground-wordpress-source.ts b/packages/runtime-playground/src/playground-wordpress-source.ts new file mode 100644 index 00000000..a8de78ae --- /dev/null +++ b/packages/runtime-playground/src/playground-wordpress-source.ts @@ -0,0 +1,187 @@ +import { randomUUID } from "node:crypto" +import { existsSync, openAsBlob } from "node:fs" +import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises" +import { dirname, isAbsolute, join, resolve, sep } from "node:path" +import { decodeZip } from "@php-wasm/stream-compression" +import { resolvePlaygroundWordPressStartupAsset } from "./playground-cli-runner.js" +import { playgroundWordPressArchiveCacheDirectory } from "./playground-wordpress-archive-cache.js" + +export const WORDPRESS_SOURCE_SCHEMA = "wp-codebox/wordpress-source/v1" + +/** + * WordPress core source materialized on the host from the same validated + * archive cache WP Codebox uses to boot Playground. + */ +export interface PlaygroundWordPressSource { + schema: typeof WORDPRESS_SOURCE_SCHEMA + version: string + /** Absolute directory containing `wp-includes/version.php`. */ + root: string + /** Absolute path of the validated cached `${version}.zip` archive. */ + archive: string + sourceUrl: string +} + +export interface PlaygroundWordPressSourceOptions { + cacheDirectory?: string + /** Receives human-readable progress diagnostics. */ + log?: (message: string) => void +} + +/** + * Resolves a WordPress version query, ensures the validated cached archive, + * and extracts it once to `/source//wordpress`. + * + * Extraction is staged in a sibling directory, verified against + * `wp-includes/version.php`, and renamed into place, so concurrent callers + * only ever observe a complete, verified tree. + */ +export async function materializePlaygroundWordPressSource(versionQuery: string | undefined, options: PlaygroundWordPressSourceOptions = {}): Promise { + const cacheDirectory = resolve(options.cacheDirectory ?? playgroundWordPressArchiveCacheDirectory()) + const log = options.log ?? (() => undefined) + assertReleaseVersionQuery(versionQuery) + + const asset = await resolvePlaygroundWordPressStartupAsset(versionQuery, undefined, cacheDirectory) + await asset.archiveReference?.release() + const version = asset.cacheValidation.version + const archive = asset.localPath + if (!archive) { + throw new Error(`WordPress ${version} archive was not cached locally from ${asset.cacheValidation.sourceUrl}`) + } + + const source = { schema: WORDPRESS_SOURCE_SCHEMA, version, archive: resolve(archive), sourceUrl: asset.cacheValidation.sourceUrl } as const + const versionDirectory = playgroundWordPressSourceDirectory(cacheDirectory, version) + const root = join(versionDirectory, "wordpress") + + if (await verifiedWordPressRootError(root, version) === undefined) { + log(`Reusing WordPress ${version} source at ${root}`) + return { ...source, root } + } + + log(`Extracting WordPress ${version} source from ${source.archive}`) + const stagingDirectory = join(dirname(versionDirectory), `.${version}.${process.pid}.${randomUUID()}.partial`) + try { + await mkdir(stagingDirectory, { recursive: true }) + await stageWordPressSource(source.archive, stagingDirectory, version) + await publishStagedSource(stagingDirectory, versionDirectory, version) + } finally { + await rm(stagingDirectory, { recursive: true, force: true }) + } + + const error = await verifiedWordPressRootError(root, version) + if (error) { + throw new Error(`WordPress ${version} source at ${root} failed verification: ${error}`) + } + log(`Materialized WordPress ${version} source at ${root}`) + return { ...source, root } +} + +export function playgroundWordPressSourceDirectory(cacheDirectory: string, version: string): string { + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(version)) { + throw new Error(`Refusing to materialize WordPress source for unsafe version identifier: ${version}`) + } + return join(resolve(cacheDirectory), "source", version) +} + +/** Returns undefined when `root/wp-includes/version.php` declares `version`, otherwise the reason it does not. */ +export async function verifiedWordPressRootError(root: string, version: string): Promise { + let contents: string + try { + contents = await readFile(join(root, "wp-includes", "version.php"), "utf8") + } catch { + return "wp-includes/version.php is missing" + } + const declared = /^\$wp_version\s*=\s*['"]([^'"]+)['"]\s*;/m.exec(contents)?.[1] + if (!declared) { + return "wp-includes/version.php does not declare $wp_version" + } + return declared === version ? undefined : `wp-includes/version.php declares $wp_version '${declared}', expected '${version}'` +} + +function assertReleaseVersionQuery(versionQuery: string | undefined): void { + if (versionQuery && (/^(trunk|nightly)$/i.test(versionQuery) || /^https?:\/\//i.test(versionQuery))) { + throw new Error(`wordpress-source requires a WordPress release version or "latest"; received "${versionQuery}"`) + } +} + +async function stageWordPressSource(archive: string, stagingDirectory: string, version: string): Promise { + const extracted = join(stagingDirectory, "extracted") + await extractZip(archive, extracted) + const wordpressRoot = await locateWordPressRoot(extracted) + if (!wordpressRoot) { + throw new Error(`WordPress archive ${archive} does not contain wp-includes/version.php`) + } + const staged = join(stagingDirectory, "wordpress") + await rename(wordpressRoot, staged) + await rm(extracted, { recursive: true, force: true }) + const error = await verifiedWordPressRootError(staged, version) + if (error) { + throw new Error(`WordPress archive ${archive} failed verification: ${error}`) + } +} + +async function publishStagedSource(stagingDirectory: string, versionDirectory: string, version: string): Promise { + await mkdir(dirname(versionDirectory), { recursive: true }) + try { + await rename(stagingDirectory, versionDirectory) + return + } catch (error) { + if (!isDestinationOccupied(error)) { + throw error + } + } + + // Another caller published first; reuse its tree when it verifies. + if (await verifiedWordPressRootError(join(versionDirectory, "wordpress"), version) === undefined) { + return + } + + // Replace an unverifiable tree by moving it aside first, so the final path + // only ever transitions between complete trees. + const discarded = `${stagingDirectory}.discarded` + await rename(versionDirectory, discarded) + try { + await rename(stagingDirectory, versionDirectory) + } finally { + await rm(discarded, { recursive: true, force: true }) + } +} + +async function extractZip(archive: string, destination: string): Promise { + await mkdir(destination, { recursive: true }) + const blob = await openAsBlob(archive) + for await (const entry of decodeZip(blob.stream() as ReadableStream)) { + const target = safeEntryPath(destination, entry.name) + if (entry.name.endsWith("/")) { + await mkdir(target, { recursive: true }) + continue + } + await mkdir(dirname(target), { recursive: true }) + await writeFile(target, new Uint8Array(await entry.arrayBuffer()), { flag: "wx" }) + } +} + +function safeEntryPath(destination: string, name: string): string { + const target = resolve(destination, name) + if (!name || isAbsolute(name) || name.includes("\\") || name.split("/").includes("..") || (target !== destination && !target.startsWith(`${destination}${sep}`))) { + throw new Error(`WordPress archive contains an unsafe entry path: ${name}`) + } + return target +} + +async function locateWordPressRoot(extracted: string): Promise { + if (existsSync(join(extracted, "wp-includes", "version.php"))) { + return extracted + } + const children = await readdir(extracted) + if (children.length !== 1) { + return undefined + } + const child = join(extracted, children[0]) + const childStat = await lstat(child) + return childStat.isDirectory() && existsSync(join(child, "wp-includes", "version.php")) ? child : undefined +} + +function isDestinationOccupied(error: unknown): boolean { + return Boolean(error && typeof error === "object" && "code" in error && (error.code === "ENOTEMPTY" || error.code === "EEXIST")) +} diff --git a/tests/wordpress-source.test.ts b/tests/wordpress-source.test.ts new file mode 100644 index 00000000..83a7e909 --- /dev/null +++ b/tests/wordpress-source.test.ts @@ -0,0 +1,181 @@ +import assert from "node:assert/strict" +import { existsSync } from "node:fs" +import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" +import { collectBytes, encodeZip } from "@php-wasm/stream-compression" + +import { runWordPressSourceCommand } from "../packages/cli/src/commands/wordpress-source.js" +import { materializePlaygroundWordPressSource, verifiedWordPressRootError, WORDPRESS_SOURCE_SCHEMA } from "../packages/runtime-playground/src/playground-wordpress-source.js" + +const TAG_PROCESSOR = "wp-includes/html-api/class-wp-html-tag-processor.php" + +async function wordpressZip(declaredVersion: string | undefined, options: { prefix?: string; extra?: Record } = {}): Promise { + const prefix = options.prefix ?? "wordpress/" + const files: File[] = [ + new File([" { + await mkdir(cacheDirectory, { recursive: true }) + await writeFile(join(cacheDirectory, `${version}.zip`), archive) +} + +async function stagingLeftovers(cacheDirectory: string): Promise { + const sourceDirectory = join(cacheDirectory, "source") + if (!existsSync(sourceDirectory)) return [] + return (await readdir(sourceDirectory)).filter((name) => name.startsWith(".")) +} + +async function captureStdout(run: () => Promise): Promise<{ code: number; stdout: string; stderr: string }> { + let stdout = "" + let stderr = "" + const writeStdout = process.stdout.write.bind(process.stdout) + const writeStderr = process.stderr.write.bind(process.stderr) + process.stdout.write = ((chunk: string | Uint8Array) => { stdout += String(chunk); return true }) as typeof process.stdout.write + process.stderr.write = ((chunk: string | Uint8Array) => { stderr += String(chunk); return true }) as typeof process.stderr.write + try { + return { code: await run(), stdout, stderr } + } finally { + process.stdout.write = writeStdout + process.stderr.write = writeStderr + } +} + +const cacheDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-wordpress-source-")) +const originalFetch = globalThis.fetch + +try { + // Cache hit: the validated archive is extracted to /source//wordpress. + await seedArchive(cacheDirectory, "9.9.1", await wordpressZip("9.9.1")) + const source = await materializePlaygroundWordPressSource("9.9.1", { cacheDirectory }) + assert.deepEqual(source, { + schema: WORDPRESS_SOURCE_SCHEMA, + version: "9.9.1", + root: join(cacheDirectory, "source", "9.9.1", "wordpress"), + archive: join(cacheDirectory, "9.9.1.zip"), + sourceUrl: "https://wordpress.org/wordpress-9.9.1.zip", + }) + assert.ok(existsSync(join(source.root, TAG_PROCESSOR)), "html-api sources must be materialized") + assert.equal(await verifiedWordPressRootError(source.root, "9.9.1"), undefined) + assert.match(await verifiedWordPressRootError(source.root, "9.9.0") ?? "", /declares \$wp_version '9\.9\.1', expected '9\.9\.0'/) + assert.deepEqual(await stagingLeftovers(cacheDirectory), []) + + // Reuse: a verified tree is returned as-is, without re-extraction. + const marker = join(source.root, "reuse-marker.txt") + await writeFile(marker, "kept") + const logs: string[] = [] + const reused = await materializePlaygroundWordPressSource("9.9.1", { cacheDirectory, log: (message) => logs.push(message) }) + assert.equal(reused.root, source.root) + assert.equal(await readFile(marker, "utf8"), "kept") + assert.ok(logs.some((message) => message.startsWith("Reusing WordPress 9.9.1 source"))) + + // Archives without a single top-level directory are accepted too. + await seedArchive(cacheDirectory, "9.9.8", await wordpressZip("9.9.8", { prefix: "" })) + const flat = await materializePlaygroundWordPressSource("9.9.8", { cacheDirectory }) + assert.ok(existsSync(join(flat.root, TAG_PROCESSOR))) + + // Atomicity: a version mismatch leaves no final directory and no staging leftovers. + await seedArchive(cacheDirectory, "9.9.2", await wordpressZip("9.9.3")) + await assert.rejects(() => materializePlaygroundWordPressSource("9.9.2", { cacheDirectory }), /declares \$wp_version '9\.9\.3', expected '9\.9\.2'/) + assert.equal(existsSync(join(cacheDirectory, "source", "9.9.2")), false) + assert.deepEqual(await stagingLeftovers(cacheDirectory), []) + + // Missing version.php is rejected the same way. + await seedArchive(cacheDirectory, "9.9.4", await wordpressZip(undefined)) + await assert.rejects(() => materializePlaygroundWordPressSource("9.9.4", { cacheDirectory }), /does not contain wp-includes\/version\.php/) + assert.equal(existsSync(join(cacheDirectory, "source", "9.9.4")), false) + + // Path traversal entries are refused before anything escapes the staging directory. + await seedArchive(cacheDirectory, "9.9.9", await wordpressZip("9.9.9", { extra: { "wordpress/../../escaped.php": " materializePlaygroundWordPressSource("9.9.9", { cacheDirectory }), /unsafe entry path/) + assert.equal(existsSync(join(cacheDirectory, "escaped.php")), false) + assert.equal(existsSync(join(cacheDirectory, "source", "9.9.9")), false) + assert.deepEqual(await stagingLeftovers(cacheDirectory), []) + + // Concurrent callers converge on one complete tree. + await seedArchive(cacheDirectory, "9.9.5", await wordpressZip("9.9.5")) + const concurrent = await Promise.all(Array.from({ length: 4 }, () => materializePlaygroundWordPressSource("9.9.5", { cacheDirectory }))) + assert.deepEqual(new Set(concurrent.map((entry) => entry.root)), new Set([join(cacheDirectory, "source", "9.9.5", "wordpress")])) + assert.equal(await verifiedWordPressRootError(concurrent[0].root, "9.9.5"), undefined) + assert.deepEqual(await stagingLeftovers(cacheDirectory), []) + + // An unverifiable final tree is replaced by a verified one. + const staleRoot = join(cacheDirectory, "source", "9.9.6", "wordpress") + await mkdir(join(staleRoot, "wp-includes"), { recursive: true }) + await writeFile(join(staleRoot, "wp-includes", "version.php"), " { + requested.push(String(input)) + return new Response(downloadArchive, { status: 200 }) + }) as typeof fetch + const downloaded = await materializePlaygroundWordPressSource("9.9.7", { cacheDirectory }) + assert.deepEqual(requested, ["https://wordpress.org/wordpress-9.9.7.zip"]) + assert.equal(downloaded.archive, join(cacheDirectory, "9.9.7.zip")) + assert.ok(existsSync(downloaded.archive)) + assert.ok(existsSync(join(downloaded.root, TAG_PROCESSOR))) + + // Offline: a failed download leaves no archive and no source tree. + globalThis.fetch = (async () => { throw new TypeError("fetch failed") }) as typeof fetch + await assert.rejects(() => materializePlaygroundWordPressSource("9.9.10", { cacheDirectory }), /wordpress-archive-cache.*fetch failed/) + assert.equal(existsSync(join(cacheDirectory, "9.9.10.zip")), false) + assert.equal(existsSync(join(cacheDirectory, "source", "9.9.10")), false) + + // Invalid archive bytes are rejected by the existing validator. + globalThis.fetch = (async () => new Response("not a zip", { status: 200 })) as typeof fetch + await assert.rejects(() => materializePlaygroundWordPressSource("9.9.11", { cacheDirectory }), /invalid/) + assert.equal(existsSync(join(cacheDirectory, "source", "9.9.11")), false) + globalThis.fetch = originalFetch + + await assert.rejects(() => materializePlaygroundWordPressSource("trunk", { cacheDirectory }), /requires a WordPress release version/) + + // CLI: --json prints exactly one wp-codebox/wordpress-source/v1 object on stdout. + const materialize: typeof materializePlaygroundWordPressSource = (versionQuery, options) => materializePlaygroundWordPressSource(versionQuery, { ...options, cacheDirectory }) + const json = await captureStdout(() => runWordPressSourceCommand(["--wp", "9.9.1", "--json"], materialize)) + assert.equal(json.code, 0) + assert.equal(json.stdout.trim().split("\n").length, 1, "stdout must contain exactly one JSON line") + const parsed = JSON.parse(json.stdout) + assert.deepEqual(Object.keys(parsed), ["schema", "version", "root", "archive", "sourceUrl"]) + assert.deepEqual(parsed, source) + assert.match(json.stderr, /Reusing WordPress 9\.9\.1 source/) + + const plain = await captureStdout(() => runWordPressSourceCommand(["--wp=9.9.1"], materialize)) + assert.equal(plain.code, 0) + assert.equal(plain.stdout, `${source.root}\n`) + + const failed = await captureStdout(() => runWordPressSourceCommand(["--wp", "9.9.2", "--json"], materialize)) + assert.equal(failed.code, 1) + assert.equal(failed.stdout, "") + assert.match(failed.stderr, /failed to materialize WordPress 9\.9\.2 source/) + + const usage = await captureStdout(() => runWordPressSourceCommand(["--bogus"], materialize)) + assert.equal(usage.code, 2) + assert.equal(usage.stdout, "") + assert.match(usage.stderr, /Unknown option: --bogus/) + + const missingValue = await captureStdout(() => runWordPressSourceCommand(["--wp", "--json"], materialize)) + assert.equal(missingValue.code, 2) + assert.match(missingValue.stderr, /--wp requires a version query/) + + console.log("wordpress-source passed") +} finally { + globalThis.fetch = originalFetch + await rm(cacheDirectory, { recursive: true, force: true }) +}