dánwòdánwò

Writing tests

Time travel

Jump days ahead in a test, so trials end, subscriptions renew, retries run and tokens expire in seconds.

A trial that ends in 14 days, a renewal next month, a link that expires in an hour: you can't wait for those in a test. time.travel jumps ahead, and everything that falls due on the way runs, in order.

Jump ahead

tests/trial.danwo.ts
import { type StripeFixtures, test as base } from "@danwo/sdk"

const test = base.extend<StripeFixtures>()

test("a trial turns into a paid plan", async ({ app, screen, expect, time, settle }) => {
  await app.open("/")
  await screen.getByRole("button", { name: "Start 14-day trial" }).click()

  await time.travel({ days: 15 })
  await settle()

  await app.open("/account")
  await expect(screen.getByRole("status")).toHaveText("Plan: active")
})

settle() delivers anything still pending, such as the webhooks the renewal sent.

CallWhat it does
time.travel(span)moves forward by { days, hours, minutes, seconds } or milliseconds
time.set(date)moves forward to a moment
time.now()the time everything is at now

Time only moves forward. Each jump is a step in the flight record.

A date or time without an offset, such as time.set("2031-07-04T09:30"), is read in the page's time zone when your config sets one. Otherwise it's read in the zone of the machine running the tests.

Three clocks move together

ClockWhat happens
Your app'sits Date runs ahead by the offset. Your code doesn't change.
Every emulator'srenewals, retries, expiries and scheduled messages run in order, each with its webhooks
The page'sthe browser's Date jumps too

An offset, not frozen time

Only Date jumps. Timers and performance.now() keep running on real time, because sockets, retries, database pools and animations rely on real durations. Freezing them would break your app in ways real users never see. An offset changes what time it is, never how fast it passes.

Cookies still expire by the browser's real clock. When your page writes one with an expires date from its travelled clock, danwo moves that date back by the offset, so a page that deletes a cookie by dating it in the past still deletes it.

Faults after a jump

danwo matrix plans faults on the webhooks that fall due after a jump, like any others. A duplicated invoice.paid after the trial ends, or a late renewal webhook, is judged by your invariant.

Let the agent wait

Give the agent { time: true } and it can travel too. Each jump is recorded, so a replay travels the same way with no model:

tests/trial.danwo.ts · the agent waits
await agent.act("wait until the trial has ended", { time: true })

What moves

SetupMoves
Node appseverything, through the preload in NODE_OPTIONS
Bun appseverything, through BUN_OPTIONS
Apps in other languagesthe page and the emulators, not the app's own Date
An environment you built yourselfonly the app and page clocks, unless it passes on travel

Time stays where a test left it for the rest of that worker. danwo matrix restores every trial, so each one starts at real time.

Next

On this page

Run it on every pull request

A GitHub check and one comment per pull request, with the flight records attached.

Set up CI