Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .changeset/evict-durable-objects.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
"@cloudflare/vitest-pool-workers": patch
Comment thread
apeacock1991 marked this conversation as resolved.
---

Add `evictDurableObject` and `evictAllDurableObjects` test helpers to `cloudflare:test`

These helpers let you exercise how a Durable Object behaves across evictions in your tests. Eviction is graceful: durable storage is preserved, in-memory state is reset by tearing down the instance, hibernatable WebSockets are hibernated rather than closed, and eviction waits for in-flight requests to drain.

```ts
import { evictDurableObject, evictAllDurableObjects } from "cloudflare:test";
import { env } from "cloudflare:workers";

const id = env.COUNTER.idFromName("my-counter");
const stub = env.COUNTER.get(id);

// Evict the Durable Object instance pointed to by a specific stub
await evictDurableObject(stub);
await evictDurableObject(stub, { webSockets: "close" });

// Evict all currently-running Durable Objects in evictable namespaces
await evictAllDurableObjects();
```
40 changes: 20 additions & 20 deletions fixtures/vitest-pool-workers-examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,25 @@

This directory contains example projects tested with `@cloudflare/vitest-pool-workers`. It aims to provide the building blocks for you to write tests for your own Workers.

| Directory | Overview |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------ |
| [✅ basics-unit-integration-self](basics-unit-integration-self) | Basic unit tests and integration tests using `exports.default` |
| [⚠️ basics-integration-auxiliary](basics-integration-auxiliary) | Basic integration tests using an auxiliary worker[^1] |
| [⚡️ pages-functions-unit-integration-self](pages-functions-unit-integration-self) | Functions unit tests and integration tests using `exports.default` |
| [📦 kv-r2-caches](kv-r2-caches) | Tests using KV, R2 and the Cache API |
| [📚 d1](d1) | Tests using D1 with migrations |
| [📌 durable-objects](durable-objects) | Tests using Durable Objects with direct access |
| [🔁 workflows](workflows) | Tests using Workflows |
| [🚥 queues](queues) | Tests using Queue producers and consumers |
| [🚰 pipelines](pipelines) | Tests using Pipelines |
| [🚀 hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server |
| [🤹 request-mocking](request-mocking) | Tests using declarative (MSW) / imperative outbound request mocks |
| [🔌 multiple-workers](multiple-workers) | Tests using multiple auxiliary workers and request mocks |
| [⚙️ web-assembly](web-assembly) | Tests importing WebAssembly modules |
| [🤯 rpc](rpc) | Tests using named entrypoints, Durable Objects and RPC |
| [🧠 ai-vectorize](ai-vectorize) | Tests using Workers AI and Vectorize |
| [🔄 context-exports](context-exports) | Tests using context exports |
| [📥 dynamic-import](dynamic-import) | Tests using dynamic imports |
| [🖼️ images](images) | Tests using the Images binding |
| Directory | Overview |
| --------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
| [✅ basics-unit-integration-self](basics-unit-integration-self) | Basic unit tests and integration tests using `exports.default` |
| [⚠️ basics-integration-auxiliary](basics-integration-auxiliary) | Basic integration tests using an auxiliary worker[^1] |
| [⚡️ pages-functions-unit-integration-self](pages-functions-unit-integration-self) | Functions unit tests and integration tests using `exports.default` |
| [📦 kv-r2-caches](kv-r2-caches) | Tests using KV, R2 and the Cache API |
| [📚 d1](d1) | Tests using D1 with migrations |
| [📌 durable-objects](durable-objects) | Tests using Durable Objects with direct access, alarms and eviction |
| [🔁 workflows](workflows) | Tests using Workflows |
| [🚥 queues](queues) | Tests using Queue producers and consumers |
| [🚰 pipelines](pipelines) | Tests using Pipelines |
| [🚀 hyperdrive](hyperdrive) | Tests using Hyperdrive with a Vitest managed TCP server |
| [🤹 request-mocking](request-mocking) | Tests using declarative (MSW) / imperative outbound request mocks |
| [🔌 multiple-workers](multiple-workers) | Tests using multiple auxiliary workers and request mocks |
| [⚙️ web-assembly](web-assembly) | Tests importing WebAssembly modules |
| [🤯 rpc](rpc) | Tests using named entrypoints, Durable Objects and RPC |
| [🧠 ai-vectorize](ai-vectorize) | Tests using Workers AI and Vectorize |
| [🔄 context-exports](context-exports) | Tests using context exports |
| [📥 dynamic-import](dynamic-import) | Tests using dynamic imports |
| [🖼️ images](images) | Tests using the Images binding |

[^1]: When using `exports.default` for integration tests, your worker code runs in the same context as the test runner. This means you can use global mocks to control your worker, but also means your worker uses the same subtly different module resolution behaviour provided by Vite. Usually this isn't a problem, but if you'd like to run your worker in a fresh environment that's as close to production as possible, using an auxiliary worker may be a good idea. Note this prevents global mocks from controlling your worker, and requires you to build your worker ahead-of-time. This means your tests won't re-run automatically if you change your worker's source code, but could be useful if you have a complicated build process (e.g. full-stack framework).
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@

This Worker implements a counter with Durable Objects. Each object holds a single count.

| Test | Overview |
| --------------------------------------------------- | --------------------------------------------------------------------- |
| [direct-access.test.ts](test/direct-access.test.ts) | Tests for endpoints that also access object instance members directly |
| [alarm.test.ts](test/alarm.test.ts) | Tests that immediately execute object alarms |
| Test | Overview |
| --------------------------------------------------- | ---------------------------------------------------------------------- |
| [direct-access.test.ts](test/direct-access.test.ts) | Tests for endpoints that also access object instance members directly |
| [alarm.test.ts](test/alarm.test.ts) | Tests that immediately execute object alarms |
| [eviction.test.ts](test/eviction.test.ts) | Tests eviction, storage preservation and WebSocket hibernation/closing |
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
evictAllDurableObjects,
evictDurableObject,
runInDurableObject,
} from "cloudflare:test";
import { env } from "cloudflare:workers";
import { it } from "vitest";
import { Counter } from "../src/";

