playwright-clock-helper
v0.2.0
Published
Deterministic browser time control fixtures and helpers for Playwright Test.
Maintainers
Readme
playwright-clock-helper
Deterministic browser-time fixtures and helpers for Playwright Test. The package centralizes clock lifecycle, strict input validation, mode-safe operations, and failure diagnostics while keeping runtime dependencies at zero.
Status:
0.1.xis an API validation release. Minor releases may contain public API changes, documented in the changelog.
Install
This is a public npmjs package, so no registry-specific .npmrc or read token
is required.
npm install --save-dev playwright-clock-helper @playwright/testRequirements: Node.js 20 or newer and @playwright/test >=1.45.0 <2.
The package is ESM-only.
Quick start
import { expect, test } from "playwright-clock-helper";
test.use({
testTime: {
mode: "controlled",
now: "2026-07-01T00:00:00Z",
},
});
test("expires the client-side session warning", async ({ page, time }) => {
await page.goto("/dashboard");
await time.pauseAt("2026-07-01T01:00:00Z");
await time.jumpBy("30m");
await expect(page.getByText("Session expired")).toBeVisible();
});Clock control is opt-in. Without testTime, the default is { mode: 'off' };
the helper does not parse time, call Playwright Clock, or attach diagnostics.
Modes and operations
| Mode | Browser behavior | Allowed controller operations |
| ------------ | ------------------------------------------------------------------------- | -------------------------------------------------------------- |
| off | Real browser time; no Clock call | None. Requesting time fails before the test body. |
| fixed | Date.now() and new Date() are fixed; timers keep running in real time | setFixedTime() |
| controlled | Date and timer APIs are controlled by Playwright Clock | pauseAt(), jumpBy(), tickBy(), tickUntil(), resume() |
jumpBy() maps to Playwright Clock.fastForward(). It jumps to the destination
and fires each due timer at most once, which resembles reopening a sleeping
laptop.
tickBy() maps to Clock.runFor(). It processes timers and related callbacks
throughout the interval. Use it when intermediate interval, animation, or
countdown updates matter.
tickUntil()
tickUntil() evaluates an application condition and advances controlled
virtual time until that condition returns exactly true:
await time.tickUntil(
async () => (await page.getByTestId("status").textContent()) === "ready",
{
step: "100ms",
max: "5s",
},
);The predicate runs before the first clock advance, so an already-matched
condition succeeds without changing time. Both step and max are required.
step must be greater than zero; max may be zero. When max is not evenly
divisible by step, the final advance is shortened so virtual time never
exceeds max. The predicate is evaluated once more after that final advance.
The predicate may be synchronous or asynchronous, but must return exactly a
boolean. A thrown/rejected predicate error and a Playwright Clock error are
propagated immediately. If the condition remains false at max,
TimeConditionTimeoutError is thrown with attempts, elapsedMs, stepMs,
and maxMs fields:
import { TimeConditionTimeoutError } from "playwright-clock-helper/core";To prevent accidental runaway loops, one call may advance the clock at most
10,000 times. Larger max / step combinations throw TimeInputError before
the predicate runs.
tickUntil() is exclusive. It waits for controller operations that were
called first, but later controller calls fail immediately with
TimeLifecycleError until polling finishes. Its predicate must only observe
application state; do not call time.* methods from inside it.
Wrong-mode operations throw TimeModeError; the helper never changes modes
implicitly. Create a new test/BrowserContext to use another mode.
Fixed time
test.use({
testTime: {
mode: "fixed",
now: "2026-07-01T09:00:00+09:00",
},
});
test("changes the displayed date", async ({ page, time }) => {
await page.goto("/today");
await time.setFixedTime("2026-07-02T00:00:00Z");
await page.getByRole("button", { name: "Reload" }).click();
});Clock lifecycle
Playwright exposes Clock as page.clock, but installs it for the entire
BrowserContext. The fixture creates a short-lived internal setup page, installs
the Context clock, closes that page, and only then exposes context to
downstream fixtures. Consequently, application pages, navigation, and timer
registration happen after clock setup.
Do not mix this package with direct page.clock.* calls in the same Context.
Direct calls cannot be detected reliably.
The low-level API is available when you own the Context lifecycle:
import { setupTestClock } from "playwright-clock-helper/core";
const context = await browser.newContext();
const time = await setupTestClock(context, {
mode: "controlled",
now: "2026-07-01T00:00:00Z",
});Call it before creating any application page. Calling it after a page exists,
or attempting to replace an installed mode/initial time, throws
TimeLifecycleError.
Fixture composition
Apply time fixtures first, then extend the returned test with application fixtures:
import type { Page } from "@playwright/test";
import { test as base, expect } from "@playwright/test";
import { extendWithTime } from "playwright-clock-helper/fixtures";
const timeBase = extendWithTime(base);
export const test = timeBase.extend<{ authenticatedPage: Page }>({
authenticatedPage: async ({ page }, use) => {
await page.goto("/login"); // Clock setup has already completed.
// Authenticate...
await use(page);
},
});
export { expect };Avoid unordered mergeTests() composition when another fixture overrides
context/page or navigates before clock setup.
Configuration
type TestTimeConfig =
| { mode: "off" }
| {
mode: "fixed" | "controlled";
now: Date | number | string;
diagnostics?: "off" | "on-failure" | "always";
};now is mandatory in active modes. Missing or unsupported configuration throws
TimeConfigurationError.
Instant inputs
Accepted:
new Date("2026-07-01T00:00:00Z");
1_782_864_000_000; // Unix epoch milliseconds
("2026-07-01T00:00:00Z");
("2026-07-01T09:00:00.123+09:00");Strings must be ISO 8601 with Z or an explicit ±HH:MM UTC offset.
Timezone-less values such as 2026-07-01T09:00:00, invalid calendar dates,
NaN, and infinity throw TimeInputError.
Duration inputs
Numbers are non-negative safe integer milliseconds. Strings are a non-negative
integer plus one of ms, s, m, h, or d:
500;
("250ms");
("10s");
("5m");
("2h");
("1d");1d always means 24 hours, not the next local calendar day. Use pauseAt() for
calendar/DST boundaries. Negative, fractional, variable-length (month,
year), and Playwright clock-format strings are rejected.
The same parsers are exported from playwright-clock-helper/core as
normalizeInstant() and parseDuration().
Browser timezone
testTime.now selects an absolute instant. Playwright timezoneId independently
controls how the browser displays and calculates local civil time:
import { defineConfig } from "@playwright/test";
export default defineConfig({
use: {
locale: "ja-JP",
timezoneId: "Asia/Tokyo",
},
});The helper does not change timezoneId.
Browser/server boundary
Clock affects browser JavaScript in the configured BrowserContext, including
pages, popups, and iframes. Depending on the installed Playwright version, this
includes Date, timers, animation/idle callbacks, performance, and event
timestamps.
It does not change the Playwright runner, Node.js helpers, API servers, databases, Redis/JWT TTLs, queues, external services, the OS clock, or another BrowserContext. Server expiration scenarios need a server-side test clock, test-only API, mock, or request-scoped time injection.
Diagnostics
Active modes default to diagnostics: 'on-failure'. The fixture attaches
playwright-clock-operations.json when the actual test status differs from the
expected status. Use always for every result or off to disable it.
The JSON contains package/Playwright versions, mode, normalized initial time,
operation history, browser, and project. A tick-until entry records one
aggregate result (matched, max-reached, predicate-error, or
clock-error) with its attempt and virtual-time counts. It does not capture
predicate values or errors, pages, cookies, headers, tokens, or application
data. Attachment failures during teardown are contained so they do not
replace the original test failure.
Public entry points
| Import | Contents |
| -------------------------------------- | ----------------------------------------------------- |
| playwright-clock-helper | Configured test, Playwright expect, primary types |
| playwright-clock-helper/fixtures | extendWithTime() and fixture types |
| playwright-clock-helper/core | Setup, parsers, controller/error types |
| playwright-clock-helper/experimental | Type-only unstable extension contracts |
Package exports blocks internal deep imports.
Troubleshooting
time requested while mode is off
Add test.use({ testTime: { mode: 'fixed' | 'controlled', now: ... } }) to the
test or enclosing describe.
Setup says application pages already exist
Call extendWithTime(base) before app fixtures. Do not call
setupTestClock() from the test body.
A server-side session/JWT still has its original expiry
This package changes browser time only. Use a server-side test mechanism too.
Intervals fire once after a long advance
That is jumpBy()/fastForward() behavior. Use tickBy() when every interval
callback matters.
Local and CI display different dates
Use offset-bearing now values and configure the same Playwright timezoneId
in every environment.
Development
npm ci
npm run verify
npx playwright install chromium firefox webkit
npm run test:integration
npm run consumer:testThe consumer smoke test packs the package, installs that tarball into a clean temporary project, type-checks every public entry point, and imports the runtime entry points.
Publishing publicly to npmjs
Releases use npm Trusted Publishing from GitHub Actions. No long-lived npm
publish token is stored in GitHub. Stable vX.Y.Z GitHub Releases from main
require approval in the npm environment before publishing.
The release conditions, approval policy, one-time 0.1.0 bootstrap, operating
procedure, and verification boundary are documented in
the npm release policy.
