From 4e47cd0a519b95dbf51ff1a263c4251e1de2b6f9 Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Fri, 11 Sep 2026 16:34:50 -0400 Subject: [PATCH] fix(cli): classify runtime input materialization crashes Empty extra_plugins still boots Playground while mounting recipe inputs. A crash in that window had no phase, so runs classified as unknown. Track it as materialize_runtime_inputs and reject PHP.wasm side modules whose ABI imports the runtime does not export. --- .../cli/src/commands/recipe-run-finalizer.ts | 2 + packages/cli/src/commands/recipe-run-types.ts | 2 +- .../cli/src/commands/recipe-runtime-setup.ts | 128 ++++++++++-------- packages/runtime-playground/src/index.ts | 2 +- .../src/php-wasm-preflight.ts | 89 ++++++++++++ .../src/playground-runtime.ts | 8 +- tests/php-wasm-extension-abi.test.ts | 58 ++++++++ ...recipe-runtime-setup-empty-plugins.test.ts | 121 +++++++++++++++++ 8 files changed, 348 insertions(+), 62 deletions(-) create mode 100644 tests/php-wasm-extension-abi.test.ts create mode 100644 tests/recipe-runtime-setup-empty-plugins.test.ts diff --git a/packages/cli/src/commands/recipe-run-finalizer.ts b/packages/cli/src/commands/recipe-run-finalizer.ts index 5c373ca8e..8a92c0052 100644 --- a/packages/cli/src/commands/recipe-run-finalizer.ts +++ b/packages/cli/src/commands/recipe-run-finalizer.ts @@ -363,6 +363,8 @@ function classifyRecipePhaseFailure(phase: string): string { return "startup" case "mount_plugins": return "plugin_mount" + case "materialize_runtime_inputs": + return "mount_materialization" case "activate_plugins": return "plugin_activation" case "import_fixture_databases": diff --git a/packages/cli/src/commands/recipe-run-types.ts b/packages/cli/src/commands/recipe-run-types.ts index a78f2801d..fb977bbe7 100644 --- a/packages/cli/src/commands/recipe-run-types.ts +++ b/packages/cli/src/commands/recipe-run-types.ts @@ -324,7 +324,7 @@ export interface RecipeDiagnosticArtifactRef { export type RecipePhasedPluginInputStage = "collect" | "project" | "resolve" | "mount" | "activate" | "readiness" -export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "activate_plugins" | "collect_phased_plugin_input" | "project_phased_plugin_input" | "resolve_phased_plugin_input" | "mount_phased_plugins" | "activate_phased_plugins" | "phased_plugin_readiness" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_adversarial_campaigns" | "run_probes" | "collect_artifacts" +export type RecipePhaseName = "provision_runtime_services" | "runtime_startup" | "mount_plugins" | "materialize_runtime_inputs" | "activate_plugins" | "collect_phased_plugin_input" | "project_phased_plugin_input" | "resolve_phased_plugin_input" | "mount_phased_plugins" | "activate_phased_plugins" | "phased_plugin_readiness" | "run_blueprint_steps" | "apply_distribution" | "import_fixture_databases" | "run_distribution_setup_artifacts" | "run_distribution_startup_probes" | "run_workloads" | "run_adversarial_campaigns" | "run_probes" | "collect_artifacts" export interface RecipePhaseEvidence { schema: "wp-codebox/recipe-phase-evidence/v1" diff --git a/packages/cli/src/commands/recipe-runtime-setup.ts b/packages/cli/src/commands/recipe-runtime-setup.ts index 68f404261..153dbe417 100644 --- a/packages/cli/src/commands/recipe-runtime-setup.ts +++ b/packages/cli/src/commands/recipe-runtime-setup.ts @@ -176,71 +176,70 @@ export async function applyRecipeRuntimeSetup(args: { const extraPluginMounts = preparedExtraPluginMounts(extraPlugins) await mountPreparedExtraPlugins(runtime, extraPlugins, extraPluginMounts, phaseExecutor, interruption, "mount_plugins") - for (const overlay of overlayCopies) { - executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(copyRuntimeOverlayCode(overlay.source, overlay.target)) }), "setup", -3, `runtime.overlay.copy:${overlay.target}`)) - interruption?.throwIfInterrupted() - } + await phaseTracker.run("materialize_runtime_inputs", phaseRuntimeInputData(recipe, extraPlugins, stagedFiles, dependencyOverlays), async () => { + for (const overlay of overlayCopies) { + executions.push(withRecipeExecutionPhase(await runtime.execute({ command: "wordpress.run-php", args: setupPhpArgs(copyRuntimeOverlayCode(overlay.source, overlay.target)) }), "setup", -3, `runtime.overlay.copy:${overlay.target}`)) + interruption?.throwIfInterrupted() + } - for (const overlay of dependencyOverlays) { - await awaitRecipe(`dependency-overlay.mount:${overlay.package}`, runtime.mount({ - type: overlay.type, - source: overlay.source, - target: overlay.target, - mode: overlay.mode, - metadata: overlay.metadata, - })) - interruption?.throwIfInterrupted() - } + for (const overlay of dependencyOverlays) { + await awaitRecipe(`dependency-overlay.mount:${overlay.package}`, runtime.mount({ + type: overlay.type, + source: overlay.source, + target: overlay.target, + mode: overlay.mode, + metadata: overlay.metadata, + })) + interruption?.throwIfInterrupted() + } - const inputMounts: MountSpec[] = [] - for (const [index, mount] of (recipe.inputs?.mounts ?? []).entries()) { - const source = resolve(recipeDirectory, mount.source) - const target = inputMountPathMap[index]?.canonicalTarget ?? mount.target - const metadata = await inputMountMetadataWithBaseline(source, mount, inputMountBaselinePaths, target) - const inputMount: MountSpec = { - type: await recipeMountType(source, mount.type), - source, - target, - mode: mount.mode ?? "readwrite", - ...(mount.captureArtifacts !== undefined ? { captureArtifacts: mount.captureArtifacts } : {}), - ...(mount.phase !== undefined ? { phase: mount.phase } : {}), - metadata, + const inputMounts: MountSpec[] = [] + for (const [index, mount] of (recipe.inputs?.mounts ?? []).entries()) { + const source = resolve(recipeDirectory, mount.source) + const target = inputMountPathMap[index]?.canonicalTarget ?? mount.target + const metadata = await inputMountMetadataWithBaseline(source, mount, inputMountBaselinePaths, target) + const inputMount: MountSpec = { + type: await recipeMountType(source, mount.type), + source, + target, + mode: mount.mode ?? "readwrite", + ...(mount.captureArtifacts !== undefined ? { captureArtifacts: mount.captureArtifacts } : {}), + ...(mount.phase !== undefined ? { phase: mount.phase } : {}), + metadata, + } + inputMounts.push(inputMount) + await awaitRecipe(`input.mount:${mount.target}`, runtime.mount(inputMount)) + interruption?.throwIfInterrupted() } - inputMounts.push(inputMount) - await awaitRecipe(`input.mount:${mount.target}`, runtime.mount(inputMount)) - interruption?.throwIfInterrupted() - } - for (const stagedFile of stagedFiles) { - await awaitRecipe(`staged-file.mount:${stagedFile.target}`, runtime.mount({ - type: stagedFile.type, - source: stagedFile.source, - target: stagedFile.target, - mode: "readwrite", - metadata: stagedFile.metadata, - })) - interruption?.throwIfInterrupted() - } + for (const stagedFile of stagedFiles) { + await awaitRecipe(`staged-file.mount:${stagedFile.target}`, runtime.mount({ + type: stagedFile.type, + source: stagedFile.source, + target: stagedFile.target, + mode: "readwrite", + metadata: stagedFile.metadata, + })) + interruption?.throwIfInterrupted() + } - const materializableMounts: MountSpec[] = [ - ...extraPluginMounts, - ...inputMounts, - ...stagedFiles.map((stagedFile) => ({ - type: stagedFile.type, - source: stagedFile.source, - target: stagedFile.target, - mode: "readwrite" as const, - metadata: stagedFile.metadata, - })), - ] - if (materializableMounts.length > 0 && canMaterializeMounts(runtime)) { - await awaitRecipe("input.materialize", () => materializePreparedMounts(runtime, materializableMounts)) - interruption?.throwIfInterrupted() - } + const materializableMounts: MountSpec[] = [ + ...extraPluginMounts, + ...inputMounts, + ...stagedFiles.map((stagedFile) => ({ + type: stagedFile.type, + source: stagedFile.source, + target: stagedFile.target, + mode: "readwrite" as const, + metadata: stagedFile.metadata, + })), + ] + if (materializableMounts.length > 0 && canMaterializeMounts(runtime)) { + await awaitRecipe("input.materialize", () => materializePreparedMounts(runtime, materializableMounts)) + interruption?.throwIfInterrupted() + } + }) - // Discovery inventories mounted files only. Running setup PHP here would - // activate dependencies before the discovery command can enforce its - // no-bootstrap boundary. if (recipeHasPhpunitDiscoveryOnly(recipe)) { return { executions } } @@ -533,6 +532,17 @@ function phasePluginMountData(extraPlugins: PreparedExtraPlugin[]): Record { + const inputMounts = recipe.inputs?.mounts ?? [] + return { + extraPluginCount: extraPlugins.length, + inputMountCount: inputMounts.length, + stagedFileCount: stagedFiles.length, + dependencyOverlayCount: dependencyOverlays.length, + inputMounts: inputMounts.map((mount) => ({ target: mount.target, mode: mount.mode ?? "readwrite", type: mount.type })), + } +} + function phasePluginActivationData(activatedPlugins: PreparedExtraPlugin[]): Record { return { count: activatedPlugins.length, diff --git a/packages/runtime-playground/src/index.ts b/packages/runtime-playground/src/index.ts index 4f7a027df..429eabb36 100644 --- a/packages/runtime-playground/src/index.ts +++ b/packages/runtime-playground/src/index.ts @@ -14,7 +14,7 @@ export { createHostCommandTool, type HostCommandToolConfig } from "./host-comman export { PlaygroundRuntimeBackend, createPlaygroundRuntimeBackend, playgroundRuntimeBackendProvider } from "./playground-runtime.js" export { maintainPlaygroundCustomArchiveCache, playgroundWordPressArchiveCacheDirectory, type PlaygroundCustomArchiveCacheMaintenance, type PlaygroundCustomArchiveCacheMaintenanceOptions, type PlaygroundCustomArchiveCachePolicy } from "./playground-wordpress-archive-cache.js" export { collectBrowserArtifactMetrics, collectWordPressEpisodeArtifacts, collectWordPressRuntimeArtifacts, createWordPressEpisode, createWordPressRuntime, runWordPressEpisodeActions, type WordPressEpisodeSpec, type WordPressRuntimeActionHooks, type WordPressRuntimeSpec } from "./public.js" -export { preflightPhpWasmRuntimeAssets, PhpWasmRuntimeAssetIntegrityError, type PhpWasmRuntimeAssetPreflight, type PhpWasmRuntimeAssetPreflightOptions } from "./php-wasm-preflight.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" export { browserPreviewAuthCookieUrls, browserPreviewNetworkPolicySummary, browserPreviewReadinessError, browserPreviewRouting, browserPreviewSecureContextError, browserPreviewTopology, browserPreviewOrigins, resolveBrowserPreviewUrl, type BrowserPreviewNetworkPolicy, type BrowserPreviewTopology } from "./browser-preview-routing.js" export { BROWSER_TRANSPORT_FAULT_CAPABILITIES, applyBrowserTransportFault, browserTransportFaultReport, createBrowserTransportFaultAdapter, installBrowserTransportFaults, type BrowserTransportFaultAdapter, type BrowserTransportFaultInstallOptions, type BrowserTransportFaultReport, type BrowserTransportFaultTeardown, type InstalledBrowserTransportFaults } from "./browser-transport-faults.js" diff --git a/packages/runtime-playground/src/php-wasm-preflight.ts b/packages/runtime-playground/src/php-wasm-preflight.ts index d63d3fee5..43b562d9a 100644 --- a/packages/runtime-playground/src/php-wasm-preflight.ts +++ b/packages/runtime-playground/src/php-wasm-preflight.ts @@ -46,6 +46,95 @@ export async function assertPhpWasmExternalExtensionsSupported(extensions: reado } } +const PHP_ABI_IMPORT_PATTERN = /^(php_|zend_|convert_to_|_emalloc|_efree|_estrdup|_safe_emalloc)/ +const phpWasmExportCache = new Map>() + +export class PhpWasmExtensionAbiError extends Error { + readonly code = "wp-codebox-php-wasm-extension-abi-mismatch" + readonly diagnostic: { extension: string; phpVersion: string; missingSymbols: string[] } + + constructor(diagnostic: { extension: string; phpVersion: string; missingSymbols: string[] }) { + const message = `PHP.wasm extension '${diagnostic.extension}' imports symbols the ${diagnostic.phpVersion} runtime does not export: ${diagnostic.missingSymbols.join(", ")}.` + super(message) + this.name = "PhpWasmExtensionAbiError" + this.diagnostic = diagnostic + } +} + +export function phpWasmExtensionMissingAbiSymbols(extensionWasm: Uint8Array, phpExportNames: Iterable): string[] { + const phpExports = phpExportNames instanceof Set ? phpExportNames : new Set(phpExportNames) + const imports = WebAssembly.Module.imports(webAssemblyModuleFromBytes(extensionWasm)) + return [...new Set(imports + .filter((entry) => entry.module === "env" && entry.kind === "function" && PHP_ABI_IMPORT_PATTERN.test(entry.name) && !phpExports.has(entry.name)) + .map((entry) => entry.name))].sort() +} + +function webAssemblyModuleFromBytes(bytes: Uint8Array): WebAssembly.Module { + const copy = new Uint8Array(bytes.byteLength) + copy.set(bytes) + return new WebAssembly.Module(copy) +} + +export async function assertPhpWasmExtensionAbi(options: { + extensions?: ReadonlyArray<{ manifest: string }> + phpVersion: string + phpWasmPath: string + mode?: "jspi" | "asyncify" +}): Promise { + if (!options.extensions || options.extensions.length === 0) { + return + } + + const phpExports = await phpWasmExportNames(options.phpWasmPath) + const mode = options.mode ?? "jspi" + for (const extension of options.extensions) { + const artifactPath = await resolveExtensionArtifactPath(extension.manifest, options.phpVersion, mode) + if (!artifactPath) continue + const missingSymbols = phpWasmExtensionMissingAbiSymbols(await readFile(artifactPath), phpExports) + if (missingSymbols.length > 0) { + throw new PhpWasmExtensionAbiError({ + extension: extension.manifest, + phpVersion: options.phpVersion, + missingSymbols, + }) + } + } +} + +async function phpWasmExportNames(phpWasmPath: string): Promise> { + const cached = phpWasmExportCache.get(phpWasmPath) + if (cached) return cached + const names = new Set(WebAssembly.Module.exports(webAssemblyModuleFromBytes(await readFile(phpWasmPath))).map((entry) => entry.name)) + phpWasmExportCache.set(phpWasmPath, names) + return names +} + +async function resolveExtensionArtifactPath(manifestPath: string, phpVersion: string, mode: "jspi" | "asyncify"): Promise { + if (!existsSync(manifestPath)) { + throw new PhpWasmExtensionAbiError({ + extension: manifestPath, + phpVersion, + missingSymbols: [`missing-manifest:${manifestPath}`], + }) + } + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { artifacts?: Array<{ phpVersion?: unknown; sourcePath?: unknown }> } + const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [] + const matching = artifacts.filter((artifact) => artifact.phpVersion === phpVersion && typeof artifact.sourcePath === "string") + const preferred = matching.find((artifact) => String(artifact.sourcePath).includes(mode)) ?? matching[0] + if (!preferred || typeof preferred.sourcePath !== "string") { + return undefined + } + const artifactPath = join(dirname(manifestPath), preferred.sourcePath) + if (!existsSync(artifactPath)) { + throw new PhpWasmExtensionAbiError({ + extension: manifestPath, + phpVersion, + missingSymbols: [`missing-artifact:${preferred.sourcePath}`], + }) + } + return artifactPath +} + const repairHint = "Repair the PHP wasm runtime package by reinstalling dependencies, for example: remove node_modules and package-lock drift, then run npm install; if using a package cache, clear the broken @php-wasm package cache first." const compiledWasmCache = new Map() const requireFromHere = createRequire(import.meta.url) diff --git a/packages/runtime-playground/src/playground-runtime.ts b/packages/runtime-playground/src/playground-runtime.ts index 6a23eaba2..c260f0c0f 100644 --- a/packages/runtime-playground/src/playground-runtime.ts +++ b/packages/runtime-playground/src/playground-runtime.ts @@ -26,7 +26,7 @@ import { runAbilityCommand, runAdminActionInventoryCommand, runBenchCommand, run import { PlaygroundSnapshotRestoreError, contentDigest, mountsFromSnapshot, runtimeSnapshotExportPayload, runtimeSnapshotExportPhp, runtimeSnapshotPayload, runtimeSnapshotRestorePhp, runtimeSpecFromSnapshot, snapshotDigest, type RuntimeSnapshotArtifact, type RuntimeSnapshotExportOptions } from "./runtime-snapshot.js" import { createRuntimeWpCliBridge, type RuntimeWpCliBridge } from "./runtime-wp-cli-bridge.js" import { writeReplayExportPackage } from "./replayable-wordpress-site-bundle.js" -import { preflightPhpWasmRuntimeAssets } from "./php-wasm-preflight.js" +import { assertPhpWasmExtensionAbi, preflightPhpWasmRuntimeAssets } from "./php-wasm-preflight.js" import { previewReviewerAccess } from "./preview-reviewer-access.js" import { installHostHttpTransportRoute } from "./host-http-transport.js" import { wordpressActionAuthNoncePhpCode, wordpressFixtureUserWithoutPassword, wordpressUserSessionFromCommandArgs, type WordPressUserSessionResolution } from "./wordpress-user-sessions.js" @@ -272,6 +272,12 @@ class PlaygroundRuntime implements Runtime { static async create(spec: RuntimeCreateSpec, options: PlaygroundRuntimeBackendOptions = {}): Promise { const phpWasmRuntimeAssetPreflight = await preflightPhpWasmRuntimeAssets({ phpVersion: spec.environment.phpVersion }) + await assertPhpWasmExtensionAbi({ + extensions: spec.environment.extensions, + phpVersion: phpWasmRuntimeAssetPreflight.phpVersion, + phpWasmPath: phpWasmRuntimeAssetPreflight.wasmPath, + mode: phpWasmRuntimeAssetPreflight.mode, + }) const runtime = new PlaygroundRuntime({ ...spec, metadata: { diff --git a/tests/php-wasm-extension-abi.test.ts b/tests/php-wasm-extension-abi.test.ts new file mode 100644 index 000000000..725be8a82 --- /dev/null +++ b/tests/php-wasm-extension-abi.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict" +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { assertPhpWasmExtensionAbi, PhpWasmExtensionAbiError, phpWasmExtensionMissingAbiSymbols } from "../packages/runtime-playground/src/php-wasm-preflight.js" + +function wasmImporting(moduleName: string, importName: string): Uint8Array { + const moduleBytes = Buffer.from(moduleName, "utf8") + const nameBytes = Buffer.from(importName, "utf8") + const importPayload = Buffer.concat([ + Buffer.from([1]), + Buffer.from([moduleBytes.length]), + moduleBytes, + Buffer.from([nameBytes.length]), + nameBytes, + Buffer.from([0, 0]), + ]) + return Buffer.concat([ + Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]), + Buffer.from([0x01, 0x04, 0x01, 0x60, 0x00, 0x00]), + Buffer.from([0x02, importPayload.length]), + importPayload, + ]) +} + +const sodiumLike = wasmImporting("env", "php_password_algo_register") +assert.deepEqual(phpWasmExtensionMissingAbiSymbols(sodiumLike, []), ["php_password_algo_register"]) +assert.deepEqual(phpWasmExtensionMissingAbiSymbols(sodiumLike, ["php_password_algo_register"]), []) +assert.deepEqual(phpWasmExtensionMissingAbiSymbols(wasmImporting("env", "__assert_fail"), []), []) +assert.deepEqual(phpWasmExtensionMissingAbiSymbols(wasmImporting("env", "_emalloc_448"), ["_emalloc", "_emalloc_128"]), ["_emalloc_448"]) + +const root = await mkdtemp(join(tmpdir(), "wp-codebox-php-wasm-abi-")) +const phpWasmPath = join(root, "php.wasm") +const manifestDir = join(root, "sodium") +const manifestPath = join(manifestDir, "manifest.json") +const artifactPath = join(manifestDir, "sodium-php8.4-jspi.so") +await mkdir(manifestDir) +await writeFile(phpWasmPath, wasmImporting("env", "unused")) +await writeFile(artifactPath, sodiumLike) +await writeFile(manifestPath, JSON.stringify({ + name: "sodium", + artifacts: [{ phpVersion: "8.4", sourcePath: "sodium-php8.4-jspi.so" }], +})) +await assert.rejects( + assertPhpWasmExtensionAbi({ + extensions: [{ manifest: manifestPath }], + phpVersion: "8.4", + phpWasmPath, + mode: "jspi", + }), + (error: unknown) => error instanceof PhpWasmExtensionAbiError + && error.code === "wp-codebox-php-wasm-extension-abi-mismatch" + && error.diagnostic.missingSymbols.includes("php_password_algo_register"), +) +await rm(root, { recursive: true, force: true }) + +console.log("php wasm extension abi ok") diff --git a/tests/recipe-runtime-setup-empty-plugins.test.ts b/tests/recipe-runtime-setup-empty-plugins.test.ts new file mode 100644 index 000000000..e2ca60d19 --- /dev/null +++ b/tests/recipe-runtime-setup-empty-plugins.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict" +import { mkdtemp, rm, writeFile } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +import { applyRecipeRuntimeSetup, recipeInputMountPathMap, type PreparedRecipeRuntimeSetup } from "../packages/cli/src/commands/recipe-runtime-setup.js" +import type { MountSpec, Runtime, WorkspaceRecipe } from "../packages/runtime-core/src/public.js" + +const inputMountSource = await mkdtemp(join(tmpdir(), "wp-codebox-empty-plugin-input-")) +const fileMountSource = join(inputMountSource, "wp-config.php") +await writeFile(fileMountSource, " = [] + return { + phases, + phaseExecutor: { + tracker: { + complete(name: string) { phases.push({ name, status: "completed" }) }, + async run(name: string, _data: unknown, callback: () => Promise) { + try { + const result = await callback() + phases.push({ name, status: "completed" }) + return result + } catch (error) { + phases.push({ name, status: "failed" }) + throw error + } + }, + list() { return phases }, + }, + async operation(_operation: string, promiseOrFactory: Promise | (() => Promise)) { + return await (typeof promiseOrFactory === "function" ? promiseOrFactory() : promiseOrFactory) + }, + }, + } +} + +function unusedRuntime(): Runtime { + return { + async info() { return { id: "runtime", backend: "wordpress-playground", environment: { kind: "wordpress" }, createdAt: new Date().toISOString(), status: "running" } }, + async mount() {}, + async execute() { throw new Error("unused") }, + async observe() { throw new Error("unused") }, + async snapshot() { throw new Error("unused") }, + async collectArtifacts() { throw new Error("unused") }, + async destroy() {}, + } satisfies Runtime +} + +try { + const materialized: MountSpec[][] = [] + const { phases, phaseExecutor } = recordingPhaseExecutor() + await applyRecipeRuntimeSetup({ + recipe, + recipeDirectory: process.cwd(), + runtime: { + ...unusedRuntime(), + async materializeStagedInputs(mounts) { materialized.push(mounts) }, + }, + runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, runtimeEnv: {} }, + prepared, + phaseExecutor: phaseExecutor as never, + }) + assert.deepEqual(phases.filter((phase) => phase.name === "mount_plugins"), [{ name: "mount_plugins", status: "completed" }]) + assert.deepEqual(phases.filter((phase) => phase.name === "materialize_runtime_inputs"), [{ name: "materialize_runtime_inputs", status: "completed" }]) + assert.equal(materialized.length, 1) + assert.equal(materialized[0].length, 2) + assert.ok(materialized[0].some((mount) => mount.type === "file" && mount.source === fileMountSource), "empty extra_plugins still materializes file input mounts") + + const failing = recordingPhaseExecutor() + await assert.rejects( + applyRecipeRuntimeSetup({ + recipe, + recipeDirectory: process.cwd(), + runtime: { + ...unusedRuntime(), + async materializeStagedInputs() { + const error = new TypeError("resolved is not a function") + error.cause = new Error("Comlink method call failed") + throw error + }, + }, + runtimeSpec: { environment: { kind: "wordpress", name: "test", version: "latest" }, runtimeEnv: {} }, + prepared, + phaseExecutor: failing.phaseExecutor as never, + }), + (error: unknown) => error instanceof TypeError && error.message === "resolved is not a function", + ) + assert.deepEqual(failing.phases.filter((phase) => phase.name === "mount_plugins"), [{ name: "mount_plugins", status: "completed" }]) + assert.deepEqual(failing.phases.filter((phase) => phase.name === "materialize_runtime_inputs"), [{ name: "materialize_runtime_inputs", status: "failed" }]) +} finally { + await rm(inputMountSource, { recursive: true, force: true }) +} + +console.log("recipe-runtime-setup empty extra plugins materialize phase ok")