function getResponseWebSocket(response: Response) {
const socket = response.webSocket;
if (socket === null || socket === undefined) {
throw new TypeError("Expected WebSocket response");
}
return socket;
}

function waitForMessage(socket: WebSocket) {
return new Promise<string>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timed out waiting for WebSocket message"));
}, 10_000);
socket.addEventListener("message", (event) => {
clearTimeout(timeout);
resolve(
typeof event.data === "string"
? event.data
: new TextDecoder().decode(event.data as ArrayBuffer)
);
});
socket.addEventListener("error", () => {
clearTimeout(timeout);
reject(new Error("WebSocket error while waiting for message"));
});
});
}

function waitForClose(socket: WebSocket) {
return new Promise<CloseEvent>((resolve, reject) => {
const timeout = setTimeout(() => {
reject(new Error("Timed out waiting for WebSocket close"));
}, 10_000);
socket.addEventListener("close", (event) => {
clearTimeout(timeout);
resolve(event);
});
socket.addEventListener("error", () => {
clearTimeout(timeout);
reject(new Error("WebSocket error while waiting for close"));
});
});
}

it("resets in-memory state but preserves storage on targeted eviction", async ({
expect,
}) => {
const id = env.COUNTER.idFromName(`evict-${crypto.randomUUID()}`);
const stub = env.COUNTER.get(id);

// Persist `count = 2` through the `fetch()` handler
expect(await (await stub.fetch("https://example.com/")).text()).toBe("1");
expect(await (await stub.fetch("https://example.com/")).text()).toBe("2");

// Corrupt in-memory state without persisting it to storage
await runInDurableObject(stub, (instance: Counter) => {
instance.count = 999;
});

await evictDurableObject(stub, { webSockets: "hibernate" });

// After eviction the instance is torn down: the in-memory `999` is discarded
// and `count` is reloaded from storage (`2`), so the next increment yields `3`
expect(await (await stub.fetch("https://example.com/")).text()).toBe("3");
});

