@odla-ai/test
v0.1.1
Published
Deterministic, dependency-free test doubles and black-box contract assertions for odla and Cloudflare Worker unit tests; intentionally not a workerd or SQL emulator.
Downloads
60
Maintainers
Readme
@odla-ai/test
Deterministic, dependency-free test doubles for odla applications and Cloudflare Worker unit tests. It is designed for fast agent feedback loops: unexpected network and database calls fail immediately, time advances without sleeping, and Worker responses can be checked as black-box contracts without a test-runner dependency in the published package.
npm view @odla-ai/[email protected] version
npm install --save-dev --save-exact @odla-ai/[email protected]Require the exact-version lookup to succeed first. An E404 means this release
is unavailable; do not substitute an unchecked local or similarly named
package.
The runtime is portable across Node 20+, Cloudflare Workers, and modern browser
test environments. It imports no cloudflare:* modules. The public types are
structural, so doubles can be injected into narrow application interfaces and,
at a Worker test boundary, cast to the corresponding Cloudflare binding type.
What is included
FetchDouble— strict fetch router and call recorder; unmatched requests throw and never escape to the network.createServiceBinding/runWorker— invoke an exported Worker handler with an inspectableTestExecutionContext.MemoryKV— immediate-consistency, single-process KV subset with text, JSON, bytes, streams, metadata, TTL, deletion, prefix listing, and pagination.D1Stub— a programmable D1-shaped seam that records SQL/bindings and returns scripted rows. It deliberately does not parse or execute SQL.WorkflowStepDouble— deterministicdo,sleep,sleepUntil, andwaitForEventprimitives with snapshotted named-step replay.ManualClock/createSequenceId— injectable time and stable ids.- odla fixtures — realistic bearer/API-key headers, data/admin/registry request builders, environment-safe tenant ids, and provisioning payloads.
assertResponse/assertWorkerContract— framework-neutral response and Worker-handler contract assertions.
A black-box Worker contract
import {
assertWorkerContract,
odlaAppRequest,
} from "@odla-ai/test";
const worker = {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
// Your real handler.
},
};
await assertWorkerContract(worker, testEnv, [
{
name: "anonymous callers are denied",
request: new Request("https://db.test/app/notes/query"),
response: {
status: 401,
contentType: /^application\/json/,
json: { error: { code: "unauthorized" } },
},
waitUntil: 0,
},
{
name: "the app key can query",
request: () => odlaAppRequest("notes", {
baseUrl: "https://db.test",
route: "query",
credential: { kind: "api-key", token: "odla_sk_fixture" },
json: { notes: {} },
}),
response: {
status: 200,
headers: { "x-request-id": /^req_/ },
json: (body) => Array.isArray((body as { notes?: unknown }).notes),
},
},
]);assertResponse clones the response before reading its body, so the original
remains available. Contracts can allow multiple statuses, match headers or text
with regular expressions, assert that a header is absent with null, and run
sync or async body predicates. Failures throw ContractAssertionError with
expected and actual fields.
By default Worker contracts drain waitUntil work and surface its rejections.
Set drainWaitUntil: false on a case only when testing response-path behavior
independently from background work.
Fetch and service-binding doubles
import { createFetchDouble, createServiceBinding, jsonResponse } from "@odla-ai/test";
const upstream = createFetchDouble()
.when("/v1/items?limit=10", jsonResponse({ items: [] }))
.when(/\/v1\/items\/[^/]+$/, (request) =>
jsonResponse({ id: new URL(request.url).pathname.split("/").at(-1) }),
);
await upstream.fetch("https://api.test/v1/items?limit=10");
upstream.calls[0]!.request; // retained Request clone
const binding = createServiceBinding(otherWorker, otherEnv);
const env = { OTHER_SERVICE: binding };
await appWorker.fetch(request, env, context);
await binding.drain(); // finish every captured waitUntil promiseA string matcher beginning with / matches pathname plus query exactly. Other
strings match the complete URL. Routes are checked in insertion order. A static
Response is cloned for every call; responders may also be async functions.
Use .fallback(...) only when an explicit catch-all is part of the test.
KV with deterministic expiry
import { ManualClock, MemoryKV } from "@odla-ai/test";
const clock = new ManualClock(Date.parse("2026-01-01T00:00:00Z"));
const kv = new MemoryKV(clock);
await kv.put("job:1", JSON.stringify({ state: "queued" }), {
expirationTtl: 60,
metadata: { owner: "agent-1" },
});
await kv.get<{ state: string }>("job:1", "json");
await kv.list({ prefix: "job:", limit: 100 });
clock.advance(60_000);
await kv.get("job:1"); // nullValues and metadata are copied at the boundary. Listing is lexicographically stable for an unchanged key set, uses an implementation-specific deterministic cursor, and removes expired entries. Mutating keys between pages can shift the in-memory offset; production cursor behavior is intentionally not simulated. Cache TTL options are accepted for structural convenience but do not simulate edge caches. Empty keys and unknown read modes fail immediately.
Scripted D1, not fake SQL
import { createD1Stub } from "@odla-ai/test";
const rows = new Map([["j1", { id: "j1", state: "queued" }]]);
const db = createD1Stub()
.when("SELECT id, state FROM jobs WHERE id = ?", (call) => ({
rows: rows.has(String(call.bindings[0]))
? [rows.get(String(call.bindings[0]))!]
: [],
}))
.when(/UPDATE jobs SET state/, (call) => {
const current = rows.get(String(call.bindings[1]));
if (current) rows.set(current.id, { ...current, state: String(call.bindings[0]) });
return { meta: { changes: current ? 1 : 0, rows_written: current ? 1 : 0 } };
});
const job = await db
.prepare("SELECT id, state FROM jobs WHERE id = ?")
.bind("j1")
.first<{ id: string; state: string }>();
db.calls; // method, exact SQL, and copied bindingsExact SQL routes normalize whitespace; regex and predicate routes can match a
family of statements. Responders may close over Maps or domain repositories to
provide intentional state. Unknown statements throw D1UnexpectedQueryError.
prepare, bind, first, all, run, raw, batch, exec, and a synthetic
session bookmark are present. dump() fails loudly.
For positional raw fixtures, supply columns when testing
raw({ columnNames: true }); row-width mismatches fail closed.
This design avoids a dangerous class of false-positive tests. D1Stub does not
implement SQL parsing, constraints, transactions, triggers, query planning,
SQLite typing, D1 limits, or replica/session consistency. Scripted batch() is
sequential and not atomic. Use Cloudflare's Vitest pool, Miniflare, or workerd
for any behavior that depends on those properties.
Workflow orchestration
import { ManualClock, createWorkflowStep } from "@odla-ai/test";
const clock = new ManualClock(0);
const step = createWorkflowStep({ clock })
.queueEvent("approved", { reviewer: "human-1" });
const loaded = await step.do("load", { timeout: "1 minute" }, async (ctx) => {
return { attempt: ctx.attempt };
});
await step.sleep("backoff", "5 seconds"); // clock is now 5000; no wall wait
await step.waitForEvent("approval", { type: "approved", timeout: "1 day" });Successful names are memoized to model deterministic replay; failures are not.
Values are snapshotted when persisted and cloned again on replay, so mutation
cannot rewrite a prior checkpoint. clearReplay() clears named checkpoint and
attempt caches; queued events and historical records remain available for
inspection. Step names, event types, fixed retry
settings, timeout values, and the one-year sleep ceiling are checked against
the current public Cloudflare limits. Numeric durations are milliseconds;
human-readable durations use second through year (not ms strings). The
manual parser treats a month as 30 days and a year as 365 days. The callback
context exposes the supplied config (or {}), not Cloudflare's
runtime-resolved defaults. The double intentionally executes callbacks once:
retry timing, rollback,
serialization, eviction, resource quotas, and engine replay require a real
Cloudflare integration test. waitForEvent consumes a pre-queued event or
throws immediately, so a missing fixture cannot hang an autonomous test run.
Authentication fixtures
import { fixtureCredentials, odlaAdminRequest, provisionFixture } from "@odla-ai/test";
const credentials = fixtureCredentials("suite-a");
const request = odlaAdminRequest("apps/notes/schema", {
credential: { kind: "developer", token: credentials.developerToken },
json: { version: 2, schema: {} },
});
const enableDevDb = provisionFixture({
appId: "notes",
env: "dev", // tenantId defaults to notes--dev
service: "db",
action: "enable",
});These helpers build the real public header and URL shapes. They do not sign a
JWT or bypass a verifier. Use a verifier seam for unit tests; use a real test
issuer or workerd integration for cryptographic authentication. No helper emits
internal trust headers such as x-odla-trusted. Scoped route builders reject
literal or percent-encoded ./.. segments and encoded path separators
instead of allowing URL normalization to escape /app, /admin, or
/registry.
Fidelity boundary
This package tests application decisions and contracts, not the Cloudflare runtime itself. Its deliberate non-goals are:
- workerd isolation, subrequest/CPU limits, compatibility dates, WebSockets, cache behavior, alarms, and Durable Object storage semantics;
- D1/SQLite execution, atomicity, constraints, and migrations;
- KV replication lag, size limits, minimum TTLs, and production cursor format;
- Workflow retries, rollback, persistence, engine serialization, scheduler timing, and runtime quotas beyond the explicitly checked names/durations;
- JWT signatures or odla server authorization policy.
A useful split is: run most handler and package tests here; run a smaller suite
with @cloudflare/vitest-pool-workers for runtime/storage semantics; keep live
smokes for deployed bindings, DNS, secrets, and IAM. Passing a test double suite
must never be described as proof of workerd parity. Cloudflare limits and
accepted duration syntax can evolve, so runtime-sensitive paths still belong in
that integration suite.
API
The installed package's exported TypeScript declarations/JSDoc are the version-matched reference; the rendered reference is at https://odla.ai/docs/packages/test.
