diff --git a/docs/public-api-contract.md b/docs/public-api-contract.md index af2c3334..170b45f7 100644 --- a/docs/public-api-contract.md +++ b/docs/public-api-contract.md @@ -35,6 +35,22 @@ Use these package entrypoints from external integrations: runtime backend packages that need to compose Codebox lifecycle/preload PHP. - `@automattic/wp-codebox-core/recipe-builders`: typed recipe construction helpers. +- `executeBoundedRuntimePlan()` from `@automattic/wp-codebox-core/public`: + generic aggregate execution for hosts that materialize one workspace/runtime, + start disposable services once, and run multiple commands at bounded + concurrency. It accepts `wp-codebox/bounded-runtime-plan/v1`; every entry has + `argv`, string-only environment overrides, a per-entry timeout, process + identity, safe artifact namespace, and input index. Results use + `wp-codebox/bounded-runtime-plan-result/v1` in input order. The adapter owns + runtime-specific execution, while Codebox guarantees one materialize/start and + one stop/dispose lifecycle. Set `failFast: true` to cancel not-yet-started + entries, and use `retryBoundedRuntimePlan(plan, priorResult)` to select only + failed, timed-out, or cancelled entries. Environment values are passed only to + the adapter and never copied into result or artifact DTOs; hosts must retain + their existing secret-redaction and reviewer-safe artifact policies. On + timeout, the adapter receives an aborted signal and must settle its execution + promise after the underlying process terminates; the scheduler retains that + entry's concurrency slot until settlement before admitting another entry. - `@automattic/wp-codebox-core/agent-task-recipe`: agent-task recipe assembly helpers. - `@automattic/wp-codebox-core/runtime-presets`: runtime preset registry helpers. diff --git a/package.json b/package.json index e5d09a9a..ad3a52ed 100644 --- a/package.json +++ b/package.json @@ -125,6 +125,7 @@ "test:runtime-neutral-contracts": "tsx tests/runtime-neutral-contracts.test.ts", "test:run-registry": "tsx tests/run-registry.test.ts", "test:run-plan-executor": "tsx tests/run-plan-executor.test.ts", + "test:bounded-runtime-plan": "tsx tests/bounded-runtime-plan.test.ts", "test:fanout-execution": "tsx tests/fanout-execution.test.ts", "test:agent-fanout-executor": "tsx tests/agent-fanout-executor.test.ts", "test:fanout-aggregation-contract-parity": "tsx tests/fanout-aggregation-contract-parity.test.ts", diff --git a/packages/runtime-core/src/bounded-runtime-plan.ts b/packages/runtime-core/src/bounded-runtime-plan.ts new file mode 100644 index 00000000..a0abc7f1 --- /dev/null +++ b/packages/runtime-core/src/bounded-runtime-plan.ts @@ -0,0 +1,183 @@ +import { normalizeRunPlanConcurrency, safeRunPlanNamespace } from "./run-plan.js" + +/** A generic aggregate command plan; PHPUnit is one consumer, not a special case. */ +export const BOUNDED_RUNTIME_PLAN_SCHEMA = "wp-codebox/bounded-runtime-plan/v1" as const +export const BOUNDED_RUNTIME_PLAN_RESULT_SCHEMA = "wp-codebox/bounded-runtime-plan-result/v1" as const + +export interface BoundedRuntimePlanEntry { + id: string + argv: string[] + environment?: Record + timeoutMs?: number + processIdentity: string + artifactNamespace: string + inputIndex: number +} + +export interface BoundedRuntimePlan { + schema: typeof BOUNDED_RUNTIME_PLAN_SCHEMA + concurrency: number + failFast?: boolean + entries: BoundedRuntimePlanEntry[] +} + +export type BoundedRuntimePlanEntryStatus = "succeeded" | "failed" | "timed_out" | "cancelled" + +export interface BoundedRuntimePlanEntryResult { + id: string + inputIndex: number + processIdentity: string + artifactNamespace: string + status: BoundedRuntimePlanEntryStatus + success: boolean + exitCode?: number + error?: { code: "execution-failed" | "execution-threw" | "timed-out" | "fail-fast"; message: string } +} + +export interface BoundedRuntimePlanResult { + schema: typeof BOUNDED_RUNTIME_PLAN_RESULT_SCHEMA + success: boolean + concurrency: number + entries: BoundedRuntimePlanEntryResult[] +} + +export interface BoundedRuntimePlanExecution { + entry: BoundedRuntimePlanEntry + workspace: TWorkspace + runtime: TRuntime + services: TServices + signal: AbortSignal +} + +/** + * Lifecycle ownership is deliberately external: callers decide how a workspace, + * runtime, and disposable services are created, while this primitive guarantees + * that each lifecycle boundary is reached at most once per aggregate. + */ +export interface BoundedRuntimePlanAdapter { + materialize(): Promise<{ workspace: TWorkspace; runtime: TRuntime }> + startServices(context: { workspace: TWorkspace; runtime: TRuntime }): Promise + execute(context: BoundedRuntimePlanExecution): Promise<{ success: boolean; exitCode?: number; message?: string }> + stopServices(context: { workspace: TWorkspace; runtime: TRuntime; services: TServices }): Promise + dispose(context: { workspace: TWorkspace; runtime: TRuntime }): Promise +} + +export function validateBoundedRuntimePlan(plan: BoundedRuntimePlan): void { + if (plan.schema !== BOUNDED_RUNTIME_PLAN_SCHEMA) throw new Error(`Bounded runtime plan schema must be ${BOUNDED_RUNTIME_PLAN_SCHEMA}.`) + if (!Array.isArray(plan.entries) || plan.entries.length === 0) throw new Error("Bounded runtime plan requires at least one entry.") + normalizeRunPlanConcurrency(plan.concurrency, { concurrencyMode: "validate", maxConcurrency: plan.entries.length }) + const ids = new Set() + const inputIndexes = new Set() + for (const entry of plan.entries) { + if (!safeIdentifier(entry.id) || ids.has(entry.id)) throw new Error(`Bounded runtime plan entry ids must be unique safe identifiers: ${entry.id || ""}`) + ids.add(entry.id) + if (!safeIdentifier(entry.processIdentity)) throw new Error(`Bounded runtime plan process identity must be a safe identifier: ${entry.processIdentity || ""}`) + safeRunPlanNamespace(entry.artifactNamespace) + if (!Array.isArray(entry.argv) || entry.argv.some((argument) => typeof argument !== "string")) throw new Error(`Bounded runtime plan entry argv must be a string array: ${entry.id}`) + if (!Number.isInteger(entry.inputIndex) || entry.inputIndex < 0 || inputIndexes.has(entry.inputIndex)) throw new Error(`Bounded runtime plan input indexes must be unique non-negative integers: ${entry.id}`) + inputIndexes.add(entry.inputIndex) + if (entry.timeoutMs !== undefined && (!Number.isFinite(entry.timeoutMs) || entry.timeoutMs <= 0)) throw new Error(`Bounded runtime plan timeout must be positive: ${entry.id}`) + if (entry.environment && Object.entries(entry.environment).some(([name, value]) => !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name) || typeof value !== "string")) throw new Error(`Bounded runtime plan environment must contain string environment overrides: ${entry.id}`) + } +} + +export async function executeBoundedRuntimePlan(plan: BoundedRuntimePlan, adapter: BoundedRuntimePlanAdapter): Promise { + validateBoundedRuntimePlan(plan) + const concurrency = normalizeRunPlanConcurrency(plan.concurrency, { concurrencyMode: "validate", maxConcurrency: plan.entries.length }) + let materialized: { workspace: TWorkspace; runtime: TRuntime } | undefined + let services: TServices | undefined + let servicesStarted = false + try { + materialized = await adapter.materialize() + services = await adapter.startServices(materialized) + servicesStarted = true + const entries = await executeEntries(plan, concurrency, materialized, services, adapter) + return { + schema: BOUNDED_RUNTIME_PLAN_RESULT_SCHEMA, + success: entries.every((entry) => entry.success), + concurrency, + entries, + } + } finally { + try { + if (materialized && servicesStarted) await adapter.stopServices({ ...materialized, services: services as TServices }) + } finally { + if (materialized) await adapter.dispose(materialized) + } + } +} + +/** Returns only unsuccessful entries, preserving the original deterministic order. */ +export function retryBoundedRuntimePlan(plan: BoundedRuntimePlan, previous: Pick): BoundedRuntimePlan { + validateBoundedRuntimePlan(plan) + const unsuccessful = new Set(previous.entries.filter((entry) => !entry.success).map((entry) => entry.id)) + const entries = plan.entries.filter((entry) => unsuccessful.has(entry.id)) + if (entries.length === 0) throw new Error("Bounded runtime plan retry requires at least one unsuccessful prior entry.") + return { ...plan, concurrency: Math.min(plan.concurrency, entries.length), entries } +} + +async function executeEntries(plan: BoundedRuntimePlan, concurrency: number, materialized: { workspace: TWorkspace; runtime: TRuntime }, services: TServices, adapter: BoundedRuntimePlanAdapter): Promise { + const results = new Array(plan.entries.length) + let next = 0 + let failed = false + const worker = async (): Promise => { + while (true) { + const index = next++ + if (index >= plan.entries.length) return + const entry = plan.entries[index]! + if (failed && plan.failFast) { + results[index] = cancelledResult(entry) + continue + } + const result = await executeEntry(entry, materialized, services, adapter) + results[index] = result + if (!result.success) failed = true + } + } + await Promise.all(Array.from({ length: Math.min(concurrency, plan.entries.length) }, worker)) + return results +} + +async function executeEntry(entry: BoundedRuntimePlanEntry, materialized: { workspace: TWorkspace; runtime: TRuntime }, services: TServices, adapter: BoundedRuntimePlanAdapter): Promise { + const controller = new AbortController() + let timer: ReturnType | undefined + let operation: Promise<{ success: boolean; exitCode?: number; message?: string }> | undefined + try { + operation = adapter.execute({ entry, ...materialized, services, signal: controller.signal }) + const completed = entry.timeoutMs === undefined ? await operation : await Promise.race([ + operation, + new Promise((_, reject) => { timer = setTimeout(() => { controller.abort(); reject(new BoundedRuntimePlanTimeoutError(entry.id)) }, entry.timeoutMs) }), + ]) + return completed.success + ? baseResult(entry, "succeeded", true, completed.exitCode) + : { ...baseResult(entry, "failed", false, completed.exitCode), error: { code: "execution-failed", message: redactEntryMessage(completed.message || `Execution failed: ${entry.id}`, entry) } } + } catch (error) { + if (error instanceof BoundedRuntimePlanTimeoutError) { + // Keep the worker slot and shared services alive until the adapter confirms + // that the aborted process has actually terminated. + await operation?.catch(() => undefined) + return { ...baseResult(entry, "timed_out", false), error: { code: "timed-out", message: `Execution timed out: ${entry.id}` } } + } + return { ...baseResult(entry, "failed", false), error: { code: "execution-threw", message: redactEntryMessage(error instanceof Error ? error.message : `Execution threw: ${entry.id}`, entry) } } + } finally { + if (timer) clearTimeout(timer) + } +} + +class BoundedRuntimePlanTimeoutError extends Error {} + +function baseResult(entry: BoundedRuntimePlanEntry, status: BoundedRuntimePlanEntryStatus, success: boolean, exitCode?: number): BoundedRuntimePlanEntryResult { + return { id: entry.id, inputIndex: entry.inputIndex, processIdentity: entry.processIdentity, artifactNamespace: entry.artifactNamespace, status, success, ...(exitCode === undefined ? {} : { exitCode }) } +} + +function cancelledResult(entry: BoundedRuntimePlanEntry): BoundedRuntimePlanEntryResult { + return { ...baseResult(entry, "cancelled", false), error: { code: "fail-fast", message: `Execution was not started after an earlier failure: ${entry.id}` } } +} + +function safeIdentifier(value: unknown): value is string { + return typeof value === "string" && /^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) +} + +function redactEntryMessage(message: string, entry: BoundedRuntimePlanEntry): string { + return Object.values(entry.environment ?? {}).reduce((redacted, value) => value ? redacted.split(value).join("[redacted]") : redacted, message) +} diff --git a/packages/runtime-core/src/index.ts b/packages/runtime-core/src/index.ts index f4ef9cf6..e6777648 100644 --- a/packages/runtime-core/src/index.ts +++ b/packages/runtime-core/src/index.ts @@ -68,6 +68,7 @@ export * from "./host-command-executor.js" export * from "./managed-host-command.js" export * from "./run-registry.js" export * from "./run-plan.js" +export * from "./bounded-runtime-plan.js" export * from "./fanout-contracts.js" export * from "./fanout-aggregation.js" export * from "./fanout-execution.js" diff --git a/packages/runtime-core/src/public.ts b/packages/runtime-core/src/public.ts index 2705bb6c..5e1a2d6e 100644 --- a/packages/runtime-core/src/public.ts +++ b/packages/runtime-core/src/public.ts @@ -59,6 +59,7 @@ export * from "./recipe-run-summary.js" export * from "./recipe-schema.js" export * from "./recipe-source-packages.js" export * from "./run-plan.js" +export * from "./bounded-runtime-plan.js" export * from "./run-registry.js" export * from "./runner-workspace-publication.js" export * from "./runtime-boundary-contracts.js" diff --git a/packages/runtime-playground/src/playground-cli-runner.ts b/packages/runtime-playground/src/playground-cli-runner.ts index 2b866312..3e90f9e9 100644 --- a/packages/runtime-playground/src/playground-cli-runner.ts +++ b/packages/runtime-playground/src/playground-cli-runner.ts @@ -135,8 +135,8 @@ export async function startPlaygroundCliServer(spec: RuntimeCreateSpec, mounts: ...(bootstrapSharedMount ? [bootstrapSharedMount] : []), ...(wordpressDirectory ? [{ hostPath: wordpressDirectory, vfsPath: "/wordpress" }] : []), ], - wordpressInstallMode, } : {}), + ...(wordpressDirectory ? { wordpressInstallMode } : {}), wp: localAssetServer?.url ?? wordpressStartupAsset?.wp, php: spec.environment.phpVersion, skipSqliteSetup: spec.environment.databaseSetup === "external", @@ -372,7 +372,8 @@ function runtimeAutoPrependPhp(spec: RuntimeCreateSpec): string { } function runtimeAutoPrependPhpBody(spec: RuntimeCreateSpec): string { - return `${phpEnvAssignments(spec.runtimeEnv ?? {})}${distributionBootstrapPhp(spec)}` + const runtimeEnv = spec.environment.databaseSetup === "external" ? phpEnvAssignments(spec.runtimeEnv ?? {}) : "" + return `${runtimeEnv}${distributionBootstrapPhp(spec)}` } function distributionBootstrapPhp(spec: RuntimeCreateSpec): string { diff --git a/tests/bounded-runtime-plan.test.ts b/tests/bounded-runtime-plan.test.ts new file mode 100644 index 00000000..aefb2151 --- /dev/null +++ b/tests/bounded-runtime-plan.test.ts @@ -0,0 +1,111 @@ +import assert from "node:assert/strict" +import { executeBoundedRuntimePlan, retryBoundedRuntimePlan, type BoundedRuntimePlan, type BoundedRuntimePlanAdapter } from "../packages/runtime-core/src/index.js" + +const plan: BoundedRuntimePlan = { + schema: "wp-codebox/bounded-runtime-plan/v1", + concurrency: 2, + entries: [ + { id: "first", argv: ["phpunit", "first"], environment: { DB_NAME: "suite_first" }, timeoutMs: 100, processIdentity: "phpunit-first", artifactNamespace: "phpunit/first", inputIndex: 0 }, + { id: "failed", argv: ["phpunit", "failed"], environment: { DB_NAME: "suite_failed" }, timeoutMs: 100, processIdentity: "phpunit-failed", artifactNamespace: "phpunit/failed", inputIndex: 1 }, + { id: "slow", argv: ["phpunit", "slow"], environment: { DB_NAME: "suite_slow" }, timeoutMs: 5, processIdentity: "phpunit-slow", artifactNamespace: "phpunit/slow", inputIndex: 2 }, + { id: "last", argv: ["phpunit", "last"], environment: { DB_NAME: "suite_last" }, timeoutMs: 100, processIdentity: "phpunit-last", artifactNamespace: "phpunit/last", inputIndex: 3 }, + ], +} + +const lifecycle: string[] = [] +let active = 0 +let maximumActive = 0 +const executed: string[] = [] +const adapter: BoundedRuntimePlanAdapter<{ root: string }, { id: string }, { id: string }> = { + async materialize() { lifecycle.push("materialize"); return { workspace: { root: "/workspace" }, runtime: { id: "runtime" } } }, + async startServices() { lifecycle.push("start-services"); return { id: "mariadb-allocation" } }, + async execute({ entry, signal }) { + executed.push(entry.id) + active++ + maximumActive = Math.max(maximumActive, active) + try { + if (entry.id === "slow") await new Promise((resolve) => signal.addEventListener("abort", resolve, { once: true })) + else await new Promise((resolve) => setTimeout(resolve, entry.id === "first" ? 15 : 1)) + return { success: entry.id !== "failed", exitCode: entry.id === "failed" ? 1 : 0 } + } finally { + active-- + } + }, + async stopServices() { lifecycle.push("stop-services") }, + async dispose() { lifecycle.push("dispose") }, +} + +const result = await executeBoundedRuntimePlan(plan, adapter) +assert.equal(result.success, false) +assert.equal(maximumActive, 2, "execution never exceeds the declared concurrency") +assert.deepEqual(result.entries.map((entry) => entry.id), ["first", "failed", "slow", "last"], "aggregate order is input order rather than completion order") +assert.deepEqual(result.entries.map((entry) => entry.status), ["succeeded", "failed", "timed_out", "succeeded"]) +assert.deepEqual(lifecycle, ["materialize", "start-services", "stop-services", "dispose"], "workspace, service, and runtime lifecycles are each owned once") +assert.deepEqual(executed.sort(), ["failed", "first", "last", "slow"], "one failed entry does not isolate unrelated entries") + +const retry = retryBoundedRuntimePlan(plan, result) +assert.deepEqual(retry.entries.map((entry) => entry.id), ["failed", "slow"], "retry selects only unsuccessful prior entries") +assert.equal(retry.concurrency, 2) + +const redacted = await executeBoundedRuntimePlan({ + schema: "wp-codebox/bounded-runtime-plan/v1", + concurrency: 1, + entries: [{ id: "secret", argv: ["phpunit"], environment: { DB_PASSWORD: "not-for-review" }, processIdentity: "phpunit-secret", artifactNamespace: "phpunit/secret", inputIndex: 0 }], +}, { + async materialize() { return { workspace: undefined, runtime: undefined } }, + async startServices() { return undefined }, + async execute() { return { success: false, message: "database password=not-for-review" } }, + async stopServices() {}, + async dispose() {}, +}) +assert.equal(redacted.entries[0]?.error?.message, "database password=[redacted]", "reviewer-facing results redact entry environment values") + +const failFastPlan = { ...plan, failFast: true, concurrency: 1, entries: plan.entries.slice(1) } +const failFast = await executeBoundedRuntimePlan(failFastPlan, adapter) +assert.deepEqual(failFast.entries.map((entry) => entry.status), ["failed", "cancelled", "cancelled"], "explicit fail-fast leaves later entries unstarted") + +let timedOutProcessActive = false +const timeoutOrder: string[] = [] +const timeoutResult = await executeBoundedRuntimePlan({ + schema: "wp-codebox/bounded-runtime-plan/v1", + concurrency: 1, + entries: [ + { id: "timeout", argv: ["phpunit"], timeoutMs: 5, processIdentity: "timeout", artifactNamespace: "timeout", inputIndex: 0 }, + { id: "after-timeout", argv: ["phpunit"], processIdentity: "after-timeout", artifactNamespace: "after-timeout", inputIndex: 1 }, + ], +}, { + async materialize() { return { workspace: undefined, runtime: undefined } }, + async startServices() { return undefined }, + async execute({ entry, signal }) { + if (entry.id === "timeout") { + timedOutProcessActive = true + await new Promise((resolve) => signal.addEventListener("abort", () => setTimeout(resolve, 10), { once: true })) + timedOutProcessActive = false + timeoutOrder.push("timeout-stopped") + return { success: false } + } + assert.equal(timedOutProcessActive, false, "the next entry starts only after the timed-out process stops") + timeoutOrder.push("next-started") + return { success: true } + }, + async stopServices() { timeoutOrder.push("services-stopped") }, + async dispose() { timeoutOrder.push("runtime-disposed") }, +}) +assert.deepEqual(timeoutResult.entries.map((entry) => entry.status), ["timed_out", "succeeded"]) +assert.deepEqual(timeoutOrder, ["timeout-stopped", "next-started", "services-stopped", "runtime-disposed"]) + +const teardownOrder: string[] = [] +await assert.rejects(executeBoundedRuntimePlan({ + schema: "wp-codebox/bounded-runtime-plan/v1", + concurrency: 1, + entries: [{ id: "teardown", argv: ["phpunit"], processIdentity: "teardown", artifactNamespace: "teardown", inputIndex: 0 }], +}, { + async materialize() { return { workspace: undefined, runtime: undefined } }, + async startServices() { return undefined }, + async execute() { return { success: true } }, + async stopServices() { teardownOrder.push("services-stopped"); throw new Error("service cleanup failed") }, + async dispose() { teardownOrder.push("runtime-disposed") }, +}), /service cleanup failed/) +assert.deepEqual(teardownOrder, ["services-stopped", "runtime-disposed"], "runtime disposal survives service cleanup failure") + +console.log("bounded runtime plan tests passed") diff --git a/tests/disposable-mysql-mysqli.integration.test.ts b/tests/disposable-mysql-mysqli.integration.test.ts index 9a42b565..af793869 100644 --- a/tests/disposable-mysql-mysqli.integration.test.ts +++ b/tests/disposable-mysql-mysqli.integration.test.ts @@ -5,6 +5,8 @@ import { tmpdir } from "node:os" import { join } from "node:path" import { promisify } from "node:util" import { runRecipe } from "../packages/cli/src/commands/recipe-run.ts" +import { provisionRuntimeServices } from "../packages/cli/src/runtime-services.ts" +import { executeBoundedRuntimePlan } from "../packages/runtime-core/src/index.js" const execFileAsync = promisify(execFile) @@ -65,6 +67,42 @@ if (!await dockerAvailable()) { }) assert.equal(mariaDbResult.success, true) assert.equal(mariaDbResult.executions.at(-1)?.stdout.trim(), "1") + + // The aggregate adapter models two PHPUnit invocations. They share the one + // MariaDB allocation but each receives its own database identity. + const allocation = await provisionRuntimeServices([{ + id: "phpunit-mariadb", + kind: "mysql", + configuration: { engine: "mariadb", rootAuthentication: "empty-password" }, + outputs: { host: "DB_HOST", port: "DB_PORT" }, + }]) + try { + const container = (await execFileAsync("docker", ["ps", "--filter", "name=wp-codebox-phpunit-mariadb", "--format", "{{.Names}}"], { timeout: 10_000 })).stdout.trim() + assert.equal(container.split("\n").filter(Boolean).length, 1, "two entries use one disposable MariaDB allocation") + const aggregate = await executeBoundedRuntimePlan({ + schema: "wp-codebox/bounded-runtime-plan/v1", + concurrency: 2, + entries: [ + { id: "phpunit-one", argv: ["phpunit", "--testsuite=one"], environment: { DB_NAME: "phpunit_one" }, processIdentity: "phpunit-one", artifactNamespace: "phpunit/one", inputIndex: 0 }, + { id: "phpunit-two", argv: ["phpunit", "--testsuite=two"], environment: { DB_NAME: "phpunit_two" }, processIdentity: "phpunit-two", artifactNamespace: "phpunit/two", inputIndex: 1 }, + ], + }, { + async materialize() { return { workspace: undefined, runtime: undefined } }, + async startServices() { return allocation }, + async execute({ entry }) { + const database = entry.environment?.DB_NAME + await execFileAsync("docker", ["exec", container, "mariadb", "-uroot", "-e", `CREATE DATABASE \`${database}\`; CREATE TABLE \`${database}\`.entry_identity (id INT);`], { timeout: 30_000 }) + return { success: true, exitCode: 0 } + }, + async stopServices() {}, + async dispose() {}, + }) + assert.equal(aggregate.success, true) + assert.deepEqual(aggregate.entries.map((entry) => entry.artifactNamespace), ["phpunit/one", "phpunit/two"]) + assert.deepEqual(aggregate.entries.map((entry) => entry.inputIndex), [0, 1]) + } finally { + await allocation.release() + } console.log("disposable MySQL and MariaDB mysqli E2E passed") } finally { await rm(directory, { recursive: true, force: true }) diff --git a/tests/playground-cli-runner-bootstrap-ini.test.ts b/tests/playground-cli-runner-bootstrap-ini.test.ts index f39c8578..1fc97326 100644 --- a/tests/playground-cli-runner-bootstrap-ini.test.ts +++ b/tests/playground-cli-runner-bootstrap-ini.test.ts @@ -101,6 +101,25 @@ try { assert.equal(calls[0].skipSqliteSetup, false) assert.equal(shouldUseProgrammaticPlaygroundRunner(defaultRuntimeIniSpec), true) + calls.length = 0 + const downloadedWordPressSpec: RuntimeCreateSpec = { + ...defaultRuntimeIniSpec, + environment: { + ...defaultRuntimeIniSpec.environment, + version: "latest", + wordpressInstallMode: undefined, + assets: undefined, + }, + } + + const downloadedWordPressServer = await startPlaygroundCliServer(downloadedWordPressSpec, [], { cliModule }) + await downloadedWordPressServer[Symbol.asyncDispose]() + + assert.equal(calls.length, 1) + assert.equal(calls[0]["mount-before-install"], undefined) + assert.equal(calls[0].wordpressInstallMode, undefined) + assert.equal(shouldUseProgrammaticPlaygroundRunner(downloadedWordPressSpec), false) + calls.length = 0 const distributionOnlySpec: RuntimeCreateSpec = { ...spec,