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" ;
212import { dedent } from "../src/utils/dedent" ;
13+ import { CLOUDFLARE_ACCOUNT_ID } from "./helpers/account-id" ;
314import { 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 ( / P r e p a r i n g c o n t a i n e r / ) ;
73- await worker . readUntil ( / D O N E / ) ;
182+ if ( source === "pull" ) {
183+ await worker . readUntil ( / S t a t u s / ) ;
184+ } else {
185+ await worker . readUntil ( / D O N E / ) ;
186+ }
74187 // from miniflare output:
75188 await worker . readUntil ( / C o n t a i n e r i m a g e \( s \) r e a d y / ) ;
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 ( / C o n t a i n e r i m a g e \( s \) r e a d y / ) ;
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 ( / P r e p a r i n g c o n t a i n e r / ) ;
141- await worker . readUntil ( / D O N E / ) ;
292+ if ( source === "pull" ) {
293+ await worker . readUntil ( / S t a t u s / ) ;
294+ } else {
295+ await worker . readUntil ( / D O N E / ) ;
296+ }
142297 await worker . readUntil ( / C o n t a i n e r i m a g e \( s \) r e a d y / ) ;
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