Skip to content

Commit c7de92e

Browse files
Add some basic smoke tests to check that workers with multiple containers work as expected (#9960)
* add multi-containers tests in interactive-dev-tests * add multi-containers wrangler deploy tests
1 parent c02b067 commit c7de92e

File tree

8 files changed

+533
-13
lines changed

8 files changed

+533
-13
lines changed
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /usr/src/app
4+
RUN echo '{"name": "simple-node-app-a", "version": "1.0.0"}' > package.json
5+
RUN npm install
6+
7+
RUN sleep 1
8+
9+
RUN echo 'const { createServer } = require("http");\
10+
\
11+
const server = createServer(function (req, res) {\
12+
res.writeHead(200, { "Content-Type": "text/plain" });\
13+
res.write("Hello from Container A");\
14+
res.end();\
15+
});\
16+
\
17+
server.listen(8080);\
18+
' > app.js
19+
20+
EXPOSE 8080
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
FROM node:22-alpine
2+
3+
WORKDIR /usr/src/app
4+
RUN echo '{"name": "simple-node-app-b", "version": "1.0.0"}' > package.json
5+
RUN npm install
6+
7+
RUN sleep 1
8+
9+
RUN echo 'const { createServer } = require("http");\
10+
\
11+
const server = createServer(function (req, res) {\
12+
res.writeHead(200, { "Content-Type": "text/plain" });\
13+
res.write("Hello from Container B");\
14+
res.end();\
15+
});\
16+
\
17+
server.listen(8080);\
18+
' > app.js
19+
20+
EXPOSE 8080
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { DurableObject } from "cloudflare:workers";
2+
3+
class FixtureTestContainerBase extends DurableObject<Env> {
4+
container: globalThis.Container;
5+
6+
constructor(ctx: DurableObjectState, env: Env) {
7+
super(ctx, env);
8+
this.container = ctx.container;
9+
}
10+
11+
async fetch(req: Request) {
12+
if (!this.container.running) {
13+
this.container.start({
14+
entrypoint: ["node", "app.js"],
15+
enableInternet: false,
16+
});
17+
// On the first request we simply start the container and return,
18+
// on the following requests the container can actually be accessed.
19+
// Note that we do this this way becase container.start is not awaitable
20+
// meaning that we can't simply wait here for the container to be ready
21+
return new Response("Container started");
22+
}
23+
return this.container
24+
.getTcpPort(8080)
25+
.fetch("http://foo/bar/baz", { method: "POST", body: "hello" });
26+
}
27+
}
28+
29+
export class FixtureTestContainerA extends FixtureTestContainerBase {}
30+
export class FixtureTestContainerB extends FixtureTestContainerBase {}
31+
32+
export default {
33+
async fetch(request, env): Promise<Response> {
34+
const getContainerText = async (
35+
container: "CONTAINER_A" | "CONTAINER_B"
36+
) => {
37+
const id = env[container].idFromName("container");
38+
const stub = env[container].get(id);
39+
return await (await stub.fetch(request)).text();
40+
};
41+
const containerAText = await getContainerText("CONTAINER_A");
42+
const containerBText = await getContainerText("CONTAINER_B");
43+
return new Response(
44+
`Response from A: "${containerAText}"` +
45+
" " +
46+
`Response from B: "${containerBText}"`
47+
);
48+
},
49+
} satisfies ExportedHandler<Env>;
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"compilerOptions": {
3+
"target": "ES2020",
4+
"module": "ESNext",
5+
"lib": ["ES2020"],
6+
"types": ["@cloudflare/workers-types"],
7+
"moduleResolution": "node",
8+
"noEmit": true,
9+
"skipLibCheck": true
10+
},
11+
"include": ["**/*.ts"]
12+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
declare namespace Cloudflare {
2+
interface Env {
3+
CONTAINER_A: DurableObjectNamespace<import("./src").FixtureTestContainerA>;
4+
CONTAINER_B: DurableObjectNamespace<import("./src").FixtureTestContainerB>;
5+
}
6+
}
7+
8+
interface Env extends Cloudflare.Env {}
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
{
2+
"name": "multi-containers-app",
3+
"main": "src/index.ts",
4+
"compatibility_date": "2025-04-03",
5+
"containers": [
6+
{
7+
"image": "./DockerfileA",
8+
"class_name": "FixtureTestContainerA",
9+
"name": "containerA",
10+
"max_instances": 2,
11+
},
12+
{
13+
"image": "./DockerfileB",
14+
"class_name": "FixtureTestContainerB",
15+
"name": "containerB",
16+
"max_instances": 2,
17+
},
18+
],
19+
"durable_objects": {
20+
"bindings": [
21+
{
22+
"class_name": "FixtureTestContainerA",
23+
"name": "CONTAINER_A",
24+
},
25+
{
26+
"class_name": "FixtureTestContainerB",
27+
"name": "CONTAINER_B",
28+
},
29+
],
30+
},
31+
"migrations": [
32+
{
33+
"tag": "v1",
34+
"new_sqlite_classes": ["FixtureTestContainerA"],
35+
},
36+
{
37+
"tag": "v1",
38+
"new_sqlite_classes": ["FixtureTestContainerB"],
39+
},
40+
],
41+
}

fixtures/interactive-dev-tests/tests/index.test.ts

Lines changed: 165 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import rl from "node:readline";
77
import stream from "node:stream";
88
import { setTimeout } from "node:timers/promises";
99
import stripAnsi from "strip-ansi";
10-
import { fetch } from "undici";
10+
import { fetch, RequestInfo } from "undici";
1111
import {
1212
afterAll,
1313
afterEach,
@@ -561,6 +561,170 @@ baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
561561
}
562562
);
563563

