O
Om Shankar
Guest
Here’s a function that decides whether an invoice is due today:
Its test passes on my laptop in the morning, and it passes in CI. Then, from 5 PM on a summer evening in California, it fails.
Nobody runs their tests at 9:30 PM in Los Angeles. Or on February 29. Or on the night New York skips from 1:59 AM straight to 3:00 AM. Or on a machine that writes 1234.5 as 1.234,5. So date bugs like this one sit quietly in passing test suites until a customer finds them.
I built jetlag to run those days now.
One run of jetlag on a small billing module with eight passing tests.
jetlag wraps the test command you already have:
Or install it once with
It runs your tests once at home, then again in ten “destinations”: other time zones, the dates that break date code, and other languages. You don’t write a config file or change a test. When a destination breaks something, jetlag tells you which test failed, what’s different about that place, and how to reproduce it:
The
To see what it catches, I wrote a small billing module the way people write date code on a deadline, with eight tests that all pass on my machine. One run of jetlag broke every one of them:
None of these bugs needs an unusual input. They need an unusual day, and jetlag supplies the day. The whole run takes about five seconds on my laptop.
Each destination changes one thing your laptop never does, so when a test fails you know exactly why.
Time zones. UTC, because that’s where your CI lives. Los Angeles at 9:30 PM, when the UTC date has already rolled over. Kiritimati, which is UTC+14 and starts each day before anyone else. Kathmandu at UTC+05:45. Lord Howe Island, where daylight saving moves the clock by 30 minutes. Antarctica’s Troll station, where it moves by two hours. And “Anywhere on Earth”, UTC-12, whose IANA name is
Tricky dates. The next spring-forward and fall-back nights in New York, two minutes to midnight on New Year’s Eve, the next leap day, January 31, the next day whose ISO week-year differs from its calendar year, a Sunday, one second past the 32-bit Unix time limit in 2038, and February 28, 2100 (which is not followed by a February 29).
Languages. German, Turkish, Arabic, Thai, Persian, Hindi, and the plain POSIX “C” locale.
The default trip visits ten of these.
Dates like “the next leap day” are worked out from today, so the destinations stay useful as time passes.
Time zones and languages are the easy part. They’re environment variables: jetlag sets
The clock is harder. There’s no environment variable that says “it’s February 29, 2028”. So jetlag loads a small preload into each runtime it knows: through
Three details make this work in real test runners.
The clock keeps ticking. The preload moves the starting point, not the speed. Timeouts, durations, and
Every process agrees on the time. Test runners start workers, and workers start seconds after the parent. jetlag passes
Jest has its own
I’ve tested it with node:test, Jest 30, Vitest 3 (threads, forks, and vmThreads pools), Mocha 11, pytest 9, and unittest, and jetlag’s own test suite runs on macOS and Linux.
A tool like this is only useful if you can trust its red marks, so jetlag works hard not to cry wolf.
Date bugs are seasonal, so a weekly run that visits every destination catches them before the dates arrive:
jetlag exits with 1 when a destination breaks a test, so the job goes red.
jetlag can’t move the clock in Go, Rust, Java, or .NET today. On Linux, libfaketime could cover them, and that’s the next thing I want to try. It also hasn’t been tested on Windows. And on Linux, Python’s
It’s MIT-licensed, has no dependencies, and needs Node.js 20 or later to run. The tests it runs can be in any language.
Run it once and tell me which destination broke your tests. My money is on Los Angeles at 9:30 PM.
The code, the destination list, and two example projects (one in JavaScript, one in Python) are on GitHub: github.com/OmShiv/jetlag.
Happy testing!
Code:
function isDueToday(invoicenow = new Date()) {
return invoice.dueDate === now.toISOString().slice();
}
Its test passes on my laptop in the morning, and it passes in CI. Then, from 5 PM on a summer evening in California, it fails.
toISOString() always returns the date in UTC, and in UTC it’s already tomorrow.Nobody runs their tests at 9:30 PM in Los Angeles. Or on February 29. Or on the night New York skips from 1:59 AM straight to 3:00 AM. Or on a machine that writes 1234.5 as 1.234,5. So date bugs like this one sit quietly in passing test suites until a customer finds them.
I built jetlag to run those days now.
One run of jetlag on a small billing module with eight passing tests.
One command, no config
jetlag wraps the test command you already have:
Code:
npx jetlagged npm test
Or install it once with
npm install --global jetlagged and run jetlag npm test. (The name jetlag was already taken on npm, which felt appropriate.)It runs your tests once at home, then again in ten “destinations”: other time zones, the dates that break date code, and other languages. You don’t write a config file or change a test. When a destination breaks something, jetlag tells you which test failed, what’s different about that place, and how to reproduce it:
Code:
Kathmandu Oct 3 12:00 UTC+05:45 Asia/Kathmandu
Nepal is UTC+05:45. Code that assumes offsets are whole hours, or
rounds getTimezoneOffset() / 60, is 15 to 45 minutes off.
✗ the offset label matches the clock
debug: jetlag exec kathmandu -- npm test
The
debug line runs that one destination with your test runner’s full output, so you can fix the bug without guessing.Eight passing tests, eight bugs
To see what it catches, I wrote a small billing module the way people write date code on a deadline, with eight tests that all pass on my machine. One run of jetlag broke every one of them:
| The code | Breaks in | Because |
|---|---|---|
date.toISOString().slice(0, 10) as “today” | Los Angeles 9:30 PM, Kiritimati, New Year’s Eve | The UTC date isn’t the local date. |
date.getTime() + 24 * 60 * 60 * 1000 for “same time tomorrow” | Spring forward | That day has 23 hours. |
next.setMonth(next.getMonth() + 1) | January 31 | February 31 overflows to March 3. |
next.setFullYear(next.getFullYear() + 1) | Leap day | February 29, 2029 doesn’t exist, so it becomes March 1. |
`${year}-${month + 2}` for next month’s key | New Year’s Eve | December plus one is month 13. |
Math.trunc(-date.getTimezoneOffset() / 60) | Kathmandu | Nepal is 5 hours and 45 minutes ahead of UTC. |
amount.toLocaleString() | German | German writes 1234.5 as 1.234,50. |
date.toLocaleDateString(undefined, { year: 'numeric' }) | Thai | The Buddhist calendar says it’s 2569. |
None of these bugs needs an unusual input. They need an unusual day, and jetlag supplies the day. The whole run takes about five seconds on my laptop.
Where it sends your tests
Each destination changes one thing your laptop never does, so when a test fails you know exactly why.
Time zones. UTC, because that’s where your CI lives. Los Angeles at 9:30 PM, when the UTC date has already rolled over. Kiritimati, which is UTC+14 and starts each day before anyone else. Kathmandu at UTC+05:45. Lord Howe Island, where daylight saving moves the clock by 30 minutes. Antarctica’s Troll station, where it moves by two hours. And “Anywhere on Earth”, UTC-12, whose IANA name is
Etc/GMT+12 because POSIX-style zone names flip the sign.Tricky dates. The next spring-forward and fall-back nights in New York, two minutes to midnight on New Year’s Eve, the next leap day, January 31, the next day whose ISO week-year differs from its calendar year, a Sunday, one second past the 32-bit Unix time limit in 2038, and February 28, 2100 (which is not followed by a February 29).
Languages. German, Turkish, Arabic, Thai, Persian, Hindi, and the plain POSIX “C” locale.
The default trip visits ten of these.
--trip world visits all 28, and --tz with --at builds your own:
Code:
jetlag --trip world python3 -m pytest -q
jetlag --tz Europe/Paris --at 2027-03-28T01:30 npm test
jetlag exec kathmandu node # a Node.js REPL in Kathmandu
Dates like “the next leap day” are worked out from today, so the destinations stay useful as time passes.
How do you move a clock without touching the clock?
Time zones and languages are the easy part. They’re environment variables: jetlag sets
TZ, LANG, and LC_ALL for your command, and every runtime reads them. Node.js even ships its own locale data, so German and Thai formatting work on machines that don’t have those locales installed.The clock is harder. There’s no environment variable that says “it’s February 29, 2028”. So jetlag loads a small preload into each runtime it knows: through
NODE_OPTIONS=--require for Node.js, a sitecustomize.py on PYTHONPATH for Python, and RUBYOPT=-r for Ruby. Here’s a simplified version of the Node.js one:
Code:
const offset = Date.parse(process.env.JETLAG_NOW) - Number(process.env.JETLAG_EPOCH);
const RealDate = Date;
const now = () => RealDate.now() + offset;
function JetlagDate(args) {
if (!newtarget) return new RealDate(now()).toString();
const date = args.length ? new RealDate(args) : new RealDate(now());
if (newtarget !== JetlagDate) Object.setPrototypeOf(date, newtargetprototype);
return date;
}
JetlagDateprototype = RealDateprototype;
JetlagDate.now = now;
JetlagDate.parse = RealDate.parse;
JetlagDate.UTC = RealDate.UTC;
globalThis.Date = JetlagDate;
Three details make this work in real test runners.
The clock keeps ticking. The preload moves the starting point, not the speed. Timeouts, durations, and
performance.now() behave normally.Every process agrees on the time. Test runners start workers, and workers start seconds after the parent. jetlag passes
JETLAG_EPOCH, the real time when the run began, so every process computes the same shifted time.Jest has its own
Date. Jest runs each test file inside a vm context, which comes with its own built-in objects. Replacing globalThis.Date in the parent does nothing in there. So the preload wraps vm.createContext and installs the same patch in every context it creates. Worker threads and child processes pick up the preload on their own through NODE_OPTIONS. One more corner: Intl.DateTimeFormat().format() with no argument reads the clock through the original Date.now, so the preload covers that too.I’ve tested it with node:test, Jest 30, Vitest 3 (threads, forks, and vmThreads pools), Mocha 11, pytest 9, and unittest, and jetlag’s own test suite runs on macOS and Linux.
Red has to mean something
A tool like this is only useful if you can trust its red marks, so jetlag works hard not to cry wolf.
- Home goes first. jetlag runs your suite unchanged before it travels. If a test already fails at home, it doesn’t blame a destination for it; it reads the failing test names (from node:test, Jest, Vitest, Mocha, pytest, unittest, RSpec, Go, Rust, and Gradle output) and reports only new failures.
- Parallel failures get a second look. Destinations run four at a time. When one fails, jetlag runs it again by itself. If it passes alone, it’s reported as flaky, usually a shared port or file, not as a date bug.
- It knows when the clock didn’t move. Each preload leaves a marker when it takes effect. If none did, because the tests are written in Go or Java, jetlag reports the date destinations as skipped instead of passed. Time zones and languages still apply.
Put it in CI
Date bugs are seasonal, so a weekly run that visits every destination catches them before the dates arrive:
Code:
name: jetlag
on:
schedule:
- cron: '0 6 * * 1'
workflow_dispatch:
jobs:
jetlag:
runs-on: ubuntu-latest
steps:
with:
node-version:
- run: npm ci
- run: npx jetlagged --trip world -- npm test
jetlag exits with 1 when a destination breaks a test, so the job goes red.
What it doesn’t do yet
jetlag can’t move the clock in Go, Rust, Java, or .NET today. On Linux, libfaketime could cover them, and that’s the next thing I want to try. It also hasn’t been tested on Windows. And on Linux, Python’s
locale module and Ruby need the system locales installed (for example with locale-gen); Node.js doesn’t.Try it on your project
It’s MIT-licensed, has no dependencies, and needs Node.js 20 or later to run. The tests it runs can be in any language.
Code:
npx jetlagged npm test
Run it once and tell me which destination broke your tests. My money is on Los Angeles at 9:30 PM.
The code, the destination list, and two example projects (one in JavaScript, one in Python) are on GitHub: github.com/OmShiv/jetlag.
Happy testing!