it("resets all running instances with bulk eviction", async ({ expect }) => {
const id = env.COUNTER.idFromName(`evict-all-${crypto.randomUUID()}`);
const stub = env.COUNTER.get(id);

expect(await (await stub.fetch("https://example.com/")).text()).toBe("1");
await runInDurableObject(stub, (instance: Counter) => {
instance.count = 999;
});

await evictAllDurableObjects();

expect(await (await stub.fetch("https://example.com/")).text()).toBe("2");
});

it("hibernates WebSockets across eviction", async ({ expect }) => {
const id = env.COUNTER.idFromName(`evict-ws-${crypto.randomUUID()}`);
const stub = env.COUNTER.get(id);
const response = await stub.fetch("https://example.com/websocket-order", {
headers: { Upgrade: "websocket" },
});
const socket = getResponseWebSocket(response);
socket.accept();

await evictDurableObject(stub);

// The WebSocket should be hibernated rather than closed, so messages still
// round-trip after eviction (waking the Durable Object back up)
const messagePromise = waitForMessage(socket);
socket.send("after-eviction");
expect(await messagePromise).toBe("after-eviction");
socket.close(1000, "done");
});

it("closes WebSockets when requested during eviction", async ({ expect }) => {
const id = env.COUNTER.idFromName(`evict-ws-close-${crypto.randomUUID()}`);
const stub = env.COUNTER.get(id);
const response = await stub.fetch("https://example.com/websocket-order", {
headers: { Upgrade: "websocket" },
});
const socket = getResponseWebSocket(response);
socket.accept();

const closePromise = waitForClose(socket);
await evictDurableObject(stub, { webSockets: "close" });
expect(await closePromise).toBeDefined();
});
2 changes: 1 addition & 1 deletion packages/miniflare/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"@cspotcode/source-map-support": "0.8.1",
"sharp": "0.34.5",
"undici": "catalog:default",
"workerd": "1.20260623.1",
"workerd": "1.20260625.1",
"ws": "catalog:default",
"youch": "4.1.0-beta.10"
},
Expand Down
20 changes: 20 additions & 0 deletions packages/vitest-pool-workers/src/worker/durable-objects.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import assert from "node:assert";
import { env, exports } from "cloudflare:workers";
import workerdUnsafe from "workerd:unsafe";
import { getSerializedOptions } from "./env";
import type { __VITEST_POOL_WORKERS_RUNNER_DURABLE_OBJECT__ } from "./index";
import type { DurableObjectEvictionOptions } from "workerd:unsafe";

const DEFAULT_EVICTION_OPTIONS: DurableObjectEvictionOptions = {
webSockets: "hibernate",
};

const CF_KEY_ACTION = "vitestPoolWorkersDurableObjectAction";

Expand Down Expand Up @@ -163,6 +169,20 @@ export async function runDurableObjectAlarm(
return await runInDurableObject(stub, runAlarm);
}

// See public facing `cloudflare:test` types for docs
// (`async` so it throws asynchronously/rejects)
export async function evictDurableObject(
stub: DurableObjectStub,
options: DurableObjectEvictionOptions = DEFAULT_EVICTION_OPTIONS
): Promise<void> {
if (!isDurableObjectStub(stub)) {
throw new TypeError(
"Failed to execute 'evictDurableObject': parameter 1 is not of type 'DurableObjectStub'."
);
}
await workerdUnsafe.evict(stub, options);
}

