Skip to main content

playwright_cdp/
clock.rs

1//! `Clock` — Playwright-style fake timers for a page.
2//!
3//! CDP has no native fake-timer primitive, so this is implemented by injecting a
4//! compact fake-timer controller script into the page's main world and then
5//! driving it with real `Runtime.evaluate` calls. The controller overrides
6//! `Date` (so `new Date()` and `Date.now()` read from an adjustable base time),
7//! `performance.now`, and the timer functions (`setTimeout`, `clearTimeout`,
8//! `setInterval`, `clearInterval`, `requestAnimationFrame`).
9//!
10//! The shape mirrors Playwright's `page.clock()`:
11//! - [`Clock::set_fixed_time`] pins time so it never advances;
12//! - [`Clock::install`] engages the fakes (call before the code under test runs);
13//! - [`Clock::advance`] / [`Clock::tick`] move the virtual clock forward,
14//!   firing any due timers in between.
15//!
16//! # Scope
17//! `install()` injects the controller on the *current* execution context only.
18//! It is not automatically re-injected on cross-origin navigations; call
19//! `install()` again after such a navigation. The injected controller lives on
20//! the page's main world, so timers scheduled in isolated worlds (e.g. content
21//! scripts) are not faked.
22
23use crate::cdp::session::CdpSession;
24use crate::error::{Error, Result};
25use serde_json::{json, Value};
26use std::sync::Arc;
27use std::time::{SystemTime, UNIX_EPOCH};
28
29/// A fake-timer handle for a [`Page`](crate::Page).
30///
31/// Cheaply cloneable; all clones share the same page session and controller.
32#[derive(Clone)]
33pub struct Clock {
34    inner: Arc<ClockInner>,
35}
36
37struct ClockInner {
38    /// The page-level CDP session (Runtime/Emulation live here).
39    session: CdpSession,
40}
41
42/// Options for [`Clock::install`].
43#[derive(Debug, Clone, Default)]
44#[non_exhaustive]
45pub struct ClockInstallOptions {
46    /// Wall-clock time (ms since the epoch) the fake clock starts at. If
47    /// absent, the real current time at `install()` is used.
48    pub time: Option<i64>,
49    /// If `Some(true)`, the clock is fixed at this time (never auto-advances)
50    /// after install — equivalent to also calling [`Clock::set_fixed_time`].
51    pub fixed: Option<bool>,
52}
53
54impl ClockInstallOptions {
55    pub fn time(mut self, v: i64) -> Self {
56        self.time = Some(v);
57        self
58    }
59    pub fn fixed(mut self, v: bool) -> Self {
60        self.fixed = Some(v);
61        self
62    }
63}
64
65impl Clock {
66    pub(crate) fn new(session: CdpSession) -> Self {
67        Self {
68            inner: Arc::new(ClockInner { session }),
69        }
70    }
71
72    /// Engage the fake timers: installs the controller over `Date`,
73    /// `performance.now`, and the timer functions, then optionally pins the
74    /// time. Call this *before* the code under test schedules any timers.
75    pub async fn install(&self, options: Option<ClockInstallOptions>) -> Result<()> {
76        let opts = options.unwrap_or_default();
77        let now_ms = opts.time.unwrap_or_else(current_unix_ms);
78
79        // Inject the controller (idempotent: it no-ops if already installed).
80        let _: Value = self
81            .inner
82            .session
83            .send(
84                "Runtime.evaluate",
85                json!({
86                    "expression": INJECT_SCRIPT,
87                    "awaitPromise": false,
88                }),
89            )
90            .await?;
91
92        // Seed the clock at the requested base time.
93        let _: Value = self
94            .inner
95            .session
96            .send(
97                "Runtime.evaluate",
98                json!({
99                    "expression": format!("(globalThis.__pwcdpClock && globalThis.__pwcdpClock.setNow({now_ms}))"),
100                    "awaitPromise": false,
101                }),
102            )
103            .await?;
104
105        if opts.fixed.unwrap_or(false) {
106            self.set_fixed_time(Some(now_ms)).await?;
107        }
108        Ok(())
109    }
110
111    /// Pin the virtual clock so `Date.now()`/`new Date()`/`performance.now()`
112    /// stop advancing. If `time` is `Some(ms)`, the clock is also moved there
113    /// first; `None` fixes it at the current virtual time.
114    ///
115    /// Calling this implicitly installs the controller if it is not present.
116    pub async fn set_fixed_time(&self, time: Option<i64>) -> Result<()> {
117        // Ensure the controller exists (no-op if already installed).
118        let _: Value = self
119            .inner
120            .session
121            .send(
122                "Runtime.evaluate",
123                json!({
124                    "expression": INJECT_SCRIPT,
125                    "awaitPromise": false,
126                }),
127            )
128            .await?;
129
130        let expr = match time {
131            Some(t) => format!(
132                "(globalThis.__pwcdpClock && globalThis.__pwcdpClock.setFixed({t}))"
133            ),
134            None => "(globalThis.__pwcdpClock && globalThis.__pwcdpClock.setFixed())"
135                .to_string(),
136        };
137        let _: Value = self
138            .inner
139            .session
140            .send(
141                "Runtime.evaluate",
142                json!({ "expression": expr, "awaitPromise": false }),
143            )
144            .await?;
145        Ok(())
146    }
147
148    /// Advance the virtual clock by `ms` milliseconds, firing every timer that
149    /// falls due along the way (just like letting real time pass). Mirrors
150    /// Playwright's `clock.advance`.
151    pub async fn advance(&self, ms: i64) -> Result<()> {
152        if ms < 0 {
153            return Err(Error::InvalidArgument(format!(
154                "Clock::advance expects a non-negative duration, got {ms}"
155            )));
156        }
157        self.eval_controller_op(&format!("advance({ms})")).await
158    }
159
160    /// Advance the virtual clock by `ms` milliseconds **without** firing any
161    /// timers in between (they remain pending). Mirrors Playwright's
162    /// `clock.tick` short-form (duration only).
163    pub async fn tick(&self, ms: i64) -> Result<()> {
164        if ms < 0 {
165            return Err(Error::InvalidArgument(format!(
166                "Clock::tick expects a non-negative duration, got {ms}"
167            )));
168        }
169        self.eval_controller_op(&format!("tick({ms})")).await
170    }
171
172    /// Run all pending timers (microtasks/timers queued up to "now") without
173    /// moving the clock. Mirrors Playwright's `clock.run_all`.
174    pub async fn run_all(&self) -> Result<()> {
175        self.eval_controller_op("runAll()").await
176    }
177
178    /// Resume automatic (real) time progression on this page and clear all
179    /// fakes. Pending fake timers are discarded. Mirrors Playwright's
180    /// `clock.resume`.
181    pub async fn resume(&self) -> Result<()> {
182        self.eval_controller_op("resume()").await
183    }
184
185    /// Alias for [`Clock::resume`].
186    pub async fn resume_all(&self) -> Result<()> {
187        self.resume().await
188    }
189
190    /// Evaluate `globalThis.__pwcdpClock.<op>` against the controller, mapping a
191    /// missing controller to a clear protocol error (the caller should have
192    /// `install()`ed first).
193    async fn eval_controller_op(&self, op: &str) -> Result<()> {
194        let resp: Value = self
195            .inner
196            .session
197            .send(
198                "Runtime.evaluate",
199                json!({
200                    "expression": format!("globalThis.__pwcdpClock && globalThis.__pwcdpClock.{op}"),
201                    "awaitPromise": false,
202                }),
203            )
204            .await?;
205
206        // The expression evaluates to `false` when the controller was never
207        // installed (the `&&` short-circuits). Surface that as a clear error.
208        let installed = resp
209            .get("result")
210            .and_then(|r| r.get("value"))
211            .map(|v| v.as_bool().unwrap_or(true))
212            .unwrap_or(true);
213        if !installed {
214            return Err(Error::ProtocolError(
215                "Clock controller is not installed; call Clock::install() first".into(),
216            ));
217        }
218        Ok(())
219    }
220}
221
222/// Current wall-clock time in milliseconds since the UNIX epoch.
223fn current_unix_ms() -> i64 {
224    SystemTime::now()
225        .duration_since(UNIX_EPOCH)
226        .map(|d| d.as_millis() as i64)
227        .unwrap_or(0)
228}
229
230/// The injected fake-timer controller. Defines `globalThis.__pwcdpClock` with:
231/// `setNow(ms)`, `setFixed(ms?)`, `advance(ms)`, `tick(ms)`, `runAll()`,
232/// `resume()`.
233///
234/// `fixed` means the clock never moves (Date.now() always returns the base);
235/// otherwise it ticks in real time off the base plus elapsed wall time, which
236/// matches Playwright's "loose" timer semantics. `advance(ms)` jumps forward
237/// and drains every timer whose deadline is now in the past, in deadline order.
238/// `tick(ms)` only moves the base forward (timers stay pending).
239const INJECT_SCRIPT: &str = r#"(function(){
240  if (globalThis.__pwcdpClock) return true;
241  var g = globalThis;
242  var origDate = g.Date;
243  // Capture the REAL Date.now before it is overridden, otherwise
244  // virtualNow()/realAtInstall would recurse into FakeDate.now (stack overflow).
245  var origDateNow = g.Date.now.bind(g.Date);
246  var origNow = (typeof performance !== 'undefined' && performance.now) ? performance.now.bind(performance) : null;
247  var origSetTimeout = g.setTimeout, origClearTimeout = g.clearTimeout;
248  var origSetInterval = g.setInterval, origClearInterval = g.clearInterval;
249  var origRAF = g.requestAnimationFrame, origCancelRAF = g.cancelAnimationFrame;
250
251  var installed = false;
252  var fixed = false;
253  var base = origDateNow();
254  var realAtInstall = origDateNow();
255  var nextId = 1;
256  var timers = {};
257
258  function virtualNow() {
259    if (fixed) return base;
260    return base + (origDateNow() - realAtInstall);
261  }
262
263  function FakeDate() {
264    if (!(this instanceof FakeDate)) return new origDate(virtualNow());
265    if (arguments.length === 0) return new origDate(virtualNow());
266    return new (Function.prototype.bind.apply(origDate, [null].concat([].slice.call(arguments))));
267  }
268  FakeDate.prototype = origDate.prototype;
269  FakeDate.now = function(){ return virtualNow(); };
270  FakeDate.parse = origDate.parse;
271  FakeDate.UTC = origDate.UTC;
272  FakeDate.toString = function(){ return origDate.toString(); };
273
274  function fakeSetTimeout(cb, delay) {
275    var id = nextId++;
276    var d = Math.max(0, Number(delay) || 0);
277    var args = [].slice.call(arguments, 2);
278    timers[id] = { fireAt: virtualNow() + d, interval: null, cb: cb, args: args };
279    return id;
280  }
281  function fakeSetInterval(cb, delay) {
282    var id = nextId++;
283    var d = Math.max(0, Number(delay) || 0);
284    var args = [].slice.call(arguments, 2);
285    timers[id] = { fireAt: virtualNow() + d, interval: d, cb: cb, args: args };
286    return id;
287  }
288  function fakeClear(id) {
289    if (id != null) delete timers[id];
290  }
291  function fakeRAF(cb) {
292    return fakeSetTimeout(function(){ cb(virtualNow()); }, 16);
293  }
294
295  function installGlobals() {
296    if (installed) return;
297    g.Date = FakeDate;
298    g.setTimeout = fakeSetTimeout;
299    g.clearTimeout = fakeClear;
300    g.setInterval = fakeSetInterval;
301    g.clearInterval = fakeClear;
302    g.requestAnimationFrame = fakeRAF;
303    g.cancelAnimationFrame = fakeClear;
304    if (origNow && typeof performance !== 'undefined') {
305      var startVirtual = virtualNow();
306      performance.now = function(){ return (virtualNow() - startVirtual); };
307    }
308    installed = true;
309  }
310  function restoreGlobals() {
311    if (!installed) return;
312    g.Date = origDate;
313    g.setTimeout = origSetTimeout;
314    g.clearTimeout = origClearTimeout;
315    g.setInterval = origSetInterval;
316    g.clearInterval = origClearInterval;
317    g.requestAnimationFrame = origRAF;
318    g.cancelAnimationFrame = origCancelRAF;
319    if (origNow && typeof performance !== 'undefined') performance.now = origNow;
320    installed = false;
321  }
322
323  function drain(upto) {
324    var guard = 0;
325    var fired = true;
326    while (fired && guard < 100000) {
327      fired = false;
328      guard++;
329      var due = [];
330      for (var id in timers) {
331        if (timers[id].fireAt <= upto) due.push([Number(id), timers[id].fireAt]);
332      }
333      if (due.length === 0) break;
334      due.sort(function(a,b){ return a[1] - b[1]; });
335      var pick = due[0][0];
336      var t = timers[pick];
337      if (!t) continue;
338      try { t.cb.apply(null, t.args || []); } catch (e) { /* swallow */ }
339      fired = true;
340      if (t.interval != null) {
341        t.fireAt = upto + t.interval - ((upto - t.fireAt) % (t.interval || 1));
342      } else {
343        delete timers[pick];
344      }
345    }
346  }
347
348  g.__pwcdpClock = {
349    setNow: function(ms){ base = Number(ms) || 0; realAtInstall = origDateNow(); return true; },
350    setFixed: function(ms){
351      if (typeof ms === 'number') { base = ms; realAtInstall = origDateNow(); }
352      fixed = true;
353      installGlobals();
354      return true;
355    },
356    advance: function(ms){
357      installGlobals();
358      fixed = false;
359      var target = virtualNow() + (Number(ms) || 0);
360      base = target;
361      realAtInstall = origDateNow();
362      drain(target);
363      return true;
364    },
365    tick: function(ms){
366      installGlobals();
367      fixed = false;
368      base = virtualNow() + (Number(ms) || 0);
369      realAtInstall = origDateNow();
370      return true;
371    },
372    runAll: function(){
373      installGlobals();
374      fixed = false;
375      drain(virtualNow());
376      return true;
377    },
378    resume: function(){
379      restoreGlobals();
380      fixed = false;
381      timers = {};
382      base = origDateNow();
383      realAtInstall = origDateNow();
384      return true;
385    }
386  };
387  return true;
388})();
389"#;