Run tests in the Cloudflare Workers runtime. Pipe in a bundled test script, get TAP out, exit non-zero on failure.
The usual way to test inside the real runtime (workerd) is vitest with
@cloudflare/vitest-pool-workers, which means adopting vitest, a vite
config, a wrangler config and a pool plugin.
cfout reads one bundled JavaScript module from stdin, runs it in
workerd locally via Miniflare,
streams its console output to the terminal, and exits with a code that
reflects the result. There is no config file. Flags mirror
tapout, which does the
same thing for browsers.
npm i -D @substrate-system/cfoutRequires Node 22 or newer. The only runtime dependency is miniflare,
which brings the workerd binary. wrangler is not needed.
npx esbuild --bundle test/index.ts --format=esm | npx cfout | npx tap-spec// test/index.ts
import { test } from '@substrate-system/tapzero'
test('example', async t => {
await new Promise(resolve => setTimeout(resolve, 10))
t.ok(true, 'timers work')
})In package.json:
{
"scripts": {
"test": "esbuild --bundle test/index.ts --format=esm | cfout | tap-spec"
}
}The input via stdin must be one fully bundled ES module.
- No unresolved bare imports (
import x from 'some-package'). - Static imports of Node built-ins (
node:*) are allowed, but only work with--compat-flag nodejs_compat. Mark them external when bundling:npx esbuild --bundle test/index.ts --format=esm --external:'node:*' | npx cfout --compat-flag nodejs_compat
Usage: cfout [options]
Reads a bundled ES module from stdin, runs it in the Cloudflare Workers
runtime, and prints its output as TAP.
Options:
-t, --timeout <ms> Timeout in milliseconds (default: 5000)
--compat-date <date> Workers compatibility date (default: newest
the installed runtime supports)
--compat-flag <name> Compatibility flag, repeatable
--var <KEY=VALUE> Variable exposed as globalThis.env.KEY,
repeatable
--env <path> .env file of variables, repeatable; --var
overrides it
-h, --help Show this help message
How long to wait for the run to complete, in milliseconds. Must be a positive integer. Default 5000. The clock starts when the test request is dispatched, so workerd startup does not count. See completion.
The Workers
compatibility date,
as YYYY-MM-DD. Defaults to the newest date the installed workerd
supports, derived from miniflare's version (4.20260702.0 means
2026-07-02). "Today" is not used because workerd refuses to start
with a date newer than it supports.
A
compatibility flag.
Repeat it for more than one flag. nodejs_compat is off by default, so
Node-only usage is caught unless you opt in.
npx cfout --compat-flag nodejs_compat --compat-flag no_global_navigatorA string variable, exposed to the test as globalThis.env.KEY. Repeat
it for more than one variable. The value is everything after the first
=, so --var A=b=c sets A to b=c. See variables.
Load variables from a .env file, such as wrangler's .dev.vars. A
relative path resolves against the current directory. The file uses
dotenv syntax: KEY=VALUE lines, # comments, and quoted values.
Repeat the flag to load more than one file. A later file overrides an
earlier one, and --var overrides any file. A missing file is an error.
npx esbuild --bundle test/index.ts --format=esm | npx cfout --env .dev.varsPrint the usage message.
A bad or unknown argument prints Error: <message> and the usage to
stderr, and exits 1.
console.log, console.info and console.debug go to stdout.
console.warn and console.error go to stderr. Only test output is
written to stdout, so it can be piped straight into a TAP reporter.
workerd's own stderr is passed through to stderr.
A run ends at the first of these:
testsFinished. The test code setsglobalThis.testsFinished = true.- A TAP plan. A top-level plan line,
1..N, optionally with a directive (1..0 # skip). A plan before them ends the run once N test points have been seen. Indented (subtest) plans are ignored. When the run ends, if the number of test points differs from the plan, cfout printsnot ok - planned N tests but ran M. - An uncaught error. A throw during module evaluation, an
unhandledrejectionevent, or a throw from a timer callback queued at module top level (see shims). cfout prints onenot ok - <message>line on stdout and the message and stack on stderr. - Timeout. After
--timeoutmilliseconds cfout stops reading, shuts down workerd, printscfout: timed out after <ms>msto stderr and exits 1. Output printed before the timeout is already on stdout.
tapzero prints a plan after its last test, so a tapzero suite ends by
rule 2 with no extra code. Other test code can log a plan or set
testsFinished:
test('all done', () => {
globalThis.testsFinished = true
})There is no idle-based "auto finish" mode.
0 when the run completed by rule 1 or 2 and there was
- no
not okline, - no plan mismatch,
- no uncaught error, and
- no
console.errorcall.
1 otherwise. Any console.error call fails the run, even if every
test passed. Use console.warn for diagnostics that should not fail.
Pass string variables with --var:
npx esbuild --bundle test/index.ts --format=esm | npx cfout --var API_URL=http://localhost:8787 --var MODE=testOr load them from a .env file with --env .dev.vars.
They arrive as the worker's env and are assigned to globalThis.env
before your module is imported, so they can be read at module top level
or inside tests:
const { API_URL, MODE } = globalThis.envThe name matches what the same code would call it inside a real worker. Only string values are supported. KV, D1, R2, Durable Objects and other bindings are not.
workerd evaluates a module's top level in "global scope", where timers,
random values and I/O are not allowed. tapzero's first test() call
schedules a setTimeout, so without help every tapzero bundle would
throw at once. cfout shims two things while your module is evaluated:
- Timers.
setTimeoutandsetIntervalcalls made at module top level are queued, then replayed with the real timers once the module has finished evaluating.clearTimeoutandclearIntervalwork with the ids returned by queued calls. A throw from a replayed callback is reported as an uncaught error. - Console. Output written during module evaluation is buffered and printed right after the module finishes evaluating.
These are deliberately not shimmed, because they are real Workers constraints that this tool exists to catch:
crypto.getRandomValuescrypto.randomUUIDfetch
Calling one of them at module top level fails the run with workerd's own error message, which explains the fix: call it inside a function that runs later, such as a test body.
- Throws in late timers are not caught. If a timer that was
scheduled after the initial replay (for example, from inside a test)
throws, workerd reports no error event to cfout and cancels the
request. The run ends without a completion rule and exits 1, without a
not ok - <message>line.tapzerocatches errors inside test bodies itself, so this only affects test code that schedules its own fire-and-forget timers. - Rejections during module evaluation are not reported. workerd
does not dispatch
unhandledrejectionfor a promise rejected while the module evaluates, for example a bare top-levelPromise.reject(...). The run still exits 1, but without a message. Rejections that happen later, such as in an async test, are reported. - Top-level
awaitis not supported. A module that uses top-levelawaitgets its request cancelled with no output.