/**
* Internal method for running `callback` inside the I/O context of the
* Runner Durable Object.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export {
runInDurableObject,
runDurableObjectAlarm,
listDurableObjectIds,
evictDurableObject,
evictAllDurableObjects,
createExecutionContext,
waitOnExecutionContext,
createScheduledController,
Expand Down
12 changes: 12 additions & 0 deletions packages/vitest-pool-workers/src/worker/reset.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import workerdUnsafe from "workerd:unsafe";
import type { DurableObjectEvictionOptions } from "workerd:unsafe";

const DEFAULT_EVICTION_OPTIONS: DurableObjectEvictionOptions = {
webSockets: "hibernate",
};

export async function reset(): Promise<void> {
await workerdUnsafe.deleteAllDurableObjects();
Expand All @@ -7,3 +12,10 @@ export async function reset(): Promise<void> {
export async function abortAllDurableObjects(): Promise<void> {
await workerdUnsafe.abortAllDurableObjects();
}

// See public facing `cloudflare:test` types for docs
export async function evictAllDurableObjects(
options: DurableObjectEvictionOptions = DEFAULT_EVICTION_OPTIONS
): Promise<void> {
await workerdUnsafe.evictAllDurableObjects(options);
}
18 changes: 17 additions & 1 deletion packages/vitest-pool-workers/src/worker/types-ambient.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,24 @@ declare module "cloudflare:mock-agent" {
}

declare module "workerd:unsafe" {
export interface DurableObjectEvictionOptions {
webSockets?: "close" | "hibernate";
}

function abortAllDurableObjects(): Promise<void>;
function deleteAllDurableObjects(): Promise<void>;
function evict(
stub: DurableObjectStub,
options?: DurableObjectEvictionOptions
): Promise<void>;
function evictAllDurableObjects(
options?: DurableObjectEvictionOptions
): Promise<void>;

export default { abortAllDurableObjects, deleteAllDurableObjects };
export default {
abortAllDurableObjects,
deleteAllDurableObjects,
evict,
evictAllDurableObjects,
};
}
57 changes: 57 additions & 0 deletions packages/vitest-pool-workers/types/cloudflare-test.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,39 @@ declare module "cloudflare:test" {
export function runDurableObjectAlarm(
stub: DurableObjectStub
): Promise<boolean /* ran */>;
export interface DurableObjectEvictionOptions {
/**
* Controls what happens to hibernatable WebSockets when evicting a Durable
* Object. Defaults to `"hibernate"`.
*/
webSockets?: "close" | "hibernate";
}
/**
* Evicts the currently-running Durable Object pointed-to by `stub`, tearing
* down its instance to reset in-memory state while preserving durable
* storage. By default, hibernatable WebSockets are hibernated rather than closed, and
* eviction waits for in-flight requests to drain (with a timeout).
*
* Useful for testing how a Durable Object behaves across evictions, e.g.
* recovering state from storage or resuming hibernated WebSockets.
*
* Rejects if `stub` is not a Durable Object stub, if the target Durable
* Object is not currently running, or if its namespace has eviction
* prevented. Note this can only be used with `stub`s pointing to Durable
* Objects defined in the `main` worker.
*
* @example
* ```ts
* import { evictDurableObject } from "cloudflare:test";
*
* await evictDurableObject(stub);
* await evictDurableObject(stub, { webSockets: "close" });
* ```
*/
export function evictDurableObject(
stub: DurableObjectStub,
options?: DurableObjectEvictionOptions
): Promise<void>;
/**
* Gets the IDs of all objects that have been created in the `namespace`.
*/
Expand Down Expand Up @@ -76,6 +109,30 @@ declare module "cloudflare:test" {
*/
export function abortAllDurableObjects(): Promise<void>;

/**
* Evicts all currently-running Durable Objects in evictable namespaces.
* Unlike `abortAllDurableObjects()`, eviction is graceful: durable storage is
* preserved, hibernatable WebSockets are hibernated rather than closed by default, and
* eviction waits for in-flight requests to drain (with a timeout). In-memory
* state is reset by tearing down each instance.
*
* Non-running/idle actors are skipped, running facets are recursively
* evicted, and namespaces with eviction prevented are respected.
*
* @example
* ```ts
* import { evictAllDurableObjects } from "cloudflare:test";
* import { afterEach } from "vitest";
*
* afterEach(async () => {
* await evictAllDurableObjects();
* });
* ```
*/
export function evictAllDurableObjects(
options?: DurableObjectEvictionOptions
): Promise<void>;

/**
* Creates an instance of `ExecutionContext` for use as the 3rd argument to
* modules-format exported handlers.
Expand Down
2 changes: 1 addition & 1 deletion packages/wrangler/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@
"miniflare": "workspace:*",
"path-to-regexp": "6.3.0",
"unenv": "2.0.0-rc.24",
"workerd": "1.20260623.1"
"workerd": "1.20260625.1"
},
"devDependencies": {
"@aws-sdk/client-s3": "^3.721.0",
Expand Down
Loading
Loading