chore: import upstream snapshot with attribution
Deploy (to testing) and Test Playground Preview Worker / Deploy Playground Preview Worker (testing) (push) Has been skipped
Deploy Workers Shared Staging / Deploy Workers Shared Staging (push) Failing after 0s
Prerelease / build (push) Has been skipped
Handle Changesets / Handle Changesets (push) Has been cancelled
Semgrep OSS scan / semgrep-oss (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:30:11 +08:00
commit 70bf21e064
5213 changed files with 731895 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
# `interactive-dev-tests`
This package contains tests for interactive `wrangler dev` sessions running in a pseudo-TTY. These have slightly different behaviour to non-interactive sessions tested by other fixtures.
@@ -0,0 +1,8 @@
FROM node:22-alpine
WORKDIR /usr/src/app
RUN echo '{"name": "simple-node-app", "version": "1.0.0", "dependencies": {"ws": "^8.0.0"}}' > package.json
RUN npm install
COPY ./container/simple-node-app.js app.js
EXPOSE 8080
@@ -0,0 +1,6 @@
FROM node:22-alpine
WORKDIR /usr/src/app
RUN echo 'This (no-op) build takes forever...'
RUN sleep 50000
@@ -0,0 +1,49 @@
const { createServer } = require("http");
const webSocketEnabled = process.env.WS_ENABLED === "true";
// Create HTTP server
const server = createServer(function (req, res) {
if (req.url === "/ws") {
// WebSocket upgrade will be handled by the WebSocket server
return;
}
res.writeHead(200, { "Content-Type": "text/plain" });
res.write("Hello World! Have an env var! " + process.env.MESSAGE);
res.end();
});
// Check if WebSocket functionality is enabled
if (webSocketEnabled) {
const WebSocket = require("ws");
// Create WebSocket server
const wss = new WebSocket.Server({
server: server,
path: "/ws",
});
wss.on("connection", function connection(ws) {
console.log("WebSocket connection established");
ws.on("message", function message(data) {
console.log("Received:", data.toString());
// Echo the message back with prefix
ws.send("Echo: " + data.toString());
});
ws.on("close", function close() {
console.log("WebSocket connection closed");
});
ws.on("error", console.error);
});
}
server.listen(8080, function () {
console.log("Server listening on port 8080");
if (webSocketEnabled) {
console.log("WebSocket support enabled");
}
});
@@ -0,0 +1,67 @@
import { DurableObject } from "cloudflare:workers";
export class FixtureTestContainer extends DurableObject<Env> {
container: globalThis.Container;
monitor?: Promise<unknown>;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.container = ctx.container;
}
async fetch(req: Request) {
const path = new URL(req.url).pathname;
switch (path) {
case "/status":
return new Response(JSON.stringify(this.container.running));
case "/destroy":
if (!this.container.running) {
throw new Error("Container is not running.");
}
await this.container.destroy();
return new Response(JSON.stringify(this.container.running));
case "/start":
this.container.start({
entrypoint: ["node", "app.js"],
env: { MESSAGE: "I'm an env var!" },
enableInternet: false,
});
// this doesn't instantly start, so we will need to poll /fetch
return new Response("Container create request sent...");
case "/fetch":
const res = await this.container
.getTcpPort(8080)
// actual request doesn't matter
.fetch("http://foo/bar/baz", { method: "POST", body: "hello" });
return new Response(await res.text());
case "/destroy-with-monitor":
const monitor = this.container.monitor();
await this.container.destroy();
await monitor;
return new Response("Container destroyed with monitor.");
default:
return new Response("Hi from Container DO");
}
}
}
export default {
async fetch(request, env): Promise<Response> {
const url = new URL(request.url);
if (url.pathname === "/second") {
// This is a second Durable Object that can be used to test multiple DOs
const id = env.CONTAINER.idFromName("second-container");
const stub = env.CONTAINER.get(id);
const query = url.searchParams.get("req");
return stub.fetch("http://example.com/" + query);
}
const id = env.CONTAINER.idFromName("container");
const stub = env.CONTAINER.get(id);
return stub.fetch(request);
},
} satisfies ExportedHandler<Env>;
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"types": ["@cloudflare/workers-types"],
"moduleResolution": "node",
"noEmit": true,
"skipLibCheck": true
},
"include": ["**/*.ts"]
}
@@ -0,0 +1,13 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types ./container-app/src/worker-configuration.d.ts -c ./container-app/wrangler.jsonc --no-include-runtime` (hash: 3e9f96a73efdcd8aacad1235e0761dab)
interface __BaseEnv_Env {
CONTAINER: DurableObjectNamespace<import("./index").FixtureTestContainer>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./index");
durableNamespaces: "FixtureTestContainer";
}
interface Env extends __BaseEnv_Env {}
}
interface Env extends __BaseEnv_Env {}
@@ -0,0 +1,27 @@
{
"name": "container-app",
"main": "src/index.ts",
"compatibility_date": "2025-04-03",
"containers": [
{
"image": "./Dockerfile",
"class_name": "FixtureTestContainer",
"name": "container",
"max_instances": 2,
},
],
"durable_objects": {
"bindings": [
{
"class_name": "FixtureTestContainer",
"name": "CONTAINER",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["FixtureTestContainer"],
},
],
}
+1
View File
@@ -0,0 +1 @@
export default {};
@@ -0,0 +1,20 @@
FROM node:22-alpine
WORKDIR /usr/src/app
RUN echo '{"name": "simple-node-app-a", "version": "1.0.0"}' > package.json
RUN npm install
RUN sleep 1
RUN echo 'const { createServer } = require("http");\
\
const server = createServer(function (req, res) {\
res.writeHead(200, { "Content-Type": "text/plain" });\
res.write("Hello from Container A");\
res.end();\
});\
\
server.listen(8080);\
' > app.js
EXPOSE 8080
@@ -0,0 +1,20 @@
FROM node:22-alpine
WORKDIR /usr/src/app
RUN echo '{"name": "simple-node-app-b", "version": "1.0.0"}' > package.json
RUN npm install
RUN sleep 1
RUN echo 'const { createServer } = require("http");\
\
const server = createServer(function (req, res) {\
res.writeHead(200, { "Content-Type": "text/plain" });\
res.write("Hello from Container B");\
res.end();\
});\
\
server.listen(8080);\
' > app.js
EXPOSE 8080
@@ -0,0 +1,49 @@
import { DurableObject } from "cloudflare:workers";
class FixtureTestContainerBase extends DurableObject<Env> {
container: globalThis.Container;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.container = ctx.container;
}
async fetch(req: Request) {
if (!this.container.running) {
this.container.start({
entrypoint: ["node", "app.js"],
enableInternet: false,
});
// On the first request we simply start the container and return,
// on the following requests the container can actually be accessed.
// Note that we do this this way because container.start is not awaitable
// meaning that we can't simply wait here for the container to be ready
return new Response("Container started");
}
return this.container
.getTcpPort(8080)
.fetch("http://foo/bar/baz", { method: "POST", body: "hello" });
}
}
export class FixtureTestContainerA extends FixtureTestContainerBase {}
export class FixtureTestContainerB extends FixtureTestContainerBase {}
export default {
async fetch(request, env): Promise<Response> {
const getContainerText = async (
container: "CONTAINER_A" | "CONTAINER_B"
) => {
const id = env[container].idFromName("container");
const stub = env[container].get(id);
return await (await stub.fetch(request)).text();
};
const containerAText = await getContainerText("CONTAINER_A");
const containerBText = await getContainerText("CONTAINER_B");
return new Response(
`Response from A: "${containerAText}"` +
" " +
`Response from B: "${containerBText}"`
);
},
} satisfies ExportedHandler<Env>;
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"types": ["@cloudflare/workers-types"],
"moduleResolution": "node",
"noEmit": true,
"skipLibCheck": true
},
"include": ["**/*.ts"]
}
@@ -0,0 +1,14 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types ./multi-containers-app/src/worker-configuration.d.ts -c ./multi-containers-app/wrangler.jsonc --no-include-runtime` (hash: bbb41fc3f9d6ad4abf178ab91cb66515)
interface __BaseEnv_Env {
CONTAINER_A: DurableObjectNamespace<import("./index").FixtureTestContainerA>;
CONTAINER_B: DurableObjectNamespace<import("./index").FixtureTestContainerB>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./index");
durableNamespaces: "FixtureTestContainerA" | "FixtureTestContainerB";
}
interface Env extends __BaseEnv_Env {}
}
interface Env extends __BaseEnv_Env {}
@@ -0,0 +1,41 @@
{
"name": "multi-containers-app",
"main": "src/index.ts",
"compatibility_date": "2025-04-03",
"containers": [
{
"image": "./DockerfileA",
"class_name": "FixtureTestContainerA",
"name": "containerA",
"max_instances": 2,
},
{
"image": "./DockerfileB",
"class_name": "FixtureTestContainerB",
"name": "containerB",
"max_instances": 2,
},
],
"durable_objects": {
"bindings": [
{
"class_name": "FixtureTestContainerA",
"name": "CONTAINER_A",
},
{
"class_name": "FixtureTestContainerB",
"name": "CONTAINER_B",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["FixtureTestContainerA"],
},
{
"tag": "v1",
"new_sqlite_classes": ["FixtureTestContainerB"],
},
],
}
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"lib": ["ES2020"],
"types": ["@cloudflare/workers-types"],
"moduleResolution": "node",
"noEmit": true,
"skipLibCheck": true
},
"include": ["**/*.ts"],
"exclude": ["tests"]
}
@@ -0,0 +1,18 @@
FROM node:22-alpine
WORKDIR /usr/src/app
RUN echo '{"name": "simple-node-app", "version": "1.0.0"}' > package.json
RUN npm install
RUN echo 'const { createServer } = require("http");\
\
const server = createServer(function (req, res) {\
res.writeHead(200, { "Content-Type": "text/plain" });\
res.write("Hello from Container A");\
res.end();\
});\
\
server.listen(8080);\
' > app.js
EXPOSE 8080
@@ -0,0 +1,38 @@
import { DurableObject } from "cloudflare:workers";
export class FixtureTestContainerA extends DurableObject<Env> {
container: globalThis.Container;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.container = ctx.container;
}
async fetch(req: Request) {
if (!this.container.running) {
this.container.start({
entrypoint: ["node", "app.js"],
enableInternet: false,
});
// On the first request we simply start the container and return,
// on the following requests the container can actually be accessed.
// Note that we do this this way because container.start is not awaitable
// meaning that we can't simply wait here for the container to be ready
return new Response("Container started");
}
return this.container
.getTcpPort(8080)
.fetch("http://foo/bar/baz", { method: "POST", body: "hello" });
}
}
export default {
async fetch(request, env): Promise<Response> {
const id = env.CONTAINER_A.idFromName("container");
const stub = env.CONTAINER_A.get(id);
return Response.json({
containerAText: await (await stub.fetch(request)).text(),
containerBText: await (await env.WORKER_B.fetch(request)).text(),
});
},
} satisfies ExportedHandler<Env>;
@@ -0,0 +1,14 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types ./multi-workers-containers-app/workerA/worker-configuration.d.ts -c ./multi-workers-containers-app/workerA/wrangler.jsonc --no-include-runtime` (hash: e414ffcd6be2e270f12de677a3fccaaf)
interface __BaseEnv_Env {
CONTAINER_A: DurableObjectNamespace<import("./index").FixtureTestContainerA>;
WORKER_B: Fetcher /* worker-b */;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./index");
durableNamespaces: "FixtureTestContainerA";
}
interface Env extends __BaseEnv_Env {}
}
interface Env extends __BaseEnv_Env {}
@@ -0,0 +1,28 @@
{
"name": "worker-a",
"main": "index.ts",
"compatibility_date": "2025-04-03",
"services": [{ "binding": "WORKER_B", "service": "worker-b" }],
"containers": [
{
"image": "./Dockerfile",
"class_name": "FixtureTestContainerA",
"name": "container",
"max_instances": 2,
},
],
"durable_objects": {
"bindings": [
{
"class_name": "FixtureTestContainerA",
"name": "CONTAINER_A",
},
],
},
"migrations": [
{
"tag": "v1",
"new_sqlite_classes": ["FixtureTestContainerA"],
},
],
}
@@ -0,0 +1,18 @@
FROM node:22-alpine
WORKDIR /usr/src/app
RUN echo '{"name": "simple-node-app", "version": "1.0.0"}' > package.json
RUN npm install
RUN echo 'const { createServer } = require("http");\
\
const server = createServer(function (req, res) {\
res.writeHead(200, { "Content-Type": "text/plain" });\
res.write("Hello from Container B");\
res.end();\
});\
\
server.listen(8080);\
' > app.js
EXPOSE 8080
@@ -0,0 +1,35 @@
import { DurableObject } from "cloudflare:workers";
export class FixtureTestContainerB extends DurableObject<Env> {
container: globalThis.Container;
constructor(ctx: DurableObjectState, env: Env) {
super(ctx, env);
this.container = ctx.container;
}
async fetch(req: Request) {
if (!this.container.running) {
this.container.start({
entrypoint: ["node", "app.js"],
enableInternet: false,
});
// On the first request we simply start the container and return,
// on the following requests the container can actually be accessed.
// Note that we do this this way because container.start is not awaitable
// meaning that we can't simply wait here for the container to be ready
return new Response("Container started");
}
return this.container
.getTcpPort(8080)
.fetch("http://foo/bar/baz", { method: "POST", body: "hello" });
}
}
export default {
async fetch(request, env): Promise<Response> {
const id = env.CONTAINER_B.idFromName("container");
const stub = env.CONTAINER_B.get(id);
return stub.fetch(request);
},
} satisfies ExportedHandler<Env>;
@@ -0,0 +1,13 @@
/* eslint-disable */
// Generated by Wrangler by running `wrangler types ./multi-workers-containers-app/workerB/worker-configuration.d.ts -c ./multi-workers-containers-app/workerB/wrangler.jsonc --no-include-runtime` (hash: ffbf775a9ee9c3d83577a68a6b563542)
interface __BaseEnv_Env {
CONTAINER_B: DurableObjectNamespace<import("./index").FixtureTestContainerB>;
}
declare namespace Cloudflare {
interface GlobalProps {
mainModule: typeof import("./index");
durableNamespaces: "FixtureTestContainerB";
}
interface Env extends __BaseEnv_Env {}
}
interface Env extends __BaseEnv_Env {}
@@ -0,0 +1,27 @@
{
"name": "worker-b",
"main": "index.ts",
"compatibility_date": "2025-04-03",
"containers": [
{
"image": "./Dockerfile",
"class_name": "FixtureTestContainerB",
"name": "container",
"max_instances": 2,
},
],
"durable_objects": {
"bindings": [
{
"class_name": "FixtureTestContainerB",
"name": "CONTAINER_B",
},
],
},
"migrations": [
{
"tag": "v1",
"new_classes": ["FixtureTestContainerB"],
},
],
}
@@ -0,0 +1,21 @@
{
"name": "@fixture/interactive-dev",
"private": true,
"scripts": {
"cf-typegen": "pnpm run \"/^typegen:.*/\"",
"test:ci": "vitest run",
"test:watch": "vitest",
"typegen:container-app": "wrangler types ./container-app/src/worker-configuration.d.ts -c ./container-app/wrangler.jsonc --no-include-runtime",
"typegen:multi-containers-app": "wrangler types ./multi-containers-app/src/worker-configuration.d.ts -c ./multi-containers-app/wrangler.jsonc --no-include-runtime",
"typegen:multi-workers-containers-app": "wrangler types ./multi-workers-containers-app/workerA/worker-configuration.d.ts -c ./multi-workers-containers-app/workerA/wrangler.jsonc --no-include-runtime && wrangler types ./multi-workers-containers-app/workerB/worker-configuration.d.ts -c ./multi-workers-containers-app/workerB/wrangler.jsonc --no-include-runtime"
},
"devDependencies": {
"@fixture/shared": "workspace:*",
"undici": "catalog:default",
"vitest": "catalog:default",
"wrangler": "workspace:*"
},
"optionalDependencies": {
"@cdktf/node-pty-prebuilt-multiarch": "0.10.2"
}
}
@@ -0,0 +1 @@
<p>body</p>
@@ -0,0 +1,5 @@
export default {
async fetch() {
return new Response("body");
},
};
@@ -0,0 +1,5 @@
export default {
async fetch(): Promise<Response {
return new Response("body");
},
};
@@ -0,0 +1,7 @@
export default {
async fetch(req, env) {
return new Response(
"hello from a & " + (await env.WORKER.fetch(req).then((r) => r.text()))
);
},
};
@@ -0,0 +1,5 @@
export default {
async fetch() {
return new Response("hello from b");
},
};
@@ -0,0 +1,943 @@
import childProcess, { execSync } from "node:child_process";
import fs from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import rl from "node:readline";
import stream from "node:stream";
import { stripVTControlCharacters } from "node:util";
import { removeDir } from "@fixture/shared/src/fs-helpers";
import { fetch } from "undici";
/* eslint-disable workers-sdk/no-vitest-import-expect -- complex test with .each patterns */
import {
afterAll,
afterEach,
assert,
describe as baseDescribe,
beforeAll,
expect,
it,
vi,
} from "vitest";
/* eslint-enable workers-sdk/no-vitest-import-expect */
import { wranglerEntryPath } from "../../shared/src/run-wrangler-long-lived";
import type pty from "@cdktf/node-pty-prebuilt-multiarch";
// These tests are failing with `Error: read EPIPE` on Windows in CI. There's still value running them on macOS and Linux.
if (process.platform === "win32") {
baseDescribe("interactive dev session tests", () => {
it.skip("skipped on Windows", () => {});
});
} else {
// Windows doesn't have a built-in way to get the CWD of a process by its ID.
// This functionality is provided by the Windows Driver Kit which is installed
// on GitHub actions Windows runners.
const tlistPath =
"C:\\Program Files (x86)\\Windows Kits\\10\\Debuggers\\x86\\tlist.exe";
let windowsProcessCwdSupported = true;
if (!fs.existsSync(tlistPath)) {
windowsProcessCwdSupported = false;
const message = [
"=".repeat(80),
"Unable to find Windows Driver Kit, skipping zombie process tests... :(",
"=".repeat(80),
].join("\n");
console.error(message);
}
const pkgRoot = path.resolve(__dirname, "..");
const ptyOptions: pty.IPtyForkOptions = {
name: "xterm-color",
cols: 80,
rows: 30,
cwd: pkgRoot,
env: process.env as Record<string, string>,
};
// Check `node-pty` installed and working correctly, skipping tests if not
let nodePtySupported = true;
try {
const pty = await import("@cdktf/node-pty-prebuilt-multiarch");
const ptyProcess = pty.spawn(
process.execPath,
["-p", "'ran node'"],
ptyOptions
);
let output = "";
ptyProcess.onData((data) => (output += data));
const code = await new Promise<number>((resolve) =>
ptyProcess.onExit(({ exitCode }) => resolve(exitCode))
);
assert.strictEqual(code, 0);
assert(output.includes("ran node"));
} catch (e) {
nodePtySupported = false;
const message = [
"=".repeat(80),
"`node-pty` unsupported, skipping interactive dev session tests... :(",
"",
"Ensure its dependencies (https://github.com/microsoft/node-pty#dependencies)",
"are installed, then re-run `pnpm install` in the repository root.",
"",
"On Windows, make sure you have `Desktop development with C++`, `Windows SDK`,",
"`MSVC VS C++ build tools`, and `MSVC VS C++ Spectre-mitigated libs` Visual",
"Studio components installed.",
"",
e instanceof Error ? e.stack : String(e),
"=".repeat(80),
].join("\n");
console.error(message);
}
const describe = baseDescribe.runIf(nodePtySupported);
interface PtyProcess {
pty: pty.IPty;
stdout: string;
exitCode: number | null;
exitPromise: Promise<number>;
url: string;
}
const processes: PtyProcess[] = [];
afterEach(() => {
for (const p of processes.splice(0)) {
// If the process didn't exit cleanly, log its output for debugging
if (p.exitCode !== 0) console.log(p.stdout);
// If the process hasn't exited yet, kill it
if (p.exitCode === null) {
// `node-pty` throws if signal passed on Windows
if (process.platform === "win32") p.pty.kill();
else p.pty.kill("SIGKILL");
}
}
});
const readyRegexp = /Ready on (http:\/\/[a-z0-9.]+:[0-9]+)/;
async function startWranglerDev(
args: string[],
skipWaitingForReady = false,
env?: Record<string, string>
) {
const stdoutStream = new stream.PassThrough();
const stdoutInterface = rl.createInterface(stdoutStream);
let exitResolve: ((code: number) => void) | undefined;
const exitPromise = new Promise<number>(
(resolve) => (exitResolve = resolve)
);
const ptyOptionsWithEnv = {
...ptyOptions,
env: env ?? (process.env as Record<string, string>),
} satisfies pty.IPtyForkOptions;
const pty = await import("@cdktf/node-pty-prebuilt-multiarch");
const ptyProcess = pty.spawn(
process.execPath,
[
wranglerEntryPath,
...args,
"--ip=127.0.0.1",
"--port=0",
"--inspector-port=0",
],
ptyOptionsWithEnv
);
const result: PtyProcess = {
pty: ptyProcess,
stdout: "",
exitCode: null,
exitPromise,
url: "",
};
processes.push(result);
ptyProcess.onData((data) => {
result.stdout += data;
stdoutStream.write(data);
});
ptyProcess.onExit(({ exitCode }) => {
result.exitCode = exitCode;
exitResolve?.(exitCode);
stdoutStream.end();
});
if (!skipWaitingForReady) {
let readyMatch: RegExpMatchArray | null = null;
for await (const line of stdoutInterface) {
if (
(readyMatch = readyRegexp.exec(stripVTControlCharacters(line))) !==
null
)
break;
}
assert(readyMatch !== null, "Expected ready message");
result.url = readyMatch[1];
}
return result;
}
interface Process {
pid: string;
cmd: string;
}
function getProcesses(): Process[] {
if (process.platform === "win32") {
return childProcess
.execSync("tasklist /fo csv", { encoding: "utf8" })
.trim()
.split("\r\n")
.slice(1)
.map((line) => {
const [cmd, pid] = line.replaceAll('"', "").split(",");
return { pid, cmd };
});
} else {
return childProcess
.execSync("ps -e | awk '{print $1,$4}'", { encoding: "utf8" })
.trim()
.split("\n")
.map((line) => {
const [pid, cmd] = line.split(" ");
return { pid, cmd };
});
}
}
function getProcessCwd(pid: string | number) {
if (process.platform === "win32") {
if (windowsProcessCwdSupported) {
return (
childProcess
.spawnSync(tlistPath, [String(pid)], { encoding: "utf8" })
.stdout.match(/^\s*CWD:\s*(.+)\\$/m)?.[1] ?? ""
);
} else {
return "";
}
} else {
return childProcess
.execSync(`lsof -p ${pid} | awk '$4=="cwd" {print $9}'`, {
encoding: "utf8",
})
.trim();
}
}
function getStartedWorkerdProcesses(): Process[] {
return getProcesses().filter(
({ cmd, pid }) =>
cmd.includes("workerd") && getProcessCwd(pid) === pkgRoot
);
}
const devScripts = [
{ args: ["dev"], expectedBody: "body" },
{ args: ["pages", "dev", "public"], expectedBody: "<p>body</p>" },
];
const exitKeys = [
{ name: "CTRL-C", key: "\x03" },
{ name: "x", key: "x" },
];
describe.each(devScripts)("wrangler $args", ({ args, expectedBody }) => {
it.each(exitKeys)("cleanly exits with $name", async ({ key }) => {
const beginProcesses = getStartedWorkerdProcesses();
const wrangler = await startWranglerDev(args);
const duringProcesses = getStartedWorkerdProcesses();
// Check dev server working correctly
const res = await fetch(wrangler.url);
expect((await res.text()).trim()).toBe(expectedBody);
// Check key cleanly exits dev server
wrangler.pty.write(key);
expect(await wrangler.exitPromise).toBe(0);
const endProcesses = getStartedWorkerdProcesses();
// Check no hanging workerd processes
if (process.platform !== "win32" || windowsProcessCwdSupported) {
expect(beginProcesses.length).toBe(endProcesses.length);
expect(duringProcesses.length).toBeGreaterThan(beginProcesses.length);
}
});
describe("--show-interactive-dev-session", () => {
it("should show hotkeys when interactive", async () => {
const wrangler = await startWranglerDev(args);
wrangler.pty.kill();
expect(wrangler.stdout).toContain("open a browser");
expect(wrangler.stdout).toContain("open devtools");
expect(wrangler.stdout).toContain("clear console");
expect(wrangler.stdout).toContain("to exit");
expect(wrangler.stdout).toContain("open local explorer");
expect(wrangler.stdout).not.toContain("rebuild container");
});
it("should not show hotkeys with --show-interactive-dev-session=false", async () => {
const wrangler = await startWranglerDev([
...args,
"--show-interactive-dev-session=false",
]);
wrangler.pty.kill();
expect(wrangler.stdout).not.toContain("open a browser");
expect(wrangler.stdout).not.toContain("open devtools");
expect(wrangler.stdout).not.toContain("clear console");
expect(wrangler.stdout).not.toContain("to exit");
expect(wrangler.stdout).not.toContain("rebuild container");
expect(wrangler.stdout).not.toContain("open local explorer");
});
});
});
it.each(exitKeys)("multiworker cleanly exits with $name", async ({ key }) => {
const beginProcesses = getStartedWorkerdProcesses();
const wrangler = await startWranglerDev([
"dev",
"-c",
"wrangler.a.jsonc",
"-c",
"wrangler.b.jsonc",
]);
const duringProcesses = getStartedWorkerdProcesses();
// Check dev server working correctly
const res = await fetch(wrangler.url);
expect((await res.text()).trim()).toBe("hello from a & hello from b");
// Check key cleanly exits dev server
wrangler.pty.write(key);
expect(await wrangler.exitPromise).toBe(0);
const endProcesses = getStartedWorkerdProcesses();
// Check no hanging workerd processes
if (process.platform !== "win32" || windowsProcessCwdSupported) {
expect(beginProcesses.length).toBe(endProcesses.length);
expect(duringProcesses.length).toBeGreaterThan(beginProcesses.length);
}
});
const isCINonLinux =
process.platform !== "linux" && process.env.CI === "true";
function isDockerRunning() {
try {
execSync("docker ps", { stdio: "ignore" });
return true;
} catch (e) {
return false;
}
}
/** Indicates whether the test is being run locally (not in CI) AND docker is currently not running on the system */
const isLocalWithoutDockerRunning =
process.env.CI !== "true" && !isDockerRunning();
if (isLocalWithoutDockerRunning) {
console.warn(
"The tests are running locally but there is no docker instance running on the system, skipping containers tests"
);
}
baseDescribe.skipIf(
isCINonLinux ||
// If the tests are being run locally and docker is not running we just skip this test
isLocalWithoutDockerRunning
)("containers", () => {
// it seems like if we spam the container too often, it freezes up and crashes
const WAITFOR_OPTIONS = { timeout: 2000, interval: 500 };
baseDescribe("container dev", { retry: 0, timeout: 90000 }, () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(tmpdir(), "wrangler-container-"));
fs.cpSync(
path.resolve(__dirname, "../", "container-app"),
path.join(tmpDir),
{
recursive: true,
}
);
const ids = getContainerIds();
if (ids.length > 0) {
execSync("docker rm -f " + ids.join(" "), {
encoding: "utf8",
});
}
});
afterEach(async () => {
const ids = getContainerIds();
if (ids.length > 0) {
execSync("docker rm -f " + ids.join(" "), {
encoding: "utf8",
});
}
});
afterAll(() => {
removeDir(tmpDir, { fireAndForget: true });
});
it("should print rebuild containers hotkey", async () => {
const wrangler = await startWranglerDev([
"dev",
"-c",
path.join(tmpDir, "wrangler.jsonc"),
]);
wrangler.pty.kill();
expect(wrangler.stdout).toContain("rebuild container");
});
it("should rebuild a container when the hotkey is pressed", async () => {
const wrangler = await startWranglerDev([
"dev",
"-c",
path.join(tmpDir, "wrangler.jsonc"),
]);
await fetch(wrangler.url + "/start");
// wait container to be ready
await vi.waitFor(async () => {
const status = await fetch(wrangler.url + "/status", {
signal: AbortSignal.timeout(500),
});
expect(await status.json()).toBe(true);
}, WAITFOR_OPTIONS);
await vi.waitFor(async () => {
const res = await fetch(wrangler.url + "/fetch", {
// Sometimes this fetch can hang if the container is not ready
// The default timeout is longer than the timeout on the `waitFor()` which results in the test failing.
// So abort this request sooner to allow it to retry.
signal: AbortSignal.timeout(500),
});
expect(await res.text()).toBe(
"Hello World! Have an env var! I'm an env var!"
);
}, WAITFOR_OPTIONS);
const output = wrangler.stdout;
// Extract the Docker image name from the output
const imageNameMatch = output.match(
/cloudflare-dev\/fixturetestcontainer:[a-f0-9]+/
);
expect(imageNameMatch).not.toBe(null);
const imageName = imageNameMatch![0];
expect(
JSON.parse(
execSync(
`docker image inspect ${imageName} --format "{{ json .RepoTags }}"`,
{ encoding: "utf8" }
)
).length
).toBeGreaterThanOrEqual(1);
fs.writeFileSync(
path.join(tmpDir, "container", "simple-node-app.js"),
`const { createServer } = require("http");
const server = createServer(function (req, res) {
res.writeHead(200, { "Content-Type": "text/plain" });
res.write("Blah! " + process.env.MESSAGE);
res.end();
});
server.listen(8080, function () {
console.log("Server listening on port 8080");
});`,
"utf-8"
);
// Clear the captured stdout so we can match on log output
// produced AFTER the rebuild hotkey is pressed.
wrangler.stdout = "";
wrangler.pty.write("r");
// Wait for the rebuild and workerd reload to actually
// finish before asserting on `/status`. Without this,
// the old workerd (which still reports the container as
// running even after `docker rm`) can satisfy /status
// requests during the rebuild window, and requests can
// hang while miniflare is reloading — both cause flakes
// on slow CI.
await vi.waitFor(
() => {
expect(wrangler.stdout).toMatch(/Local server updated and ready/);
},
{ timeout: 30_000, interval: 500 }
);
await vi.waitFor(async () => {
const status = await fetch(wrangler.url + "/status", {
signal: AbortSignal.timeout(500),
});
expect(await status.json()).toBe(false);
}, WAITFOR_OPTIONS);
await fetch(wrangler.url + "/start");
await vi.waitFor(async () => {
const status = await fetch(wrangler.url + "/status", {
signal: AbortSignal.timeout(500),
});
expect(await status.json()).toBe(true);
}, WAITFOR_OPTIONS);
await vi.waitFor(async () => {
const res = await fetch(wrangler.url + "/fetch", {
signal: AbortSignal.timeout(500),
});
expect(await res.text()).toBe("Blah! I'm an env var!");
}, WAITFOR_OPTIONS);
// Verify that the old image tag has been deleted after rebuild
expect(() => {
execSync(`docker image inspect ${imageName}`, { encoding: "utf8" });
}).toThrow();
wrangler.pty.kill();
});
it("should clean up any containers that were started", async () => {
const wrangler = await startWranglerDev([
"dev",
"-c",
path.join(tmpDir, "wrangler.jsonc"),
]);
await fetch(wrangler.url + "/start");
// wait container to be ready
await vi.waitFor(async () => {
const ids = getContainerIds();
expect(ids.length).toBe(1);
}, WAITFOR_OPTIONS);
// ctrl + c
wrangler.pty.write("\x03");
await new Promise<void>((resolve) => {
wrangler.pty.onExit(() => resolve());
});
// `docker rm -f` of the running container after the SIGINT
// teardown can take several seconds on a busy CI runner, so
// allow more than the default 5s `vi.waitFor` timeout.
await vi.waitFor(
() => {
expect(getContainerIds()).toHaveLength(0);
},
{ timeout: 10_000, interval: 500 }
);
});
});
baseDescribe(
"container dev where image build takes a long time",
{ retry: 0, timeout: 90000 },
() => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(
path.join(tmpdir(), "wrangler-container-sleep-")
);
fs.cpSync(
path.resolve(__dirname, "../", "container-app"),
path.join(tmpDir),
{
recursive: true,
}
);
const tmpDockerFilePath = path.join(tmpDir, "Dockerfile");
fs.rmSync(tmpDockerFilePath);
fs.renameSync(
path.join(tmpDir, "DockerfileWithLongSleep"),
tmpDockerFilePath
);
const ids = getContainerIds();
if (ids.length > 0) {
execSync("docker rm -f " + ids.join(" "), {
encoding: "utf8",
});
}
});
afterEach(async () => {
const ids = getContainerIds();
if (ids.length > 0) {
execSync("docker rm -f " + ids.join(" "), {
encoding: "utf8",
});
}
});
afterAll(() => {
removeDir(tmpDir, { fireAndForget: true });
});
it("should allow quitting while the image is building", async () => {
const wrangler = await startWranglerDev(
["dev", "-c", path.join(tmpDir, "wrangler.jsonc")],
true
);
// Use a generous timeout to accommodate slow CI runners —
// 10s was occasionally tight when the docker daemon was
// under contention from other parallel fixtures.
const waitForOptions = { timeout: 30_000, interval: 500 };
// wait for long sleep instruction to start
await vi.waitFor(async () => {
expect(wrangler.stdout).toContain("RUN sleep 50000");
}, waitForOptions);
wrangler.pty.write("q");
await vi.waitFor(async () => {
expect(wrangler.stdout).toMatch(/CANCELED \[.*?\] RUN sleep 50000/);
}, waitForOptions);
});
it("should rebuilding while the image is building", async () => {
const wrangler = await startWranglerDev(
["dev", "-c", path.join(tmpDir, "wrangler.jsonc")],
true
);
// We assert on wrangler's own deterministic log line, which
// is emitted synchronously when wrangler picks up a new
// `containerBuildId` and starts the rebuild flow. We
// intentionally do NOT match "This (no-op) build takes
// forever..." or buildkit's `CANCELED` line — both come
// from docker buildkit's TTY output and are unreliable:
// the first is redrawn an unpredictable number of times
// per build, and the second is only emitted when buildkit
// has time to flush its cancellation status before the
// docker CLI is killed by the abort. Relying on either of
// those made earlier versions of this test flaky on slow
// CI (see
// https://github.com/cloudflare/workers-sdk/actions/runs/24924588002).
const PREPARING_RE = /⎔ Preparing container image\(s\)/;
const SLEEP_RE = /RUN sleep 50000/;
const waitForOptions = { timeout: 30_000, interval: 500 };
// 1. Wait for the initial build to reach the long sleep
// instruction — this is the "while the image is building"
// state that the test name refers to.
await vi.waitFor(() => {
expect(wrangler.stdout).toMatch(SLEEP_RE);
}, waitForOptions);
// 2. First `r`: should trigger another rebuild while the
// initial build is still in its long-sleep phase. Clear
// the captured stdout so the assertion below only sees
// output produced after the press.
wrangler.stdout = "";
wrangler.pty.write("r");
await vi.waitFor(() => {
expect(wrangler.stdout).toMatch(PREPARING_RE);
}, waitForOptions);
// 3. Wait for the second build to also reach the long sleep
// instruction so the next `r` press is genuinely "while
// the image is building".
await vi.waitFor(() => {
expect(wrangler.stdout).toMatch(SLEEP_RE);
}, waitForOptions);
// 4. Second `r`: should again trigger another rebuild.
// Clear stdout again so the assertion only sees the new
// Preparing log.
wrangler.stdout = "";
wrangler.pty.write("r");
await vi.waitFor(() => {
expect(wrangler.stdout).toMatch(PREPARING_RE);
}, waitForOptions);
wrangler.pty.kill();
});
}
);
baseDescribe("multi-containers dev", { retry: 0, timeout: 50000 }, () => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(
path.join(tmpdir(), "wrangler-multi-containers-")
);
fs.cpSync(
path.resolve(__dirname, "../", "multi-containers-app"),
path.join(tmpDir),
{
recursive: true,
}
);
const ids = getContainerIds();
if (ids.length > 0) {
execSync("docker rm -f " + ids.join(" "), {
encoding: "utf8",
});
}
});
afterEach(async () => {
const ids = getContainerIds();
if (ids.length > 0) {
execSync("docker rm -f " + ids.join(" "), {
encoding: "utf8",
});
}
});
afterAll(() => {
removeDir(tmpDir, { fireAndForget: true });
});
it("should print build logs for all the containers", async () => {
const wrangler = await startWranglerDev([
"dev",
"-c",
path.join(tmpDir, "wrangler.jsonc"),
]);
await vi.waitFor(
() => {
expect(wrangler.stdout).toContain('"name": "simple-node-app-a"');
expect(wrangler.stdout).toContain('"name": "simple-node-app-b"');
},
{ timeout: 10_000 }
);
wrangler.pty.kill();
});
it("should rebuild all the containers when the hotkey is pressed", async () => {
const wrangler = await startWranglerDev([
"dev",
"-c",
path.join(tmpDir, "wrangler.jsonc"),
]);
await vi.waitFor(
async () => {
const text = await (
await fetch(wrangler.url, { signal: AbortSignal.timeout(3_000) })
).text();
expect(text).toBe(
'Response from A: "Hello from Container A" Response from B: "Hello from Container B"'
);
},
{ timeout: 30_000, interval: 1000 }
);
const tmpDockerfileAPath = path.join(tmpDir, "DockerfileA");
const dockerFileAContent = fs.readFileSync(tmpDockerfileAPath, "utf8");
fs.writeFileSync(
tmpDockerfileAPath,
dockerFileAContent.replace(
'"Hello from Container A"',
'"Hello World from Container A"'
),
"utf-8"
);
const tmpDockerfileBPath = path.join(tmpDir, "DockerfileB");
const dockerFileBContent = fs.readFileSync(tmpDockerfileBPath, "utf8");
fs.writeFileSync(
tmpDockerfileBPath,
dockerFileBContent.replace(
'"Hello from Container B"',
'"Hello from the B Container"'
),
"utf-8"
);
wrangler.pty.write("r");
await vi.waitFor(
async () => {
const text = await (
await fetch(wrangler.url, { signal: AbortSignal.timeout(3_000) })
).text();
expect(text).toBe(
'Response from A: "Hello World from Container A" Response from B: "Hello from the B Container"'
);
},
{ timeout: 30_000, interval: 1000 }
);
fs.writeFileSync(tmpDockerfileAPath, dockerFileAContent, "utf-8");
fs.writeFileSync(tmpDockerfileBPath, dockerFileBContent, "utf-8");
wrangler.pty.kill();
});
it("should clean up any containers that were started", async () => {
const wrangler = await startWranglerDev([
"dev",
"-c",
path.join(tmpDir, "wrangler.jsonc"),
]);
// wait container to be ready
await vi.waitFor(
async () => {
const text = await (
await fetch(wrangler.url, { signal: AbortSignal.timeout(3_000) })
).text();
expect(text).toBe(
'Response from A: "Hello from Container A" Response from B: "Hello from Container B"'
);
},
{ timeout: 30_000, interval: 1000 }
);
const ids = getContainerIds();
expect(ids.length).toBe(2);
// ctrl + c
wrangler.pty.write("\x03");
await new Promise<void>((resolve) => {
wrangler.pty.onExit(() => resolve());
});
// `docker rm -f` of multiple running containers after the
// SIGINT teardown can take several seconds on a busy CI
// runner, so allow more than the default 5s `vi.waitFor`
// timeout.
await vi.waitFor(
() => {
const remainingIds = getContainerIds();
expect(remainingIds).toHaveLength(0);
},
{ timeout: 10_000, interval: 500 }
);
});
});
baseDescribe(
"multi-workers containers dev",
{ retry: 0, timeout: 50000 },
() => {
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(
path.join(tmpdir(), "wrangler-multi-workers-containers-")
);
fs.cpSync(
path.resolve(__dirname, "../", "multi-workers-containers-app"),
path.join(tmpDir),
{
recursive: true,
}
);
const ids = getContainerIds();
if (ids.length > 0) {
execSync("docker rm -f " + ids.join(" "), {
encoding: "utf8",
});
}
});
afterEach(async () => {
const ids = getContainerIds();
if (ids.length > 0) {
execSync("docker rm -f " + ids.join(" "), {
encoding: "utf8",
});
}
});
afterAll(() => {
removeDir(tmpDir, { fireAndForget: true });
});
it("should be able to interact with both workers, rebuild the containers with the hotkey and all containers should be cleaned up at the end", async () => {
const wrangler = await startWranglerDev([
"dev",
"-c",
path.join(tmpDir, "workerA/wrangler.jsonc"),
"-c",
path.join(tmpDir, "workerB/wrangler.jsonc"),
]);
await vi.waitFor(
async () => {
const json = await (
await fetch(wrangler.url, {
signal: AbortSignal.timeout(5_000),
})
).json();
expect(json).toEqual({
containerAText: "Hello from Container A",
containerBText: "Hello from Container B",
});
},
{ timeout: 10_000, interval: 1000 }
);
const tmpDockerfileAPath = path.join(tmpDir, "workerA/Dockerfile");
const dockerFileAContent = fs.readFileSync(
tmpDockerfileAPath,
"utf8"
);
fs.writeFileSync(
tmpDockerfileAPath,
dockerFileAContent.replace(
'"Hello from Container A"',
'"Hello World from Container A"'
),
"utf-8"
);
const tmpDockerfileBPath = path.join(tmpDir, "workerB/Dockerfile");
const dockerFileBContent = fs.readFileSync(
tmpDockerfileBPath,
"utf8"
);
fs.writeFileSync(
tmpDockerfileBPath,
dockerFileBContent.replace(
'"Hello from Container B"',
'"Hello from the B Container"'
),
"utf-8"
);
wrangler.pty.write("r");
await vi.waitFor(
async () => {
const json = await (
await fetch(wrangler.url, {
signal: AbortSignal.timeout(5_000),
})
).json();
expect(json).toEqual({
containerAText: "Hello World from Container A",
containerBText: "Hello from the B Container",
});
},
{ timeout: 30_000, interval: 1000 }
);
fs.writeFileSync(tmpDockerfileAPath, dockerFileAContent, "utf-8");
fs.writeFileSync(tmpDockerfileBPath, dockerFileBContent, "utf-8");
wrangler.pty.kill("SIGINT");
});
}
);
/** gets any containers that were created by running this fixture */
const getContainerIds = () => {
// note the -a to include stopped containers
const allContainers = execSync(`docker ps -a --format json`)
.toString()
.split("\n")
.filter((line) => line.trim());
if (allContainers.length === 0) {
return [];
}
const jsonOutput = allContainers.map((line) => JSON.parse(line));
const matches = jsonOutput.map((container) => {
if (container.Image.includes("cloudflare-dev/fixturetestcontainer")) {
return container.ID;
}
});
return matches.filter(Boolean);
};
});
}
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"isolatedModules": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node",
"target": "esnext",
"module": "esnext",
"strict": true,
"lib": ["esnext"]
}
}
@@ -0,0 +1,12 @@
import { defineProject, mergeConfig } from "vitest/config";
import configShared from "../../vitest.shared";
export default mergeConfig(
configShared,
defineProject({
test: {
// `node-pty` doesn't work inside worker threads
pool: "forks",
},
})
);
@@ -0,0 +1,11 @@
{
"name": "a",
"main": "src/worker-a.mjs",
"compatibility_date": "2023-12-01",
"services": [
{
"binding": "WORKER",
"service": "b",
},
],
}
@@ -0,0 +1,5 @@
{
"name": "b",
"main": "src/worker-b.mjs",
"compatibility_date": "2023-12-01",
}
@@ -0,0 +1,4 @@
{
"main": "src/index.mjs",
"compatibility_date": "2023-12-01",
}