t3-code-android-nightly/.repos/alchemy-effect/examples/dev-stress/test/dev-stress.test.ts

1636 lines
60 KiB
TypeScript

/**
* `alchemy dev` stress suite.
*
* Every other dev test in this repo asserts that a stack CONVERGES. This
* one asserts that the dev server SURVIVES — that no sequence of edits a
* developer can perform while `alchemy dev` is running kills it, wedges it,
* or leaves it serving stale code.
*
* Topology under test (one cross-cloud stack, see ../alchemy.run.ts):
*
* AWS (floci emulator) Cloudflare (workerd + docker)
* ──────────────────── ─────────────────────────────
* Lambda ApiFunction ◀──────── Worker ApiWorker (path `main`)
* ├ S3 StressBucket cross- Worker EchoWorker (Effect-native `main`)
* ├ DynamoDB Table cloud ├ KV + R2 + Durable Object
* └ SQS + consumer hop └ Container SandboxContainer
* Website.StaticSite Website.StaticSite (build mode)
* (dev-command child)
*
* The suite runs ONE dev server for its whole lifetime and accumulates
* churn on it — that accumulation IS the stress. Phases run in order and
* each one leaves the stack healthy for the next.
*
* Two distinct reload paths are told apart throughout by
* `server.planCount`, which counts `Plan:` renders (i.e. exec-child
* restarts). Both paths put new code in front of a request within ~2s —
* the local Worker provider's bundler watch loop always wins that race —
* so the discriminator is what happens AFTER the swap:
*
* - a file only the BUNDLER sees → hot swap, and `planCount` never
* (`src/api/marker.ts`) moves, however long you wait.
* - a file the STACK imports → hot swap, and then `bun --watch`
* (`src/echo/marker.ts`) re-runs the stack (~4s), replan,
* re-apply. `planCount` goes UP.
*
* A failed rebuild takes its own Worker off the air until the next good
* save; what must never happen is the CLI dying or an UNRELATED resource
* going with it. That is what the resilience phases assert.
*
* Requires Docker: the AWS half runs in floci and the Cloudflare Container
* runs as a real container. No cloud credentials are used or needed.
*/
import { afterAll, beforeAll, expect, test } from "bun:test";
import * as fs from "node:fs";
import { PORTS } from "../src/ports.ts";
import {
at,
DevServer,
dockerAvailable,
fetchJson,
fetchOk,
freePinnedPorts,
makeScratchProject,
PollTimeout,
resetFlociEmulator,
pollUntil,
waitForJson,
} from "./harness.ts";
import {
echoInboxClass,
echoQueueBindings,
echoQueueLayers,
echoQueueRoutes,
echoStreamImport,
extraWorkerDeclaration,
extraWorkerOutput,
extraWorkerSource,
lambdaArchiveBindings,
lambdaArchiveRoutes,
portSquatterDeclaration,
portSquatterSource,
reportFunctionDeclaration,
reportFunctionImport,
reportFunctionOutput,
reportFunctionSource,
secondImageBindings,
secondImageImport,
secondImageLayer,
secondImageRoutes,
secondImageSource,
secondImageWorkerImport,
} from "./mutations.ts";
const STAGE = "dev-stress";
// Long budgets: the first apply builds a Lambda bundle and a MicroVM
// image, boots floci, starts four workerd instances, runs a site build,
// and pulls a container image. Later phases are fast because only the
// delta re-applies.
//
// These are deliberately LARGER than the sum of every bounded poll inside
// a phase. `bun test` kills every child process the file spawned the
// moment a test hits its timeout (`killed N dangling processes`) — which
// would take the dev server down and turn one slow assertion into a
// cascade of meaningless failures in every later phase. A phase must
// therefore always fail through a `pollUntil` timeout (server still
// alive, output tail attached), never through bun's.
const BOOT_TIMEOUT = 1_800_000;
const PHASE_TIMEOUT = 1_800_000;
let server: DevServer;
let cfSiteUrl: string;
/** Cursor into the CLI output, advanced past phases that expect errors. */
let cleanCursor = 0;
/** URL on a floci host-routed address (`*.localhost.floci.io`). */
const at2 = (host: string, port: number, path: string) =>
new URL(path, `http://${host}:${port}`);
const echo = (path: string) => at(PORTS.echo, path);
const api = (path: string) => at(PORTS.api, path);
const extra = (path: string) => at(PORTS.extra, path);
const awsSite = (path: string) => at(PORTS.awsSite, path);
const extraAlt = (path: string) => at(PORTS.extraAlt, path);
const microvm = (path: string) => at(PORTS.microvm, path);
const ecs = (path: string) => at(PORTS.ecs, path);
const ecsInline = (path: string) => at(PORTS.ecsInline, path);
/**
* Poll an ECS-served URL until its TEXT body matches. The busybox httpd
* containers serve plain files; a container mid-roll refuses connections.
*/
const waitForText = async (
what: string,
url: URL,
expected: string,
options?: { tries?: number; delayMs?: number },
): Promise<void> => {
let last = "no response yet";
try {
await pollUntil(
what,
async () => {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
const text = (await res.text()).trim();
last = `${res.status} ${text.slice(0, 200)}`;
return res.ok && text === expected ? true : undefined;
} catch (cause) {
last = `error: ${String(cause).slice(0, 200)}`;
return undefined;
}
},
{ tries: 240, delayMs: 1_000, server, ...options },
);
} catch (cause) {
if (cause instanceof PollTimeout) {
cause.message = `${cause.message.split("\n")[0]}\n--- last response from ${url} ---\n${last}\n${cause.message.split("\n").slice(1).join("\n")}`;
}
throw cause;
}
};
/** Source text for a marker module — the suite's unit of observable change. */
const markerModule = (name: string, value: string) =>
`export const ${name} = ${JSON.stringify(value)};\n`;
let lambdaMarkerSeq = 0;
/**
* Bump the Lambda's marker and wait for the emulator to serve it, observed
* THROUGH the cross-cloud hop. Called after every phase that makes the
* engine re-reconcile the Function, because that is exactly where hot
* swap used to die: an engine update re-points the function's code at a
* content-addressed S3 key, detaching it from the watch loop's stable dev
* key, and the next marker edit was silently ignored until the next engine
* update (fixed in FlociFunctionProvider — this pins it).
*/
const expectLambdaHotSwapStillWorks = async (context: string) => {
const marker = `lambda-after-${context}-${++lambdaMarkerSeq}`;
server.write("src/lambda/marker.ts", markerModule("LAMBDA_MARKER", marker));
await waitForJson<{ marker: string }>(
`the Lambda to hot-swap to ${marker} (${context})`,
api("/aws/"),
(body) => body.marker === marker,
{ tries: 120, delayMs: 1_000, server },
);
};
const messageModule = (value: string) =>
`export const message = () => ${JSON.stringify(value)};\n`;
/** Is a port refusing connections (i.e. nothing is serving there)? */
const isClosed = async (url: URL) => {
try {
await fetch(url, { signal: AbortSignal.timeout(2_000) });
return false;
} catch {
return true;
}
};
/**
* Has `url` stopped serving the worker that used to answer with `marker`?
* A deleted local Worker normally closes its port, but a lingering dev
* proxy answering 5xx counts as "gone" too — what matters is that the
* removed code is no longer reachable.
*/
const stoppedServing = async (url: URL, marker: string) => {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(2_000) });
if (!res.ok) return true;
const body = (await res.json()) as { marker?: string };
return body.marker !== marker;
} catch {
return true;
}
};
beforeAll(async () => {
// Docker is a hard requirement, not a reason to skip: floci hosts the
// whole AWS half and the Container and MicroVM are real containers. A
// machine without it must fail this suite, not quietly pass it.
if (!dockerAvailable) {
throw new Error(
"dev-stress requires Docker (floci, Cloudflare Containers, MicroVMs). " +
"Start the Docker daemon and re-run.",
);
}
resetFlociEmulator();
freePinnedPorts(Object.values(PORTS));
const cwd = makeScratchProject(STAGE);
server = new DevServer({ cwd, stage: STAGE });
// The first apply is done when the CLI prints its `Done:` summary.
await pollUntil(
"the first dev apply to complete",
() => (server.doneCount >= 1 ? true : undefined),
{ tries: 1_700, delayMs: 1_000, server },
);
}, BOOT_TIMEOUT);
afterAll(async () => {
if (!server) return;
await server.shutdown();
if (!process.env.NO_DESTROY) {
server.destroyStack();
if (!process.env.DEBUG) {
fs.rmSync(server.cwd, { recursive: true, force: true });
}
}
}, 300_000);
// ───────────────────────────────────────────────────────────────────────
// Phase 1 — the stack converges, locally, across both clouds.
// ───────────────────────────────────────────────────────────────────────
test(
"boots: every local resource serves, and the cross-cloud hop works",
async () => {
server.assertAlive("boot");
// ── Cloudflare, Effect-native worker ──
expect(
await fetchJson<{ marker: string }>(echo("/marker")),
).toEqual({ marker: "echo-v1" });
expect(await fetchJson<{ value: string }>(echo("/kv?key=boot"))).toEqual({
value: "kv:boot",
});
const r2 = await fetchJson<{ text: string; keys: string[] }>(echo("/r2"));
expect(r2.text).toBe("hello from r2");
expect(r2.keys).toContain("hello.txt");
// Durable Object state is real state, not a stub.
const first = await fetchJson<{ count: number }>(echo("/counter"));
const second = await fetchJson<{ count: number }>(echo("/counter"));
expect(second.count).toBe(first.count + 1);
// ── Cloudflare Container (docker), reached through the DO ──
const sandbox = await fetchJson<{ greeting: string; marker: string }>(
echo("/sandbox"),
undefined,
// The image may still be pulling/starting on the first request.
{ tries: 180, delayMs: 1_000 },
);
expect(sandbox.greeting).toBe("hello-from-container");
expect(sandbox.marker).toBe("sandbox-v1");
// ── #1334: the container reaches a service on the HOST through an env
// var written as `http://localhost:…` (the dev runtime rewrites the
// loopback host to `host.docker.localhost`; Linux unix-socket-tunnels
// it into the sidecar netns instead of SYNing the docker bridge) ──
const hostFetch = await fetchJson<{ target: string; body: string }>(
echo("/sandbox/host-fetch"),
undefined,
{ tries: 60, delayMs: 1_000 },
);
expect(hostFetch.target).toBe("http://host.docker.localhost:8793");
expect(hostFetch.body).toContain("aws-site-env-v1");
// ── Cloudflare, path-`main` worker ──
expect(
await fetchJson<{ marker: string; message: string }>(api("/marker")),
).toEqual({ marker: "api-v1", message: "message-v1" });
const env = await fetchJson<{
API_VARIABLE: string;
AWS_LAMBDA_URL: string;
}>(api("/env"));
expect(env.API_VARIABLE).toBe("api-variable-v1");
// Local identity: the Lambda URL is the emulator's, not real AWS.
expect(env.AWS_LAMBDA_URL).toContain("localhost:4566");
// ── CROSS-CLOUD: local workerd → the floci-hosted Lambda URL ──
const lambda = await fetchJson<{ marker: string; variable: string }>(
api("/aws/"),
undefined,
{ tries: 120, delayMs: 1_000 },
);
expect(lambda).toEqual({
marker: "lambda-v1",
variable: "lambda-variable-v1",
});
// ── AWS data plane, driven through the cross-cloud hop ──
expect(await fetchJson<{ text: string }>(api("/aws/s3"))).toEqual({
text: "hello from s3",
});
expect(await fetchJson<{ text: string }>(api("/aws/dynamo"))).toEqual({
text: "hello from dynamo",
});
// SQS produce → floci poller → the same Lambda's consumer → DynamoDB.
const message = { id: crypto.randomUUID() };
await fetchOk(api("/aws/queue/send"), {
method: "POST",
body: JSON.stringify(message),
});
const delivered = await waitForJson<{ body: string | null }>(
"the queue message to be consumed",
api(`/aws/queue/messages?id=${message.id}`),
(body) => body.body !== null,
{ tries: 120, delayMs: 500, server },
);
expect(JSON.parse(delivered.body!)).toEqual(message);
// ── AWS ECS: floci runs the services' tasks as real containers on
// the host daemon; bridge networking publishes the baked ports. First
// contact can wait out the image build + scheduler launch.
await waitForText(
"the context-built ECS service to serve",
ecs("/"),
"ecs-site-v1",
{ tries: 300 },
);
await waitForText(
"the ECS service's baked Dockerfile marker",
ecs("/baked.txt"),
"dockerfile-v1",
);
await waitForText(
"the ECS service's task-definition env",
ecs("/env.txt"),
"ecs-env-v1",
);
await waitForText(
"the inline-Dockerfile ECS service to serve",
ecsInline("/"),
"ecs-inline-v1",
{ tries: 300 },
);
// ── AWS Website: the dev-command child, on its pinned port ──
expect(await (await fetchOk(awsSite("/"))).text()).toContain("aws-site-v1");
const siteEnv = await fetchJson<{ marker: string; pid: number }>(
awsSite("/__dev-env"),
);
expect(siteEnv.marker).toBe("aws-site-env-v1");
// ── Cloudflare Website: build mode, served by a local Worker ──
cfSiteUrl = await pollUntil(
"cfSiteUrl in the stack outputs",
() => server.outputUrl("cfSiteUrl"),
{ tries: 60, delayMs: 1_000, server },
);
expect(cfSiteUrl).toMatch(/^http:\/\/localhost:\d+/);
expect(await (await fetchOk(new URL("/", cfSiteUrl))).text()).toContain(
"cf-site-v1",
);
// Nothing in a clean boot may look like a failure.
expect(server.output).not.toContain("alchemy dev: run failed");
expect(server.output).not.toContain("alchemy dev: apply failed");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"cross-cloud: a Cloudflare Worker boots an AWS Lambda MicroVM and drives both its protocols",
async () => {
// Boot → wait for RUNNING → auth token → typed RPC + raw HTTPS →
// terminate, all from local workerd against the floci emulator.
const roundtrip = await fetchJson<{
microvmId: string;
reply: string;
marker: string;
echo: string;
}>(microvm("/roundtrip?message=stress"), undefined, {
tries: 240,
delayMs: 1_000,
});
expect(roundtrip.microvmId).toBeTruthy();
expect(roundtrip.reply).toBe("hello, stress!");
expect(roundtrip.marker).toBe("vm-v1");
expect(roundtrip.echo).toBe("stress");
server.assertAlive("microvm roundtrip");
},
PHASE_TIMEOUT,
);
// ───────────────────────────────────────────────────────────────────────
// Phase 2 — the two reload paths, told apart.
// ───────────────────────────────────────────────────────────────────────
test(
"hot reload (bundler path): editing a file the stack never imports swaps the script without re-running the stack",
async () => {
const plansBefore = server.planCount;
const counterBefore = (
await fetchJson<{ count: number }>(echo("/counter"))
).count;
server.write("src/api/marker.ts", markerModule("API_MARKER", "api-v2"));
await waitForJson<{ marker: string }>(
"ApiWorker to serve api-v2",
api("/marker"),
(body) => body.marker === "api-v2",
{ server },
);
// The whole point of this path: the stack process never re-ran. A
// re-plan would trail the swap by a couple of seconds, so settle well
// past that before claiming it never came.
await Bun.sleep(20_000);
expect(server.planCount).toBe(plansBefore);
server.assertAlive("bundler hot swap");
// And nothing else moved: the sibling worker's DO kept its state.
const counterAfter = (await fetchJson<{ count: number }>(echo("/counter")))
.count;
expect(counterAfter).toBe(counterBefore + 1);
},
PHASE_TIMEOUT,
);
test(
"hot reload (watch path): editing a file the stack imports re-runs the stack and keeps sidecar children alive",
async () => {
const plansBefore = server.planCount;
// The dev-command child lives in the provider sidecar, which is meant
// to survive user-code restarts. Its pid is the proof.
const sitePidBefore = (
await fetchJson<{ pid: number }>(awsSite("/__dev-env"))
).pid;
server.write("src/echo/marker.ts", markerModule("ECHO_MARKER", "echo-v2"));
await waitForJson<{ marker: string }>(
"EchoWorker to serve echo-v2",
echo("/marker"),
(body) => body.marker === "echo-v2",
{ server },
);
// The re-plan trails the hot swap — wait for it rather than sampling.
await server.waitForPlanAfter(plansBefore);
server.assertAlive("watch-path reload");
// The sidecar-hosted dev server was NOT bounced by a user-code reload.
const sitePidAfter = (await fetchJson<{ pid: number }>(awsSite("/__dev-env")))
.pid;
expect(sitePidAfter).toBe(sitePidBefore);
// The bundler-path worker was not disturbed either.
expect(
(await fetchJson<{ marker: string }>(api("/marker"))).marker,
).toBe("api-v2");
},
PHASE_TIMEOUT,
);
test(
"hot reload (AWS): editing the Lambda's source hot-swaps its code in the emulator",
async () => {
server.write(
"src/lambda/marker.ts",
markerModule("LAMBDA_MARKER", "lambda-v2"),
);
// Observed THROUGH the cross-cloud hop, so this also re-proves that the
// Worker → Lambda edge still resolves after both sides reloaded.
await waitForJson<{ marker: string }>(
"the Lambda to serve lambda-v2",
api("/aws/"),
(body) => body.marker === "lambda-v2",
{ tries: 240, delayMs: 1_000, server },
);
server.assertAlive("lambda hot swap");
// Bindings survived the swap.
expect((await fetchJson<{ text: string }>(api("/aws/s3"))).text).toBe(
"hello from s3",
);
},
PHASE_TIMEOUT,
);
// ───────────────────────────────────────────────────────────────────────
// Phase 3 — files that move out from under the bundler.
// ───────────────────────────────────────────────────────────────────────
test(
"surviving a moved module: the importer points at a missing path, then the file arrives",
async () => {
const importer = server.read("src/api-worker.ts");
// Deliberately out of order: rewrite the import FIRST, so the bundler
// is asked to resolve a module that does not exist yet.
server.write(
"src/api-worker.ts",
importer.replace('"./api/message.ts"', '"./api/nested/message.ts"'),
);
await Bun.sleep(8_000);
// A failed rebuild takes ITS worker off the air — that part is fine.
// What must hold is that the dev server lives and every unrelated
// resource is untouched.
server.assertAlive("broken import window");
expect(
(await fetchJson<{ marker: string }>(echo("/marker"))).marker,
).toBe("echo-v2");
expect(await (await fetchOk(awsSite("/"))).text()).toContain("aws-site-v1");
// Complete the move, with a NEW value so "the rebuild happened" cannot
// be confused with "the old bundle is still serving".
server.remove("src/api/message.ts");
server.write("src/api/nested/message.ts", messageModule("message-moved"));
await waitForJson<{ message: string }>(
"ApiWorker to rebuild against the moved module",
api("/marker"),
(body) => body.message === "message-moved",
{ tries: 180, delayMs: 500, server },
);
// The NEW path is watched too, not just resolved once.
server.write("src/api/nested/message.ts", messageModule("message-v2"));
await waitForJson<{ message: string }>(
"the moved module's edit to land",
api("/marker"),
(body) => body.message === "message-v2",
{ server },
);
server.assertAlive("moved module reload");
},
PHASE_TIMEOUT,
);
// ───────────────────────────────────────────────────────────────────────
// Phase 4 — broken states. The dev server must log and wait, never exit.
// ───────────────────────────────────────────────────────────────────────
test(
"surviving a syntax error in a stack-imported module",
async () => {
const plansBefore = server.planCount;
const cursor = server.mark();
// Unterminated string literal: the module cannot even be parsed, so
// importing the stack throws before any Alchemy code runs.
server.write("src/echo/marker.ts", 'export const ECHO_MARKER = "oops\n');
await pollUntil(
"the CLI to report the failed run",
() =>
/alchemy dev: run failed|SyntaxError|Unterminated|Unexpected end/.test(
server.since(cursor),
) || undefined,
{ tries: 120, delayMs: 500, server },
);
server.assertAlive("stack syntax error");
// EchoWorker is down (its bundle no longer builds), but every resource
// that does not depend on the broken module keeps serving: they live in
// the provider sidecar, which the exec child's crash does not touch.
expect(
(await fetchJson<{ marker: string }>(api("/marker"))).marker,
).toBe("api-v2");
expect(await (await fetchOk(awsSite("/"))).text()).toContain("aws-site-v1");
// Recovery on the next save.
server.write("src/echo/marker.ts", markerModule("ECHO_MARKER", "echo-v3"));
await waitForJson<{ marker: string }>(
"EchoWorker to recover on echo-v3",
echo("/marker"),
(body) => body.marker === "echo-v3",
{ tries: 240, delayMs: 500, server },
);
await server.waitForPlanAfter(plansBefore);
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"surviving a throw at module scope",
async () => {
const config = server.read("src/stack-config.ts");
server.write(
"src/stack-config.ts",
`${config}\nthrow new Error("dev-stress: deliberate module-scope failure");\n`,
);
await pollUntil(
"the CLI to report the failed run",
() =>
server.since(cleanCursor).includes("deliberate module-scope failure")
? true
: undefined,
{ tries: 120, delayMs: 500, server },
);
server.assertAlive("module-scope throw");
expect(server.since(cleanCursor)).toContain("alchemy dev: run failed");
// Restore and converge.
server.write("src/stack-config.ts", config);
server.write("src/echo/marker.ts", markerModule("ECHO_MARKER", "echo-v4"));
await waitForJson<{ marker: string }>(
"EchoWorker to recover on echo-v4",
echo("/marker"),
(body) => body.marker === "echo-v4",
{ tries: 240, delayMs: 500, server },
);
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"surviving an apply failure: healthy resources keep serving while one resource cannot reconcile",
async () => {
// A second Worker that demands a port EchoWorker already holds. The
// stack imports and plans fine; the reconcile is what fails — which is
// the case that has to leave the rest of the stack serving. (A Worker
// with a missing `main` is NOT such a case: the local provider creates
// it happily.)
server.write("src/extra/squatter.ts", portSquatterSource);
server.patchRegion("alchemy.run.ts", "EXTRA", portSquatterDeclaration);
await pollUntil(
"the CLI to report the failed apply",
() =>
/alchemy dev: apply failed/.test(server.since(cleanCursor)) ||
undefined,
{ tries: 240, delayMs: 500, server },
);
server.assertAlive("apply failure");
expect(server.since(cleanCursor)).toContain("already in use");
// Everything healthy is still healthy.
expect(
(await fetchJson<{ marker: string }>(echo("/marker"))).marker,
).toBe("echo-v4");
expect(
(await fetchJson<{ marker: string }>(api("/marker"))).marker,
).toBe("api-v2");
expect((await fetchJson<{ text: string }>(api("/aws/s3"))).text).toBe(
"hello from s3",
);
server.patchRegion("alchemy.run.ts", "EXTRA", "");
server.remove("src/extra/squatter.ts");
server.write("src/echo/marker.ts", markerModule("ECHO_MARKER", "echo-v5"));
await waitForJson<{ marker: string }>(
"the stack to converge again",
echo("/marker"),
(body) => body.marker === "echo-v5",
{ tries: 240, delayMs: 500, server },
);
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
// ───────────────────────────────────────────────────────────────────────
// Phase 5 — the resource GRAPH changes shape. This is the substance of the
// suite: resources appear, get renamed, get replaced and disappear while
// the dev server keeps running, across both clouds and every resource kind
// the stack has (Workers, a Lambda, buckets, a queue + its event source, a
// Durable Object class, a MicroVM image).
// ───────────────────────────────────────────────────────────────────────
test(
"graph churn: a Cloudflare Worker can be added, renamed onto a new port, and removed",
async () => {
// ── add ──
server.write("src/extra/extra-worker.ts", extraWorkerSource("extra-v1"));
server.patchRegion(
"alchemy.run.ts",
"EXTRA",
extraWorkerDeclaration("ExtraWorker", "extra"),
);
server.patchRegion("alchemy.run.ts", "EXTRA_OUTPUTS", extraWorkerOutput);
await waitForJson<{ marker: string }>(
"the added ExtraWorker to serve",
extra("/"),
(body) => body.marker === "extra-v1",
{ tries: 240, delayMs: 500, server },
);
// The worker serves before the stack's re-apply prints its outputs —
// the output line is the proof the ENGINE saw the new resource.
await pollUntil(
"extraUrl in the stack outputs",
() => server.outputUrl("extraUrl"),
{ tries: 120, delayMs: 500, server },
);
server.assertAlive("worker added");
// ── rename (a new logical id is a create + a delete) ──
// The new generation lands on its own port: the engine may create
// before it deletes, and two `strictPort` workers cannot share one.
server.patchRegion(
"alchemy.run.ts",
"EXTRA",
extraWorkerDeclaration("RenamedWorker", "extraAlt"),
);
server.write("src/extra/extra-worker.ts", extraWorkerSource("extra-v2"));
await waitForJson<{ marker: string }>(
"the renamed worker to serve on its new port",
extraAlt("/"),
(body) => body.marker === "extra-v2",
{ tries: 240, delayMs: 500, server },
);
// …and the old generation is gone.
await pollUntil(
"the old worker's port to stop serving",
async () => ((await isClosed(extra("/"))) ? true : undefined),
{ tries: 120, delayMs: 500, server },
);
server.assertAlive("worker renamed");
// ── remove (output reference first, so the stack never names a
// binding that no longer exists) ──
server.patchRegion("alchemy.run.ts", "EXTRA_OUTPUTS", "");
server.patchRegion("alchemy.run.ts", "EXTRA", "");
server.remove("src/extra/extra-worker.ts");
await pollUntil(
"the removed worker to stop serving",
async () =>
(await stoppedServing(extraAlt("/"), "extra-v2")) ? true : undefined,
{ tries: 240, delayMs: 500, server },
);
server.assertAlive("worker removed");
// The survivors are untouched.
expect(
(await fetchJson<{ marker: string }>(echo("/marker"))).marker,
).toBe("echo-v5");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"graph churn: a whole AWS Lambda (with its own S3 bucket) can be added and removed",
async () => {
// ── add: a new module, a new top-level import in the stack, a new
// Function resource, a new Bucket resource, and two new bindings ──
server.write(
"src/extra/ReportFunction.ts",
reportFunctionSource("report-v1"),
);
server.patchRegion("alchemy.run.ts", "EXTRA_IMPORTS", reportFunctionImport);
server.patchRegion("alchemy.run.ts", "EXTRA", reportFunctionDeclaration);
server.patchRegion("alchemy.run.ts", "EXTRA_OUTPUTS", reportFunctionOutput);
const reportUrl = await pollUntil(
"the new Lambda's function URL in the stack outputs",
() => server.outputUrl("reportUrl"),
{ tries: 480, delayMs: 500, server },
);
// Local identity: a floci URL, never real AWS.
expect(reportUrl).toContain("localhost:4566");
const report = await fetchJson<{ marker: string; text: string }>(
new URL("/report", reportUrl),
undefined,
{ tries: 240, delayMs: 500 },
);
expect(report).toEqual({ marker: "report-v1", text: "report-v1" });
server.assertAlive("lambda added");
// ── change it: a new marker means a new bundle for a Function that
// only came into existence a moment ago ──
server.write(
"src/extra/ReportFunction.ts",
reportFunctionSource("report-v2"),
);
await waitForJson<{ marker: string }>(
"the added Lambda to hot-swap to report-v2",
new URL("/report", reportUrl),
(body) => body.marker === "report-v2",
{ tries: 480, delayMs: 500, server },
);
// ── remove: the Function, its Bucket and its URL all go away ──
server.patchRegion("alchemy.run.ts", "EXTRA_OUTPUTS", "");
server.patchRegion("alchemy.run.ts", "EXTRA", "");
server.patchRegion("alchemy.run.ts", "EXTRA_IMPORTS", "");
server.remove("src/extra/ReportFunction.ts");
await pollUntil(
"the removed Lambda to stop answering",
async () => {
try {
const res = await fetch(new URL("/report", reportUrl), {
signal: AbortSignal.timeout(5_000),
});
return res.ok ? undefined : true;
} catch {
return true;
}
},
{ tries: 240, delayMs: 500, server },
);
server.assertAlive("lambda removed");
// The original Lambda is untouched by its sibling's whole lifecycle.
expect((await fetchJson<{ text: string }>(api("/aws/s3"))).text).toBe(
"hello from s3",
);
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"graph churn: a Queue, its consumer event source, and a new Durable Object class are grafted onto a LIVE Worker",
async () => {
const plansBefore = server.planCount;
// Three kinds of change at once, all on a Worker that is already
// serving: a new resource (the Queue), a new event source (its
// consumer), and a new Durable Object class — which means a class
// migration on a running script.
server.patchRegion("src/EchoWorker.ts", "ECHO_IMPORTS", echoStreamImport);
server.patchRegion("src/EchoWorker.ts", "ECHO_BINDINGS", echoQueueBindings);
server.patchRegion("src/EchoWorker.ts", "ECHO_ROUTES", echoQueueRoutes);
server.patchRegion("src/EchoWorker.ts", "ECHO_LAYERS", echoQueueLayers);
server.patchRegion("src/EchoWorker.ts", "ECHO_CLASSES", echoInboxClass);
// The bundler hot-swaps the new script BEFORE the stack re-apply has
// registered the `Inbox` class and the queue consumer, so for a beat
// the Worker serves a bundle whose DO binding workerd rejects
// (`DurableObject 'Inbox' not found`). A message sent in that window
// has no consumer and is lost. Wait for the re-apply to land first;
// the self-healing is what's under test, not the window.
await server.waitForPlanAfter(plansBefore, { tries: 480 });
await pollUntil(
"the re-apply that registers the Queue, consumer and DO to finish",
() =>
/\[EchoQueueConsumer\] created/.test(server.since(cleanCursor))
? true
: undefined,
{ tries: 480, delayMs: 500, server },
);
const id = crypto.randomUUID();
await pollUntil(
"the new queue route to accept a message",
async () => {
try {
const res = await fetch(echo(`/queue/send?id=${id}`), {
signal: AbortSignal.timeout(5_000),
});
return res.ok ? true : undefined;
} catch {
return undefined;
}
},
{ tries: 480, delayMs: 500, server },
);
// Produce → the local broker delivers → the new DO records it.
const received = await waitForJson<{ ids: string[] }>(
"the queue message to reach the new Durable Object",
echo("/queue/received"),
(body) => body.ids.includes(id),
{ tries: 240, delayMs: 500, server },
);
expect(received.ids).toContain(id);
server.assertAlive("queue + consumer + DO added");
// The Worker's pre-existing bindings still work after the graft.
expect((await fetchJson<{ value: string }>(echo("/kv?key=graft"))).value)
.toBe("kv:graft");
expect(
(await fetchJson<{ count: number }>(echo("/counter"))).count,
).toBeGreaterThan(0);
// ── and back out again: the routes, the consumer, the Queue and the
// DO class all disappear from a running Worker ──
server.patchRegion("src/EchoWorker.ts", "ECHO_ROUTES", "");
server.patchRegion("src/EchoWorker.ts", "ECHO_BINDINGS", "");
server.patchRegion("src/EchoWorker.ts", "ECHO_LAYERS", "");
server.patchRegion("src/EchoWorker.ts", "ECHO_CLASSES", "");
server.patchRegion("src/EchoWorker.ts", "ECHO_IMPORTS", "");
await pollUntil(
"the queue route to disappear",
async () => {
try {
const res = await fetch(echo(`/queue/send?id=${id}`), {
signal: AbortSignal.timeout(5_000),
});
// The fall-through route answers with the marker instead.
if (!res.ok) return true;
const body = (await res.json()) as { sent?: string };
return body.sent === undefined ? true : undefined;
} catch {
return undefined;
}
},
{ tries: 480, delayMs: 500, server },
);
server.assertAlive("queue + consumer + DO removed");
expect(
(await fetchJson<{ marker: string }>(echo("/marker"))).marker,
).toBe("echo-v5");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"graph churn: a second S3 bucket and its bindings are added to the LIVE Lambda, then removed",
async () => {
server.patchRegion(
"src/ApiFunction.ts",
"LAMBDA_BINDINGS",
lambdaArchiveBindings,
);
server.patchRegion(
"src/ApiFunction.ts",
"LAMBDA_ROUTES",
lambdaArchiveRoutes,
);
// Driven through the cross-cloud hop, so this also re-proves the
// Worker → Lambda edge after the Lambda's binding set changed.
const archived = await waitForJson<{ text: string }>(
"the new bucket binding to answer on the Lambda",
api("/aws/archive"),
(body) => body.text === "archived",
{ tries: 480, delayMs: 500, server },
);
expect(archived.text).toBe("archived");
server.assertAlive("lambda bucket added");
// Pre-existing bindings survived the change.
expect((await fetchJson<{ text: string }>(api("/aws/dynamo"))).text).toBe(
"hello from dynamo",
);
// The engine just UPDATED the Function — hot swap must still work.
await expectLambdaHotSwapStillWorks("archive-added");
server.patchRegion("src/ApiFunction.ts", "LAMBDA_ROUTES", "");
server.patchRegion("src/ApiFunction.ts", "LAMBDA_BINDINGS", "");
await pollUntil(
"the archive route to disappear",
async () => {
try {
const res = await fetch(api("/aws/archive"), {
signal: AbortSignal.timeout(10_000),
});
return res.status === 404 ? true : undefined;
} catch {
return undefined;
}
},
{ tries: 480, delayMs: 500, server },
);
server.assertAlive("lambda bucket removed");
await expectLambdaHotSwapStillWorks("archive-removed");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"ECS hot reload: context content, the Dockerfile itself, an env prop, and an inline-Dockerfile prop all roll the running containers",
async () => {
// ── 1. a file in the build context (the classic watch path) ──
server.write("site/ecs/index.html", "ecs-site-v2\n");
await waitForText(
"the ECS service to serve the edited context file",
ecs("/"),
"ecs-site-v2",
);
server.assertAlive("ecs context reload");
// ── 2. the Dockerfile ITSELF (also a file in the context, but the
// change only lands if the image actually rebuilds) ──
server.write(
"site/ecs/Dockerfile",
server
.read("site/ecs/Dockerfile")
.replace("BAKED_MARKER=dockerfile-v1", "BAKED_MARKER=dockerfile-v2"),
);
await waitForText(
"the ECS service to serve the rebuilt Dockerfile marker",
ecs("/baked.txt"),
"dockerfile-v2",
);
// The context file survived the rebuild.
await waitForText(
"the context file to still serve after the Dockerfile rebuild",
ecs("/"),
"ecs-site-v2",
);
server.assertAlive("ecs dockerfile reload");
// ── 3. an env PROP (no file event at all — the engine registers a new
// task-definition revision, and the running task must roll onto it;
// regression: only file-watch triggers restarted tasks, so prop-driven
// updates left containers serving the old revision forever) ──
server.patchRegion(
"alchemy.run.ts",
"ECS_ENV",
' STRESS_ENV: "ecs-env-v2",\n',
);
await waitForText(
"the ECS task to roll onto the new env",
ecs("/env.txt"),
"ecs-env-v2",
);
server.assertAlive("ecs env-prop reload");
// ── 4. an INLINE Dockerfile (a pure prop change that is nonetheless a
// Dockerfile edit — same engine path as 3, but the image itself must
// rebuild) ──
server.patchRegion(
"alchemy.run.ts",
"ECS_INLINE_MARKER",
` "RUN mkdir -p /www && echo -n ecs-inline-v2 > /www/index.html",\n`,
);
await waitForText(
"the inline-Dockerfile service to serve the rebuilt image",
ecsInline("/"),
"ecs-inline-v2",
);
server.assertAlive("ecs inline-dockerfile reload");
// The sibling service was untouched by the inline rebuild.
await waitForText(
"the context service to still serve",
ecs("/"),
"ecs-site-v2",
{ tries: 30 },
);
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"EC2 hot reload: the hosted instance serves, and editing its program updates it in place",
async () => {
// The box's address comes from floci: `i-….localhost.floci.io`
// resolves to 127.0.0.1 and the mux publishes the SG app port.
const dns = await pollUntil(
"ec2Dns in the stack outputs",
() => server.outputValue("ec2Dns"),
{ tries: 240, delayMs: 500, server },
);
expect(dns).toMatch(/\.localhost\.floci\.io$/);
const marker = at2(dns, PORTS.ec2, "/marker");
// First boot: container start + userData (bundle sync from emulated
// S3, runtime install) + the Bun HTTP server binding the app port.
await waitForJson<{ marker: string }>(
"the EC2 box to serve its hosted program",
marker,
(body) => body.marker === "ec2-v1",
{ tries: 90, delayMs: 1_000, server },
);
// The reload path is the ENGINE's: a content edit re-plans, the
// provider re-uploads the bundle in place and reboots the instance —
// same instance id, same address, new code.
const reloadStartedAt = Date.now();
server.write("src/ec2/marker.ts", markerModule("EC2_MARKER", "ec2-v2"));
await waitForJson<{ marker: string }>(
"the EC2 box to serve ec2-v2 after the in-place update",
marker,
(body) => body.marker === "ec2-v2",
{ tries: 150, delayMs: 1_000, server },
);
console.log(
`ec2 hosted-program reload -> serving ec2-v2 in ${Date.now() - reloadStartedAt}ms`,
);
// Same box, same address — the update was in place, not a replacement.
expect(server.outputValue("ec2Dns")).toBe(dns);
server.assertAlive("ec2 hot reload");
},
PHASE_TIMEOUT,
);
test(
"Cloudflare Container hot reload: editing the container's program rebuilds the image and restarts it",
async () => {
// The container module is imported by the stack, so this edit replans;
// the reload rides the image content hash in the worker's restart
// config (regression: the sidecar-lifetime artifact memo made the diff
// compare the first run's hash forever, and the config only carried
// the image's stable paths — the running container served stale code
// until a full dev-session restart).
server.patchRegion(
"src/SandboxContainer.ts",
"SANDBOX_MARKER",
' marker: "sandbox-v2",\n',
);
await waitForJson<{ marker: string }>(
"the sandbox container to serve the rebuilt program",
echo("/sandbox"),
(body) => body.marker === "sandbox-v2",
{ tries: 300, delayMs: 1_000, server },
);
server.assertAlive("container hot reload");
// The #1334 loopback rewrite still holds on the rebuilt container.
const hostFetch = await fetchJson<{ body: string }>(
echo("/sandbox/host-fetch"),
undefined,
{ tries: 60, delayMs: 1_000 },
);
expect(hostFetch.body).toContain("aws-site-env-v1");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"graph churn: a replacement-forcing prop change swaps a DynamoDB table under a running Lambda",
async () => {
const source = server.read("src/ApiFunction.ts");
// The partition key is immutable on DynamoDB — changing it is a
// REPLACEMENT, not an update: a new table is created, the Lambda's
// binding is repointed, and the old table is deleted.
server.write(
"src/ApiFunction.ts",
source
.replace('partitionKey: "id",', 'partitionKey: "pk",')
.replace('attributes: { id: "S" },', 'attributes: { pk: "S" },')
// every item key in the handler, in one sweep
.replaceAll("id: { S:", "pk: { S:"),
);
// The old table answers `/dynamo` until the replacement lands, so the
// engine's own plan line is the signal — then the route must work
// against the NEW table (a fresh write lands in it).
const replacedCursor = server.mark();
await pollUntil(
"the engine to plan the table replacement",
() =>
/\[StressTable\] replace/.test(server.plain(replacedCursor)) ||
undefined,
{ tries: 240, delayMs: 500, server },
);
// A replacement prints `creating replacement` → `created` and then
// cleans up the old generation; the cleanup line is the end of it.
await pollUntil(
"the replaced table to finish",
() =>
/\[StressTable\] Replaced resource cleanup complete/.test(
server.plain(replacedCursor),
) || undefined,
{ tries: 480, delayMs: 500, server },
);
await waitForJson<{ text: string }>(
"the replaced table to serve through the Lambda",
api("/aws/dynamo"),
(body) => body.text === "hello from dynamo",
{ tries: 480, delayMs: 500, server },
);
server.assertAlive("table replaced");
await expectLambdaHotSwapStillWorks("table-replaced");
// Put it back — the swap back is a second replacement.
const restoredCursor = server.mark();
server.write("src/ApiFunction.ts", source);
await pollUntil(
"the table's second replacement to finish",
() =>
/\[StressTable\] Replaced resource cleanup complete/.test(
server.plain(restoredCursor),
) || undefined,
{ tries: 480, delayMs: 500, server },
);
await waitForJson<{ text: string }>(
"the restored table to serve through the Lambda",
api("/aws/dynamo"),
(body) => body.text === "hello from dynamo",
{ tries: 480, delayMs: 500, server },
);
server.assertAlive("table restored");
await expectLambdaHotSwapStillWorks("table-restored");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"graph churn: a SECOND AWS MicroVM image is added, driven from the Worker, and removed",
async () => {
// A new image resource (floci builds it), a new top-level import in
// both the stack and the Worker, two new MicroVM bindings on a running
// Worker, and a route that boots and terminates an instance of it.
server.write("src/extra/WorkerImage.ts", secondImageSource("worker-vm-v1"));
server.patchRegion("alchemy.run.ts", "EXTRA_IMPORTS", secondImageImport);
server.patchRegion("alchemy.run.ts", "EXTRA_LAYERS", secondImageLayer);
server.patchRegion(
"src/MicrovmWorker.ts",
"VM_IMPORTS",
secondImageWorkerImport,
);
server.patchRegion(
"src/MicrovmWorker.ts",
"VM_BINDINGS",
secondImageBindings,
);
server.patchRegion("src/MicrovmWorker.ts", "VM_ROUTES", secondImageRoutes);
const booted = await waitForJson<{ microvmId: string }>(
"the second MicroVM image to build and boot an instance",
microvm("/second"),
(body) => typeof body.microvmId === "string" && body.microvmId.length > 0,
{ tries: 900, delayMs: 1_000, server },
);
expect(booted.microvmId).toBeTruthy();
server.assertAlive("second microvm added");
// The FIRST image still works — adding a sibling did not disturb it.
expect(
(
await fetchJson<{ marker: string }>(
microvm("/roundtrip?message=sibling"),
undefined,
{ tries: 300, delayMs: 1_000 },
)
).marker,
).toBe("vm-v1");
// ── remove the second image and everything that referenced it ──
server.patchRegion("src/MicrovmWorker.ts", "VM_ROUTES", "");
server.patchRegion("src/MicrovmWorker.ts", "VM_BINDINGS", "");
server.patchRegion("src/MicrovmWorker.ts", "VM_IMPORTS", "");
server.patchRegion("alchemy.run.ts", "EXTRA_LAYERS", "");
server.patchRegion("alchemy.run.ts", "EXTRA_IMPORTS", "");
server.remove("src/extra/WorkerImage.ts");
await pollUntil(
"the second MicroVM's route to disappear",
async () => {
try {
const res = await fetch(microvm("/second"), {
signal: AbortSignal.timeout(10_000),
});
const text = await res.text();
return text === "ok" ? true : undefined;
} catch {
return undefined;
}
},
{ tries: 600, delayMs: 500, server },
);
server.assertAlive("second microvm removed");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"graph churn: the entire AWS Lambda subsystem is deleted and restored while Cloudflare keeps serving",
async () => {
const awsHalf = server.read("alchemy.run.ts");
// Delete the Lambda, its bucket, its table, its queue and its event
// source in one edit — and the cross-cloud binding that referenced it.
server.patchRegion("alchemy.run.ts", "AWS_OUTPUTS", "");
server.patchRegion("alchemy.run.ts", "AWS_LAMBDA_URL", "");
server.patchRegion("alchemy.run.ts", "AWS_HALF", "");
await pollUntil(
"the AWS half to be gone from ApiWorker's bindings",
async () => {
try {
const body = (await (
await fetch(api("/env"), { signal: AbortSignal.timeout(5_000) })
).json()) as { AWS_LAMBDA_URL: string | null };
return body.AWS_LAMBDA_URL === null ? true : undefined;
} catch {
return undefined;
}
},
{ tries: 600, delayMs: 500, server },
);
server.assertAlive("aws half deleted");
// Cloudflare is completely unaffected by the AWS half vanishing.
expect(
(await fetchJson<{ marker: string }>(echo("/marker"))).marker,
).toBe("echo-v5");
expect((await fetchJson<{ text: string }>(echo("/r2"))).text).toBe(
"hello from r2",
);
expect(await (await fetchOk(awsSite("/"))).text()).toContain("aws-site-v1");
// ── restore the whole subsystem ──
server.write("alchemy.run.ts", awsHalf);
await waitForJson<{ marker: string }>(
"the restored Lambda to serve through the cross-cloud hop",
api("/aws/"),
(body) => body.marker.startsWith("lambda-"),
{ tries: 900, delayMs: 500, server },
);
expect((await fetchJson<{ text: string }>(api("/aws/s3"))).text).toBe(
"hello from s3",
);
server.assertAlive("aws half restored");
// A brand-new Function generation must get a working watch loop.
await expectLambdaHotSwapStillWorks("aws-half-restored");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
test(
"binding churn: changing a binding value re-applies the consumer; changing a dev child's env restarts it",
async () => {
// A plain var binding on the path-`main` Worker.
server.patchRegion(
"alchemy.run.ts",
"API_VARIABLE",
' API_VARIABLE: "api-variable-v2",\n',
);
await waitForJson<{ API_VARIABLE: string }>(
"ApiWorker to pick up the new binding value",
api("/env"),
(body) => body.API_VARIABLE === "api-variable-v2",
{ tries: 240, delayMs: 500, server },
);
// A brand-new binding on a Worker that is already running.
server.patchRegion(
"alchemy.run.ts",
"API_EXTRA_ENV",
' API_EXTRA: "api-extra-v1",\n',
);
await waitForJson<{ API_EXTRA: string | null }>(
"ApiWorker to gain a binding it never had",
api("/env"),
(body) => body.API_EXTRA === "api-extra-v1",
{ tries: 240, delayMs: 500, server },
);
// …and lose it again.
server.patchRegion("alchemy.run.ts", "API_EXTRA_ENV", "");
await waitForJson<{ API_EXTRA: string | null }>(
"ApiWorker to lose the binding again",
api("/env"),
(body) => body.API_EXTRA === null,
{ tries: 240, delayMs: 500, server },
);
// `Command.Dev`'s restart surface: a change to the resolved config must
// restart the child (new pid), not silently leave the old one running.
const pidBefore = (await fetchJson<{ pid: number }>(awsSite("/__dev-env")))
.pid;
server.patchRegion(
"alchemy.run.ts",
"SITE_MARKER",
' SITE_MARKER: "aws-site-env-v2",\n',
);
const restarted = await waitForJson<{ marker: string; pid: number }>(
"the dev-command child to restart with the new env",
awsSite("/__dev-env"),
(body) => body.marker === "aws-site-env-v2",
{ tries: 240, delayMs: 500, server },
);
expect(restarted.pid).not.toBe(pidBefore);
server.assertAlive("binding churn");
await expectLambdaHotSwapStillWorks("binding-churn");
cleanCursor = server.mark();
},
PHASE_TIMEOUT,
);
// ───────────────────────────────────────────────────────────────────────
// Phase 6 — rapid-fire edits. Convergence to the LAST write, and evidence
// that the watchers coalesce instead of replaying every keystroke.
// ───────────────────────────────────────────────────────────────────────
test(
"rapid-fire bundler edits converge on the last write without re-running the stack",
async () => {
// The previous phase edited stack-graph files (alchemy.run.ts, the
// Lambda source); absorb any trailing re-plan before baselining.
await server.settlePlans();
const plansBefore = server.planCount;
const BURST = 25;
for (let i = 1; i <= BURST; i++) {
server.write(
"src/api/marker.ts",
markerModule("API_MARKER", `api-storm-${i}`),
);
await Bun.sleep(40);
}
await waitForJson<{ marker: string }>(
`ApiWorker to settle on api-storm-${BURST}`,
api("/marker"),
(body) => body.marker === `api-storm-${BURST}`,
{ tries: 240, delayMs: 500, server },
);
// A burst on the bundler path must never escalate into stack re-runs.
expect(server.planCount).toBe(plansBefore);
server.assertAlive("bundler burst");
},
PHASE_TIMEOUT,
);
test(
"rapid-fire stack edits converge on the last write and are coalesced",
async () => {
const plansBefore = server.planCount;
const BURST = 15;
for (let i = 1; i <= BURST; i++) {
server.write(
"src/echo/marker.ts",
markerModule("ECHO_MARKER", `echo-storm-${i}`),
);
await Bun.sleep(60);
}
await waitForJson<{ marker: string }>(
`EchoWorker to settle on echo-storm-${BURST}`,
echo("/marker"),
(body) => body.marker === `echo-storm-${BURST}`,
{ tries: 360, delayMs: 500, server },
);
// The hot swap lands before the stack re-runs; wait for the re-plan
// and let any coalesced follow-up runs drain before counting.
await server.waitForPlanAfter(plansBefore);
await Bun.sleep(15_000);
const restarts = server.planCount - plansBefore;
expect(restarts).toBeGreaterThanOrEqual(1);
// Debouncing: a sub-second burst of 15 saves must not produce 15
// complete plan/apply cycles.
expect(restarts).toBeLessThan(BURST);
server.assertAlive("stack burst");
},
PHASE_TIMEOUT,
);
test(
"simultaneous cross-cloud edits all land",
async () => {
// One tick, four surfaces: the bundler path, the watch path, the AWS
// half, and the stack graph itself.
server.write("src/api/marker.ts", markerModule("API_MARKER", "api-final"));
server.write("src/echo/marker.ts", markerModule("ECHO_MARKER", "echo-final"));
server.write(
"src/lambda/marker.ts",
markerModule("LAMBDA_MARKER", "lambda-final"),
);
server.patchRegion(
"alchemy.run.ts",
"API_VARIABLE",
' API_VARIABLE: "api-variable-final",\n',
);
await waitForJson<{ marker: string }>(
"EchoWorker to serve echo-final",
echo("/marker"),
(body) => body.marker === "echo-final",
{ tries: 360, delayMs: 500, server },
);
await waitForJson<{ marker: string }>(
"ApiWorker to serve api-final",
api("/marker"),
(body) => body.marker === "api-final",
{ tries: 360, delayMs: 500, server },
);
await waitForJson<{ API_VARIABLE: string }>(
"ApiWorker to carry api-variable-final",
api("/env"),
(body) => body.API_VARIABLE === "api-variable-final",
{ tries: 360, delayMs: 500, server },
);
await waitForJson<{ marker: string }>(
"the Lambda to serve lambda-final",
api("/aws/"),
(body) => body.marker === "lambda-final",
{ tries: 360, delayMs: 1_000, server },
);
server.assertAlive("simultaneous edits");
},
PHASE_TIMEOUT,
);
// ───────────────────────────────────────────────────────────────────────
// Phase 7 — after all that churn, the whole stack is still the stack.
// ───────────────────────────────────────────────────────────────────────
test(
"after the full churn every resource still serves and the CLI never restarted",
async () => {
server.assertAlive("final health check");
// Cloudflare
expect((await fetchJson<{ value: string }>(echo("/kv?key=final"))).value)
.toBe("kv:final");
expect((await fetchJson<{ text: string }>(echo("/r2"))).text).toBe(
"hello from r2",
);
expect(
(await fetchJson<{ count: number }>(echo("/counter"))).count,
).toBeGreaterThan(0);
const finalSandbox = await fetchJson<{ greeting: string; marker: string }>(
echo("/sandbox"),
undefined,
{ tries: 60, delayMs: 1_000 },
);
expect(finalSandbox.greeting).toBe("hello-from-container");
expect(finalSandbox.marker).toBe("sandbox-v2");
// AWS, through the cross-cloud hop
expect((await fetchJson<{ text: string }>(api("/aws/dynamo"))).text).toBe(
"hello from dynamo",
);
const message = { id: crypto.randomUUID() };
await fetchOk(api("/aws/queue/send"), {
method: "POST",
body: JSON.stringify(message),
});
const delivered = await waitForJson<{ body: string | null }>(
"the post-churn queue message to be consumed",
api(`/aws/queue/messages?id=${message.id}`),
(body) => body.body !== null,
{ tries: 180, delayMs: 500, server },
);
expect(JSON.parse(delivered.body!)).toEqual(message);
// ECS: both services still serve after every reload they went through.
await waitForText("the ECS service post-churn", ecs("/"), "ecs-site-v2", {
tries: 60,
});
await waitForText(
"the inline ECS service post-churn",
ecsInline("/"),
"ecs-inline-v2",
{ tries: 60 },
);
// Websites. The Cloudflare site's port is not pinned (a
// `Website.StaticSite` owns its Worker's dev options), so re-read the
// newest value the CLI printed rather than trusting the boot one.
expect(await (await fetchOk(awsSite("/"))).text()).toContain("aws-site-v1");
cfSiteUrl = server.outputUrl("cfSiteUrl") ?? cfSiteUrl;
expect(await (await fetchOk(new URL("/", cfSiteUrl))).text()).toContain(
"cf-site-v1",
);
// No failure was logged since the last phase that expected one.
expect(server.since(cleanCursor)).not.toContain("alchemy dev: run failed");
expect(server.since(cleanCursor)).not.toContain(
"alchemy dev: apply failed",
);
},
PHASE_TIMEOUT,
);
test(
"the MicroVM image survives every unrelated edit, and rebuilds when its own source changes",
async () => {
// Nothing the suite did to the other resources touched the image.
expect(server.output).not.toContain("[StressMicrovm] replace");
expect(
(
await fetchJson<{ marker: string }>(
microvm("/roundtrip?message=post-churn"),
undefined,
{ tries: 240, delayMs: 1_000 },
)
).marker,
).toBe("vm-v1");
// Now change the program that runs INSIDE the VM. The dev provider
// builds the new image with a HOST-side cached `docker build` and hands
// floci a pre-built `docker://` reference, so the rebuild itself is
// seconds — the wait below is dominated by the roundtrip's own
// boot-a-VM-per-request cost.
const rebuildStartedAt = Date.now();
server.write("src/vm/marker.ts", markerModule("VM_MARKER", "vm-v2"));
await waitForJson<{ marker: string }>(
"the rebuilt MicroVM image to serve vm-v2",
microvm("/roundtrip?message=rebuild"),
(body) => body.marker === "vm-v2",
{ tries: 600, delayMs: 1_000, server },
);
const rebuildMs = Date.now() - rebuildStartedAt;
console.log(`microvm image rebuild -> serving vm-v2 in ${rebuildMs}ms`);
// Pre-docker:// this took minutes (zip upload + cold server-side
// build); the cached host build must keep the whole edit-to-serving
// path within a couple of VM boots.
expect(rebuildMs).toBeLessThan(120_000);
server.assertAlive("microvm image rebuild");
},
PHASE_TIMEOUT,
);
test(
"shuts down cleanly on Ctrl-C and stops serving",
async () => {
await server.shutdown();
expect(server.alive).toBe(false);
for (const url of [echo("/marker"), api("/marker"), awsSite("/")]) {
await pollUntil(
`${url} to stop serving after shutdown`,
async () => ((await isClosed(url)) ? true : undefined),
{ tries: 60, delayMs: 500 },
);
}
},
PHASE_TIMEOUT,
);