564+
baseDescribe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
565+
"multi-containers dev",
566+
{ retry: 0, timeout: 50000 },
567+
() => {
568+
let tmpDir: string;
569+
beforeAll(async () => {
570+
tmpDir = fs.mkdtempSync(
571+
path.join(tmpdir(), "wrangler-multi-containers-")
572+
);
573+
fs.cpSync(
574+
path.resolve(__dirname, "../", "multi-containers-app"),
575+
path.join(tmpDir),
576+
{
577+
recursive: true,
578+
}
579+
);
580+
581+
const ids = getContainerIds();
582+
if (ids.length > 0) {
583+
execSync("docker rm -f " + ids.join(" "), {
584+
encoding: "utf8",
585+
});
586+
}
587+
});
588+
589+
afterEach(async () => {
590+
const ids = getContainerIds();
591+
if (ids.length > 0) {
592+
execSync("docker rm -f " + ids.join(" "), {
593+
encoding: "utf8",
594+
});
595+
}
596+
});
597+
afterAll(async () => {
598+
try {
599+
fs.rmSync(tmpDir, { recursive: true, force: true });
600+
} catch (e) {
601+
// It seems that Windows doesn't let us delete this, with errors like:
602+
//
603+
// Error: EBUSY: resource busy or locked, rmdir 'C:\Users\RUNNER~1\AppData\Local\Temp\wrangler-modules-pKJ7OQ'
604+
// ⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯⎯
605+
// Serialized Error: {
606+
// "code": "EBUSY",
607+
// "errno": -4082,
608+
// "path": "C:\Users\RUNNER~1\AppData\Local\Temp\wrangler-modules-pKJ7OQ",
609+
// "syscall": "rmdir",
610+
// }
611+
console.error(e);
612+
}
613+
});
614+
615+
async function fetchWithTimeout(input: RequestInfo) {
616+
const controller = new AbortController();
617+
const { signal } = controller;
618+
setTimeout(3_000).then(() => {
619+
controller.abort();
620+
});
621+
return await fetch(input, { signal });
622+
}
623+
624+
it("should print build logs for all the containers", async () => {
625+
const wrangler = await startWranglerDev([
626+
"dev",
627+
"-c",
628+
path.join(tmpDir, "wrangler.jsonc"),
629+
]);
630+
await vi.waitFor(
631+
() => {
632+
expect(wrangler.stdout).toContain('"name": "simple-node-app-a"');
633+
expect(wrangler.stdout).toContain('"name": "simple-node-app-b"');
634+
},
635+
{ timeout: 10_000 }
636+
);
637+
wrangler.pty.kill();
638+
});
639+
640+
it("should rebuild all the containers when the hotkey is pressed", async () => {
641+
const wrangler = await startWranglerDev([
642+
"dev",
643+
"-c",
644+
path.join(tmpDir, "wrangler.jsonc"),
645+
]);
646+
647+
await vi.waitFor(
648+
async () => {
649+
const text = await (await fetchWithTimeout(wrangler.url)).text();
650+
expect(text).toBe(
651+
'Response from A: "Hello from Container A" Response from B: "Hello from Container B"'
652+
);
653+
},
654+
{ timeout: 30_000, interval: 1000 }
655+
);
656+
657+
const tmpDockerfileAPath = path.join(tmpDir, "DockerfileA");
658+
const dockerFileAContent = fs.readFileSync(tmpDockerfileAPath, "utf8");
659+
fs.writeFileSync(
660+
tmpDockerfileAPath,
661+
dockerFileAContent.replace(
662+
'"Hello from Container A"',
663+
'"Hello World from Container A"'
664+
),
665+
"utf-8"
666+
);
667+
668+
const tmpDockerfileBPath = path.join(tmpDir, "DockerfileB");
669+
const dockerFileBContent = fs.readFileSync(tmpDockerfileBPath, "utf8");
670+
fs.writeFileSync(
671+
tmpDockerfileBPath,
672+
dockerFileBContent.replace(
673+
'"Hello from Container B"',
674+
'"Hello from the B Container"'
675+
),
676+
"utf-8"
677+
);
678+
679+
wrangler.pty.write("r");
680+
681+
await vi.waitFor(
682+
async () => {
683+
const text = await (await fetchWithTimeout(wrangler.url)).text();
684+
expect(text).toBe(
685+
'Response from A: "Hello World from Container A" Response from B: "Hello from the B Container"'
686+
);
687+
},
688+
{ timeout: 30_000, interval: 1000 }
689+
);
690+
691+
fs.writeFileSync(tmpDockerfileAPath, dockerFileAContent, "utf-8");
692+
fs.writeFileSync(tmpDockerfileBPath, dockerFileBContent, "utf-8");
693+
694+
wrangler.pty.kill();
695+
});
696+
697+
it("should clean up any containers that were started", async () => {
698+
const wrangler = await startWranglerDev([
699+
"dev",
700+
"-c",
701+
path.join(tmpDir, "wrangler.jsonc"),
702+
]);
703+
// wait container to be ready
704+
await vi.waitFor(
705+
async () => {
706+
const text = await (await fetchWithTimeout(wrangler.url)).text();
707+
expect(text).toBe(
708+
'Response from A: "Hello from Container A" Response from B: "Hello from Container B"'
709+
);
710+
},
711+
{ timeout: 30_000, interval: 1000 }
712+
);
713+
const ids = getContainerIds();
714+
expect(ids.length).toBe(2);
715+
716+
wrangler.pty.kill("SIGINT");
717+
await new Promise<void>((resolve) => {
718+
wrangler.pty.onExit(() => resolve());
719+
});
720+
vi.waitFor(() => {
721+
const remainingIds = getContainerIds();
722+
expect(remainingIds.length).toBe(0);
723+
});
724+
});
725+
}
726+
);
727+
564728
/** gets any containers that were created by running this fixture */
565729
const getContainerIds = () => {
566730
// note the -a to include stopped containers

0 commit comments

Comments
 (0)