Your AI Feature Has Three Clocks. They Disagree.

Table of Contents
Any AI feature that understands the phrase “next Tuesday” is reading three different clocks, and your Playwright test pins exactly one of them — the one that decides nothing. page.clock controls the browser; the prompt is assembled server-side from a completely different new Date(); and the model, if nobody tells it what day it is, is guessing. Three clocks, three answers, one green test.
user types "next Tuesday at 2pm" │ ┌───────▼────────┐ page.clock ✅ renders dates, timers, "Today: Mar 3" │ 1. BROWSER │ ← the only clock your test pins └───────┬────────┘ ┌───────▼────────┐ page.clock ❌ builds the prompt: `Today is ${new Date()}` │ 2. SERVER │ ← decides what "next Tuesday" anchors to └───────┬────────┘ ┌───────▼────────┐ page.clock ❌ does the arithmetic, badly, on whatever │ 3. MODEL │ date it was handed — or invents one └───────┬────────┘ ▼ create_event(startsAt: "2026-03-??T14:00:00-0?:00") ← where the bug actually isThis is the least-discussed determinism problem in AI feature testing, and it’s the one that produces the most embarrassing production bugs: meetings booked a day early, “last quarter” reports pulling the wrong quarter, expiry reminders that fire after the thing expired. It doesn’t show up as a crash. It shows up as a confidently wrong date in a sentence that reads perfectly. And because most teams assert on that sentence, the suite stays green right through it.
Why doesn’t freezing the browser clock make a date-sensitive AI test deterministic?
Because page.clock only overrides Date, setTimeout, and friends inside the browser context. It has no idea your prompt exists. If the current date is injected server-side — which it is in essentially every production AI feature — the model never sees the time you froze.
Here’s the test that convinces a team they’ve solved this:
test('books a review for next Tuesday', async ({ page }) => { await page.clock.setFixedTime(new Date('2026-03-03T09:00:00-05:00')); await page.goto('/assistant');
await page.getByRole('textbox', { name: 'Message' }) .fill('Book a design review for next Tuesday at 2pm'); await page.getByRole('button', { name: 'Send' }).click();
await expect(page.getByTestId('reply')).toContainText('Tuesday');});That assertion passes on every day of the year, because the assistant always says “Tuesday” — it’s echoing the user’s word back. And here’s the code that actually decides what Tuesday means, two network hops away:
export function buildSchedulingPrompt(userText: string, ctx: RequestContext) { return [ { role: 'system', content: SCHEDULING_SYSTEM_PROMPT }, { // This is the clock that resolves "next Tuesday". // page.clock never touched it. Neither did your test. role: 'user', content: `Today is ${new Date().toISOString()}.\n\n${userText}`, }, ];}Two details from the Clock API docs are worth internalizing before you rely on it. First, the clock is installed for the whole BrowserContext — every page and iframe in it — not just the page you called it on. Second, setFixedTime is what actually freezes time; install() fakes the clock but lets it progress as timers fire, and it must be called before any other clock method. Teams reach for install() because it sounds more thorough, then wonder why a long test drifts. Everything the API promises, it delivers. The mistake is ours: we borrowed a frontend tool to solve a distributed-systems problem.
What does the model think “today” is when nobody tells it?
It guesses, and you have no contract governing the guess. This is the third clock, and it’s the one most teams don’t know exists. Nothing in the API forces a model to say “I don’t know today’s date” — it will produce a confident answer either way. And even when you do hand it the date explicitly, arithmetic on that date is one of the weakest primitives large models have.
63%
Best model, DateLogicQA
GPT-4-turbo led a 190-question benchmark across 7 date formats
34.5%
Cross-calendar accuracy
Average across models; none exceeded 80%
10 pts
Tokenizer penalty
Accuracy drop on uncommon dates from date fragmentation
DateLogicQA is a human-curated set of 190 questions spanning seven date formats and past, present, and future contexts. The best proprietary model in that evaluation, GPT-4-turbo, answered 63% correctly. That is not a number you want load-bearing in a booking flow.
The failure mode has a mechanical explanation, too. Date Fragments shows that BPE tokenizers routinely shred dates into meaningless pieces — 20250312 becomes 202, 503, 12 — and that this fragmentation correlates with accuracy drops of up to 10 points on uncommon dates. The paper’s DateAugBench suite (6,500 examples) also documents that models perform worse the further a date sits from the contemporary range they saw most in training.
The direction of the error matters too. SPAN evaluates cross-calendar reasoning — Gregorian against Hebrew, Islamic, and others — so it isn’t a direct proxy for a Gregorian booking flow, but its headline is still instructive: 34.5% average accuracy, no model above 80%, and a relative pattern the authors name Future-Date Degradation where performance drops on future dates versus past ones. Scheduling features are, definitionally, about future dates.
The primitive-level picture is the one I’d put in front of a skeptical staff engineer. DATETIME isolates just two operations — parsing a datetime and doing arithmetic on it — and the best model tested (o3-mini) reached 79% on the 250-item addition task and 88% on the mixed set. Those are the primitives. Anything you build on top inherits that error rate. Test of Time splits arithmetic into seven categories for the same reason, and the spread between them is enormous: Duration tasks scored in the mid-teens for the models evaluated while Timezone tasks scored in the 70s-90s. A model that nails one flavour of date math tells you nothing about the flavour your feature actually needs.
The practical conclusion writes itself: never let the model do the date math. More on that below, but first you have to be able to test it at all.
How do you pin all three clocks in one Playwright test?
You freeze the browser, force the server to accept a test-supplied “now,” and then prove the model actually received it. All three, or you’ve tested nothing.
Freeze the browser clock
page.clock.install({ time }) handles any client-side rendering of dates — the “Today: March 3” header, relative timestamps, countdown timers. Call it before anything else touches the clock.
Give the server an overridable clock
Replace every direct new Date() in your prompt-building path with an injected now(). In non-production builds only, let a signed x-test-now header supply it. This is the step teams skip, and it’s the whole ballgame.
Prove the pinned date reached the model
Assert on the prompt your service actually sent — via an OpenTelemetry span attribute, a test-only echo endpoint, or your request log. If you can’t see the date the model got, you’re guessing.
Assert on resolved tool arguments, not on the reply text
The bug lives in the ISO string passed to create_event, not in the sentence rendered in the chat bubble. This is the same lesson as green tests hiding real bugs — the oracle is checking the wrong surface.
A fixture keeps the first two honest and makes the pinned time a first-class test option:
import { test as base } from '@playwright/test';
// Test-scoped, not worker-scoped: a worker-scoped option cannot be set with// test.use() inside a describe block, which is exactly how we drive the matrix.export const test = base.extend<{ pinnedNow: string }>({ pinnedNow: ['2026-03-03T09:00:00-05:00', { option: true }],
page: async ({ page, pinnedNow }, use) => { // Clock 1 — the browser. setFixedTime actually freezes; install() lets // time keep progressing as timers fire. await page.clock.setFixedTime(new Date(pinnedNow));
// Clock 2 — the server. Honoured only when TEST_CLOCK_ENABLED is set, // which is never true in a production build. await page.setExtraHTTPHeaders({ 'x-test-now': pinnedNow });
await use(page); },});Then the assertion that actually earns its keep — reading the tool call, not the prose:
test('"next Tuesday" resolves against the pinned date', async ({ page }) => { await page.goto('/assistant'); await ask(page, 'Book a design review for next Tuesday at 2pm');
const call = await lastToolCall(page); // read from your span or test hook expect(call.name).toBe('create_event');
// Pinned "now" is Tue 2026-03-03 (EST, -05:00), so "next Tuesday" is the 10th. // DST starts Sun Mar 8 — the target is EDT, -04:00. Get this wrong and you // have written the exact off-by-an-hour bug this test exists to catch. expect(call.args.startsAt).toBe('2026-03-10T14:00:00-04:00'); // Offset-aware, always. A naive local datetime here is a latent bug. expect(call.args.startsAt).toMatch(/[+-]\d{2}:\d{2}$|Z$/);});That offset is not a typo, and it’s the whole reason to write the expectation by hand rather than snapshot whatever the resolver produced. The anchor is in EST and the target is in EDT, because North American DST starts on March 8, 2026 — between them. A resolver that does naive +7 days on a UTC instant books this meeting at 1pm local.
Run that test on two different days. If it passes both times, your server clock is genuinely pinned. If it only passes on Tuesdays, you just found the leak.
Why should the model never produce a date at all?
Because date arithmetic is deterministic, and you should never hand a deterministic problem to a probabilistic system. The model’s job is to extract intent from language — which it’s genuinely good at. Resolving that intent into a timestamp is your code’s job.
Ask for a relative intent, not a timestamp
import { z } from 'zod';
// The model fills this in. It never sees or produces an ISO date.export const RelativeWhen = z.object({ anchor: z.enum(['today', 'now']), unit: z.enum(['day', 'week', 'month', 'quarter']), offset: z.number().int().min(-8).max(8), weekday: z.enum(['mon','tue','wed','thu','fri','sat','sun']).optional(), timeOfDay: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/).optional(),});The offset bounds are doing quiet work. A model that misreads “next Tuesday” as offset: 52 gets rejected at the schema boundary instead of booking a meeting a year out — the same reason contract testing beats hoping the payload is right.
Resolve it in code, with the user’s timezone and week start
import { DateTime } from 'luxon';import { z } from 'zod';import { RelativeWhen } from './schema';
const ISO_WEEKDAY = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 } as const;
export function resolveWhen( w: z.infer<typeof RelativeWhen>, nowIso: string, tz: string, weekStart: 'mon' | 'sun',): string { let dt = DateTime.fromISO(nowIso, { zone: tz }).plus({ [`${w.unit}s`]: w.offset });
if (w.weekday) { // Luxon's `weekday` is ISO (Mon=1..Sun=7). Normalise to the first day of // *this user's* week before stepping — a US/CA/JP week starts Sunday. const daysIn = weekStart === 'sun' ? dt.weekday % 7 : dt.weekday - 1; const target = weekStart === 'sun' ? ISO_WEEKDAY[w.weekday] % 7 : ISO_WEEKDAY[w.weekday] - 1; dt = dt.minus({ days: daysIn }).plus({ days: target }); }
const [h, m] = (w.timeOfDay ?? '09:00').split(':').map(Number); return dt.set({ hour: h, minute: m, second: 0, millisecond: 0 }).toISO()!;}The % 7 in both lines is the entire Sunday-vs-Monday fix, and it’s the kind of thing that looks like noise in code review until you’ve shipped the bug. (Recent Luxon versions can do it for you via startOf('week', { useLocaleWeeks: true }) — either way, weekStart has to be an explicit input, never a server default.)
Now “next Tuesday” is a pure function of four inputs, and you can unit-test it a thousand times a second without spending a token. The Playwright test’s job shrinks to one question: did the model extract the right intent? That’s a far more stable assertion than an ISO string the model guessed at.
The week-start problem is bigger than it looks
ISO 8601 says the week starts Monday. The United States, Canada, and Japan say Sunday. If your resolver hardcodes one, then on a Sunday, “next week” is off by a full week for roughly half your user base — and it only breaks on Sundays, which is why it never shows up in a demo, a sprint review, or a weekday smoke run. It’s a bug with a one-day-in-seven attack surface, and the day it fires is the day nobody’s watching the dashboard.
Which anchor dates actually find the bugs?
Not the ones you’d pick by hand. One frozen date is a single sample; date bugs cluster at boundaries. Parameterize the fixture and run a small matrix.
| Anchor | Timezone | What it catches |
|---|---|---|
| Sat 23:40 local | America/Vancouver | Already tomorrow in UTC — off-by-one for evening users |
| DST spring-forward, 01:59 | America/Toronto | 02:30 that day doesn’t exist; naive +1 hour math lands in the gap |
| Feb 29 | UTC | Leap-day arithmetic in plus({ years: 1 }) |
| Dec 31, 18:00 | America/Toronto | Year rollover plus ISO week-year mismatch |
| Sunday, any time | America/New_York | Week-start disagreement between ISO and US locale |
const ANCHORS = [ { now: '2026-03-07T23:40:00-08:00', tz: 'America/Vancouver' }, // tomorrow in UTC { now: '2026-03-08T01:59:00-05:00', tz: 'America/Toronto' }, // one minute to the gap { now: '2028-02-29T09:00:00Z', tz: 'UTC' }, // leap day { now: '2026-12-31T18:00:00-05:00', tz: 'America/Toronto' }, // year rollover { now: '2026-06-14T10:00:00-04:00', tz: 'America/New_York' }, // a Sunday];
for (const anchor of ANCHORS) { test.describe(`anchored at ${anchor.now}`, () => { test.use({ pinnedNow: anchor.now, timezoneId: anchor.tz }); test('resolves "next Tuesday" to the expected ISO instant', async ({ page }) => { // ... }); });}Note timezoneId alongside the pinned clock. Playwright’s timezoneId context option and your pinned instant are separate knobs, and a test that pins one without the other is only half controlled — the same class of leaky-isolation problem as storage state bleeding between tests.
How do you keep a frozen clock from rotting in CI?
Add a lane that doesn’t freeze it. A permanently pinned anchor is a test that stops learning: it will happily pass for eighteen months while real dates drift past a boundary your resolver never handled.
Run the pinned matrix on every PR — it’s fast, deterministic, and cheap because the model call is mocked. Then run a nightly lane against the real clock with a live model and a relative assertion: not “equals 2026-03-10” but “the resolved instant is the next Tuesday after now, at 14:00 in the user’s zone.” Compute the expectation the same way a human would, independently of the resolver, and you have a genuine oracle rather than a snapshot.
One more guard worth its weight: a canary test that runs at 23:50 UTC and asserts the resolver and the rendered UI agree on what day it is. Cross-midnight disagreements between a cached server date and a client render are the single most common source of “the app said tomorrow but booked today,” and they’re invisible at 10am.
Your one concrete next step
Grep your codebase for new Date(), datetime.now(), and LocalDate.now() inside the prompt-building path. Every hit is a clock your Playwright tests cannot reach. Replace the first one with an injected now() and add a single test that pins it to a Tuesday and asserts the resolved tool argument — not the reply text. If that test passes on a Wednesday, you were never testing dates. You were testing that the assistant can repeat a word back to you.
If that resonates, the next post to read is why green tests hide real bugs — the clock problem is one specific instance of a much broader oracle failure, and the rest of the AI testing archive keeps circling the same conclusion from different angles: assert on the contract, never on the prose.
Does page.clock affect API requests made by Playwright's request fixture?
No. The clock is installed for the whole BrowserContext, so it covers every page and iframe in that context — but nothing outside the browser. Requests made with APIRequestContext, and anything your server does, run on the real system clock. You need a separate mechanism, such as a test-only header or an injected clock, to control server time.
Can I just tell the model the date in the system prompt and trust it?
Pin the date in the prompt, yes — always. Trust the model to do arithmetic with it, no. DateLogicQA’s best model answered 63% of date questions correctly, and DATETIME shows even isolated datetime primitives topping out near 88%. Have the model emit a relative intent ({ unit: 'week', offset: 1, weekday: 'tue' }) and resolve it in code.
How do I test timezone behaviour without spinning up users in five regions?
Use Playwright’s timezoneId context option together with a pinned instant. The pinned instant fixes the moment; timezoneId fixes how the browser renders it. Pass the user’s IANA zone explicitly into your resolver rather than inferring it from server locale.
Is a test-only header that overrides server time safe to ship?
Only if the code path is compiled out of production builds and gated behind a shared secret everywhere else. Add a negative test asserting the header is ignored when the flag is off. An unguarded clock override is a token-replay vector.
Should date tests use a real model or a mocked one?
Both, in separate lanes. Mock the model on PRs so the pinned matrix stays fast and deterministic. Run a nightly lane against the real model with relative assertions to catch prompt or model changes that break intent extraction.
