Skip to content

Commit f064f01

Browse files
authored
containers local dev e2e tests (#9820)
* e2e tests * change linux runner to amd * pr feedback
1 parent 1a58bc3 commit f064f01

File tree

2 files changed

+232
-55
lines changed

2 files changed

+232
-55
lines changed

.github/workflows/e2e.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,8 @@ jobs:
2424
- os: windows-2022
2525
description: v20, Windows
2626
node: 20.19.1
27-
- os: ubuntu-22.04-arm
27+
# we need to use an amd image to run the containers tests, since we build for linux/amd64
28+
- os: ubuntu-22.04
2829
description: v20, Linux
2930
node: 20.19.1
3031

Lines changed: 230 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,80 +1,225 @@
1-
import { beforeEach, describe, expect, it, vi } from "vitest";
1+
import { execSync } from "child_process";
2+
import {
3+
afterAll,
4+
beforeAll,
5+
beforeEach,
6+
describe,
7+
expect,
8+
it,
9+
vi,
10+
} from "vitest";
11+
import { getDockerPath } from "../src/environment-variables/misc-variables";
212
import { dedent } from "../src/utils/dedent";
13+
import { CLOUDFLARE_ACCOUNT_ID } from "./helpers/account-id";
314
import { WranglerE2ETestHelper } from "./helpers/e2e-wrangler-test";
15+
import { generateResourceName } from "./helpers/generate-resource-name";
416

5-
const wranglerConfig = {
6-
name: "container-app",
7-
main: "src/index.ts",
8-
compatibility_date: "2025-04-03",
9-
containers: [
10-
{
11-
configuration: {
12-
image: "./Dockerfile",
13-
},
14-
class_name: "Container",
15-
name: "http2",
16-
max_instances: 2,
17-
},
18-
],
19-
durable_objects: {
20-
bindings: [
21-
{
22-
class_name: "Container",
23-
name: "CONTAINER",
24-
},
25-
],
26-
},
27-
migrations: [
28-
{
29-
tag: "v1",
30-
new_classes: ["Container"],
31-
},
32-
],
33-
};
17+
const imageSource = ["pull", "build"];
3418

35-
// TODO: docker is not installed by default on macOS runners in github actions.
36-
// And windows is being difficult.
37-
// So we skip these tests in CI, and test this locally for now :/
38-
describe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
39-
"containers local dev tests",
19+
// We can only really run these tests on Linux, because we build our images for linux/amd64,
20+
// and github runners don't really support container virtualization in any sane way
21+
describe
22+
.skipIf(process.platform !== "linux" && process.env.CI === "true")
23+
.each(imageSource)(
24+
"containers local dev tests: %s",
4025
{ timeout: 90_000 },
41-
() => {
26+
(source) => {
4227
let helper: WranglerE2ETestHelper;
28+
let workerName: string;
29+
let wranglerConfig: Record<string, unknown>;
30+
31+
beforeAll(async () => {
32+
workerName = generateResourceName();
4333

44-
beforeEach(async () => {
4534
helper = new WranglerE2ETestHelper();
35+
wranglerConfig = {
36+
name: `${workerName}`,
37+
main: "src/index.ts",
38+
compatibility_date: "2025-04-03",
39+
containers: [
40+
{
41+
image: "./Dockerfile",
42+
class_name: `E2EContainer`,
43+
name: `${workerName}-container`,
44+
},
45+
],
46+
durable_objects: {
47+
bindings: [
48+
{
49+
class_name: `E2EContainer`,
50+
name: "CONTAINER",
51+
},
52+
],
53+
},
54+
migrations: [
55+
{
56+
tag: "v1",
57+
new_classes: [`E2EContainer`],
58+
},
59+
],
60+
};
4661
await helper.seed({
4762
"wrangler.json": JSON.stringify(wranglerConfig),
4863
"src/index.ts": dedent`
4964
import { DurableObject } from "cloudflare:workers";
50-
export class Container extends DurableObject {}
65+
66+
export class E2EContainer extends DurableObject<Env> {
67+
container: globalThis.Container;
68+
69+
constructor(ctx: DurableObjectState, env: Env) {
70+
super(ctx, env);
71+
this.container = ctx.container!;
72+
}
73+
74+
async fetch(req: Request) {
75+
const path = new URL(req.url).pathname;
76+
switch (path) {
77+
case "/status":
78+
return new Response(JSON.stringify(this.container.running));
79+
80+
case "/start":
81+
this.container.start({
82+
entrypoint: ["node", "app.js"],
83+
env: { MESSAGE: "I'm an env var!" },
84+
enableInternet: false,
85+
});
86+
return new Response("Container create request sent...");
87+
88+
case "/fetch":
89+
const res = await this.container
90+
.getTcpPort(8080)
91+
.fetch("http://foo/bar/baz");
92+
return new Response(await res.text());
93+
default:
94+
return new Response("Hi from Container DO");
95+
}
96+
}
97+
}
98+
5199
export default {
52-
async fetch() {
100+
async fetch(request, env): Promise<Response> {
101+
const id = env.CONTAINER.idFromName("container");
102+
const stub = env.CONTAINER.get(id);
103+
return stub.fetch(request);
53104
},
54-
};
55-
`,
56-
"package.json": dedent`
57-
{
58-
"name": "worker",
59-
"version": "0.0.0",
60-
"private": true
61-
}
62-
`,
105+
} satisfies ExportedHandler<Env>;`,
63106
Dockerfile: dedent`
64-
FROM alpine:latest
65-
CMD ["echo", "hello world"]
107+
FROM node:22-alpine
108+
109+
WORKDIR /usr/src/app
110+
111+
COPY ./container/app.js app.js
66112
EXPOSE 8080
67113
`,
114+
"container/app.js": dedent`
115+
const { createServer } = require("http");
116+
117+
const server = createServer(function (req, res) {
118+
res.writeHead(200, { "Content-Type": "text/plain" });
119+
res.write("Hello World! Have an env var! " + process.env.MESSAGE);
120+
res.end();
121+
});
122+
123+
server.listen(8080, function () {
124+
console.log("Server listening on port 8080");
125+
});
126+
`,
68127
});
128+
// if we are pulling we need to push the image first
129+
if (source === "pull") {
130+
// pull a container image from the registry
131+
await helper.run(
132+
`wrangler containers build . -t ${workerName}:tmp-e2e -p`
133+
);
134+
135+
wranglerConfig = {
136+
...wranglerConfig,
137+
containers: [
138+
{
139+
image: `registry.cloudflare.com/${CLOUDFLARE_ACCOUNT_ID}/${workerName}:tmp-e2e`,
140+
class_name: `E2EContainer`,
141+
name: `${workerName}-container`,
142+
},
143+
],
144+
};
145+
await helper.seed({
146+
"wrangler.json": JSON.stringify(wranglerConfig),
147+
});
148+
// wait a bit for the image to be available to pull
149+
await new Promise((resolve) => setTimeout(resolve, 5_000));
150+
}
151+
}, 30_000);
152+
beforeEach(async () => {
153+
await helper.seed({
154+
"wrangler.json": JSON.stringify(wranglerConfig),
155+
});
156+
// cleanup any running containers
157+
const ids = getContainerIds("e2econtainer");
158+
if (ids.length > 0) {
159+
console.log(ids);
160+
execSync(`${getDockerPath()} rm -f ${ids.join(" ")}`, {
161+
encoding: "utf8",
162+
});
163+
}
164+
});
165+
afterAll(async () => {
166+
const ids = getContainerIds("e2econtainer");
167+
if (ids.length > 0) {
168+
execSync(`${getDockerPath()} rm -f ${ids.join(" ")}`, {
169+
encoding: "utf8",
170+
});
171+
}
172+
if (source === "pull") {
173+
// TODO: we won't need to prefix the account id once 9811 lands
174+
await helper.run(
175+
`wrangler containers images delete ${CLOUDFLARE_ACCOUNT_ID}/${workerName}:tmp-e2e`
176+
);
177+
}
69178
});
70-
it(`will build containers when miniflare starts`, async () => {
179+
it(`will build or pull containers when miniflare starts`, async () => {
71180
const worker = helper.runLongLived("wrangler dev");
72181
await worker.readUntil(/Preparing container/);
73-
await worker.readUntil(/DONE/);
182+
if (source === "pull") {
183+
await worker.readUntil(/Status/);
184+
} else {
185+
await worker.readUntil(/DONE/);
186+
}
74187
// from miniflare output:
75188
await worker.readUntil(/Container image\(s\) ready/);
76189
});
77190

191+
it(`will be able to interact with the container`, async () => {
192+
const worker = helper.runLongLived("wrangler dev");
193+
const ready = await worker.waitForReady();
194+
await worker.readUntil(/Container image\(s\) ready/);
195+
196+
let response = await fetch(`${ready.url}/status`);
197+
expect(response.status).toBe(200);
198+
let status = await response.json();
199+
expect(status).toBe(false);
200+
201+
response = await fetch(`${ready.url}/start`);
202+
let text = await response.text();
203+
expect(response.status).toBe(200);
204+
expect(text).toBe("Container create request sent...");
205+
206+
// Wait a bit for container to start
207+
await new Promise((resolve) => setTimeout(resolve, 2_000));
208+
209+
response = await fetch(`${ready.url}/status`);
210+
status = await response.json();
211+
expect(response.status).toBe(200);
212+
expect(status).toBe(true);
213+
214+
response = await fetch(`${ready.url}/fetch`);
215+
expect(response.status).toBe(200);
216+
text = await response.text();
217+
expect(text).toBe("Hello World! Have an env var! I'm an env var!");
218+
// Check that a container is running using `docker ps`
219+
const ids = getContainerIds("e2econtainer");
220+
expect(ids.length).toBe(1);
221+
});
222+
78223
it("won't start the container service if no containers are present", async () => {
79224
await helper.seed({
80225
"wrangler.json": JSON.stringify({
@@ -84,7 +229,6 @@ describe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
84229
});
85230
const worker = helper.runLongLived("wrangler dev");
86231
await worker.waitForReady();
87-
// await worker.exitCode;
88232
await worker.stop();
89233
const output = await worker.output;
90234
expect(output).not.toContain("Preparing container image(s)...");
@@ -124,12 +268,19 @@ describe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
124268
CMD ["echo", "hello world"]
125269
`,
126270
});
271+
if (source === "pull") {
272+
await helper.run(
273+
`wrangler containers build . -t ${workerName}:tmp-e2e -p`
274+
);
275+
}
127276
});
277+
// this will never run in CI
128278
it.skipIf(process.platform === "linux")(
129279
"errors in windows/macos if no ports are exposed",
130280
async () => {
131281
const worker = helper.runLongLived("wrangler dev");
132282
expect(await worker.exitCode).toBe(1);
283+
expect(await worker.output).toContain("does not expose any ports");
133284
}
134285
);
135286

@@ -138,7 +289,11 @@ describe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
138289
async () => {
139290
const worker = helper.runLongLived("wrangler dev");
140291
await worker.readUntil(/Preparing container/);
141-
await worker.readUntil(/DONE/);
292+
if (source === "pull") {
293+
await worker.readUntil(/Status/);
294+
} else {
295+
await worker.readUntil(/DONE/);
296+
}
142297
await worker.readUntil(/Container image\(s\) ready/);
143298
}
144299
);
@@ -154,6 +309,27 @@ describe.skipIf(process.platform !== "linux" && process.env.CI === "true")(
154309
expect(await worker.output).toContain(
155310
`To suppress this error if you do not intend on triggering any container instances, set dev.enable_containers to false in your Wrangler config or passing in --enable-containers=false.`
156311
);
312+
vi.unstubAllEnvs();
157313
});
158314
}
159315
);
316+
317+
/** gets any containers that were created by running this fixture */
318+
const getContainerIds = (class_name: string) => {
319+
// note the -a to include stopped containers
320+
321+
const allContainers = execSync(`${getDockerPath()} ps -a --format json`)
322+
.toString()
323+
.split("\n")
324+
.filter((line) => line.trim());
325+
if (allContainers.length === 0) {
326+
return [];
327+
}
328+
const jsonOutput = allContainers.map((line) => JSON.parse(line));
329+
330+
return jsonOutput.map((container) => {
331+
if (container.Image.includes(`cloudflare-dev/${class_name}`)) {
332+
return container.ID;
333+
}
334+
});
335+
};

0 commit comments

Comments
 (0)