diff --git a/src/browser/history/rrweb.ts b/src/browser/history/rrweb.ts
index e5fcf1f52..da81b37f8 100644
--- a/src/browser/history/rrweb.ts
+++ b/src/browser/history/rrweb.ts
@@ -168,9 +168,16 @@ function collectRrwebEvents(
const getRealTimestamp = (fallbackTime: number = 0): number => {
const nativeCode = "[native code]";
+ const isNativeFunction = (fn: unknown): boolean => {
+ try {
+ return typeof fn === "function" && Function.prototype.toString.call(fn).includes(nativeCode);
+ } catch (e) {
+ return false;
+ }
+ };
try {
- if (Date.now.toString().includes(nativeCode)) {
+ if (isNativeFunction(Date.now)) {
return Date.now();
}
} catch (e) {
@@ -178,23 +185,31 @@ function collectRrwebEvents(
}
try {
- if (new Date().getTime.toString().includes(nativeCode)) {
- return new Date().getTime();
+ if (isNativeFunction(Date)) {
+ const date = new Date();
+
+ if (isNativeFunction(date.getTime)) {
+ return date.getTime();
+ }
}
} catch (e) {
/**/
}
try {
- if (new Date().valueOf.toString().includes(nativeCode)) {
- return new Date().valueOf();
+ if (isNativeFunction(Date)) {
+ const date = new Date();
+
+ if (isNativeFunction(date.valueOf)) {
+ return date.valueOf();
+ }
}
} catch (e) {
/**/
}
try {
- if (performance.now.toString().includes(nativeCode)) {
+ if (isNativeFunction(performance.now) && typeof performance.timeOrigin === "number") {
return Math.floor(performance.timeOrigin + performance.now());
}
} catch (e) {
diff --git a/test/e2e/fixtures/basic-report/static/time-travel-delayed.html b/test/e2e/fixtures/basic-report/static/time-travel-delayed.html
new file mode 100644
index 000000000..416d08ed4
--- /dev/null
+++ b/test/e2e/fixtures/basic-report/static/time-travel-delayed.html
@@ -0,0 +1,30 @@
+
+
+
+
+ Time Travel delayed rrweb installation
+
+
+
+
+
+
diff --git a/test/e2e/fixtures/basic-report/static/time-travel.html b/test/e2e/fixtures/basic-report/static/time-travel.html
new file mode 100644
index 000000000..261be0d8f
--- /dev/null
+++ b/test/e2e/fixtures/basic-report/static/time-travel.html
@@ -0,0 +1,10 @@
+
+
+
+
+ Time Travel baseline
+
+
+
+
+
diff --git a/test/e2e/fixtures/basic-report/testplane.config.ts b/test/e2e/fixtures/basic-report/testplane.config.ts
index f65446601..27d67bc47 100644
--- a/test/e2e/fixtures/basic-report/testplane.config.ts
+++ b/test/e2e/fixtures/basic-report/testplane.config.ts
@@ -1,4 +1,12 @@
import path from "path";
+import Module from "module";
+
+const moduleAliasesPath = path.resolve(__dirname, "../../module-aliases");
+
+// In this repository Testplane is the package root, not an installed peer dependency.
+// Expose its built entry point so html-reporter can detect Time Travel while generating the fixture.
+process.env.NODE_PATH = [moduleAliasesPath, process.env.NODE_PATH].filter(Boolean).join(path.delimiter);
+(Module as typeof Module & { _initPaths: () => void })._initPaths();
const SERVER_PORT = 3700;
@@ -14,7 +22,12 @@ export default {
sets: {
assertView: {
- files: path.join(__dirname, "tests/**/*.testplane.js"),
+ files: path.join(__dirname, "tests/test.testplane.js"),
+ browsers: ["chrome"],
+ },
+ timeTravel: {
+ files: path.join(__dirname, "tests/time-travel.testplane.js"),
+ browsers: ["time-travel-chrome"],
},
},
@@ -38,6 +51,17 @@ export default {
},
waitTimeout: 3000,
},
+ "time-travel-chrome": {
+ timeTravel: "on",
+ desiredCapabilities: {
+ browserName: "chrome",
+ "goog:chromeOptions": {
+ args: ["headless", "no-sandbox", "hide-scrollbars", "disable-dev-shm-usage"],
+ binary: "/usr/bin/chromium",
+ },
+ },
+ waitTimeout: 3000,
+ },
},
devServer: {
diff --git a/test/e2e/fixtures/basic-report/tests/time-travel.testplane.js b/test/e2e/fixtures/basic-report/tests/time-travel.testplane.js
new file mode 100644
index 000000000..30240f893
--- /dev/null
+++ b/test/e2e/fixtures/basic-report/tests/time-travel.testplane.js
@@ -0,0 +1,23 @@
+/* global window */
+
+describe("time travel", () => {
+ it("keeps steps aligned after delayed rrweb installation on navigation", async ({ browser }) => {
+ await browser.url("time-travel.html");
+ await browser.waitUntil(() => browser.execute(() => Boolean(window.rrweb)), {
+ timeout: 3000,
+ timeoutMsg: "rrweb recorder was not installed on the baseline page",
+ });
+
+ await browser.runStep("Open delayed page", async () => {
+ await browser.url("time-travel-delayed.html");
+
+ const rrwebInstallDelayMs = await browser.execute(() => window.rrwebInstallDelayMs);
+
+ expect(rrwebInstallDelayMs).toBeGreaterThanOrEqual(1200);
+ });
+
+ await browser.runStep("Fill delayed input", async () => {
+ await browser.$("#value").setValue("after-delay");
+ });
+ });
+});
diff --git a/test/e2e/module-aliases/testplane.js b/test/e2e/module-aliases/testplane.js
new file mode 100644
index 000000000..ed816efc8
--- /dev/null
+++ b/test/e2e/module-aliases/testplane.js
@@ -0,0 +1 @@
+module.exports = require("../../../build/src");
diff --git a/test/e2e/testplane.config.ts b/test/e2e/testplane.config.ts
index c7ce8558f..5afe51af8 100644
--- a/test/e2e/testplane.config.ts
+++ b/test/e2e/testplane.config.ts
@@ -21,6 +21,10 @@ export default {
files: path.join(__dirname, "tests/report-page-screenshot.testplane.js"),
browsers: ["chrome"],
},
+ timeTravel: {
+ files: path.join(__dirname, "tests/time-travel.testplane.js"),
+ browsers: ["chrome"],
+ },
calibrationResize: {
files: path.join(__dirname, "tests/calibration-resize.testplane.js"),
browsers: ["calibrated-chrome"],
diff --git a/test/e2e/tests/time-travel.testplane.js b/test/e2e/tests/time-travel.testplane.js
new file mode 100644
index 000000000..97783aa4c
--- /dev/null
+++ b/test/e2e/tests/time-travel.testplane.js
@@ -0,0 +1,141 @@
+const assert = require("assert");
+const fs = require("fs");
+const path = require("path");
+const initSqlJs = require("@gemini-testing/sql.js");
+const yauzl = require("yauzl");
+
+const REPORT_PATH = path.resolve(__dirname, "../static/basic-report");
+const TEST_NAME = "keeps steps aligned after delayed rrweb installation on navigation";
+
+const readSnapshots = zipPath =>
+ new Promise((resolve, reject) => {
+ yauzl.open(zipPath, { lazyEntries: true }, (openError, zipFile) => {
+ if (openError) {
+ reject(openError);
+ return;
+ }
+
+ const rejectAndClose = error => {
+ zipFile.close();
+ reject(error);
+ };
+
+ zipFile.readEntry();
+ zipFile.on("entry", entry => {
+ if (entry.fileName !== "snapshots.json") {
+ zipFile.readEntry();
+ return;
+ }
+
+ zipFile.openReadStream(entry, (streamError, stream) => {
+ if (streamError) {
+ rejectAndClose(streamError);
+ return;
+ }
+
+ const chunks = [];
+
+ stream.on("data", chunk => chunks.push(chunk));
+ stream.on("error", rejectAndClose);
+ stream.on("end", () => {
+ try {
+ const snapshots = Buffer.concat(chunks)
+ .toString()
+ .split("\n")
+ .filter(Boolean)
+ .map(line => JSON.parse(line));
+
+ zipFile.close();
+ resolve(snapshots);
+ } catch (error) {
+ rejectAndClose(error);
+ }
+ });
+ });
+ });
+ zipFile.on("end", () => rejectAndClose(new Error('snapshot archive does not contain "snapshots.json"')));
+ zipFile.on("error", rejectAndClose);
+ });
+ });
+
+const findNode = (node, predicate) => {
+ if (predicate(node)) {
+ return node;
+ }
+
+ for (const child of node.childNodes || []) {
+ const result = findNode(child, predicate);
+
+ if (result) {
+ return result;
+ }
+ }
+
+ return null;
+};
+
+const isInsideStep = (timestamp, step) => timestamp >= step.ts && timestamp <= step.ts + step.d;
+
+describe("time travel report", () => {
+ it("keeps delayed navigation DOM events aligned with named steps", async () => {
+ const SQL = await initSqlJs({
+ locateFile: file => require.resolve(`@gemini-testing/sql.js/dist/${file}`),
+ });
+ const database = new SQL.Database(fs.readFileSync(path.join(REPORT_PATH, "sqlite.db")));
+ const statement = database.prepare("SELECT history, attachments FROM suites WHERE suiteName = ?");
+
+ statement.bind([TEST_NAME]);
+ assert(statement.step(), `test result "${TEST_NAME}" is missing from the generated report`);
+
+ const reportEntry = statement.getAsObject();
+ const history = JSON.parse(reportEntry.history);
+ const attachments = JSON.parse(reportEntry.attachments);
+
+ statement.free();
+ database.close();
+
+ const snapshotAttachment = attachments.find(attachment => attachment.type === 0);
+
+ assert(snapshotAttachment, "Time Travel snapshot attachment is missing from the generated report");
+
+ const snapshots = await readSnapshots(path.join(REPORT_PATH, snapshotAttachment.path));
+ const openStep = history.find(step => step.n === "Open delayed page");
+ const fillStep = history.find(step => step.n === "Fill delayed input");
+
+ assert(openStep, 'history step "Open delayed page" is missing');
+ assert(fillStep, 'history step "Fill delayed input" is missing');
+
+ const delayedPageSnapshot = snapshots.find(snapshot => {
+ if (snapshot.type !== 2 || !isInsideStep(snapshot.timestamp, openStep)) {
+ return false;
+ }
+
+ const title = findNode(snapshot.data.node, node => node.tagName === "title");
+ const input = findNode(
+ snapshot.data.node,
+ node => node.tagName === "input" && node.attributes?.id === "value",
+ );
+
+ return title?.childNodes?.[0]?.textContent === "Time Travel delayed rrweb installation" && input;
+ });
+
+ assert(delayedPageSnapshot, 'the delayed page full snapshot is not aligned with "Open delayed page"');
+
+ const input = findNode(
+ delayedPageSnapshot.data.node,
+ node => node.tagName === "input" && node.attributes?.id === "value",
+ );
+
+ assert.strictEqual(input.attributes.value, "", 'input must be empty at the end of "Open delayed page"');
+
+ const filledInputEvent = snapshots.find(
+ snapshot => snapshot.data?.source === 5 && snapshot.data.text === "after-delay",
+ );
+
+ assert(filledInputEvent, 'input mutation "after-delay" is missing from Time Travel snapshots');
+ assert(
+ isInsideStep(filledInputEvent.timestamp, fillStep),
+ 'input mutation "after-delay" is not aligned with "Fill delayed input"',
+ );
+ });
+});
diff --git a/test/src/browser/history/rrweb.js b/test/src/browser/history/rrweb.js
index 0822374a0..5a67bc9be 100644
--- a/test/src/browser/history/rrweb.js
+++ b/test/src/browser/history/rrweb.js
@@ -1,5 +1,6 @@
"use strict";
+const vm = require("node:vm");
const { cleanupRrweb, installRrwebAndCollectEvents } = require("src/browser/history/rrweb");
describe("browser/history/rrweb", () => {
@@ -7,6 +8,161 @@ describe("browser/history/rrweb", () => {
afterEach(() => sandbox.restore());
+ const createNativeDate = () => vm.runInNewContext("Date");
+
+ const createPerformance = ({ isNative = true } = {}) => ({
+ timeOrigin: 1000,
+ now: isNative ? Math.max.bind(null, 234.6) : () => 234.6,
+ });
+
+ const createSession = ({ browserDate, performance }) => {
+ const window = {};
+ let recordReturned = false;
+ let emitWasSynchronous = false;
+ let serverTime;
+
+ window.eval = () => {
+ const record = ({ emit }) => {
+ emitWasSynchronous = !recordReturned;
+ emit({ type: 1, timestamp: 0, data: { id: 1 } });
+ emit({ type: 2, timestamp: 0, data: { id: 2 } });
+ recordReturned = true;
+
+ return () => {};
+ };
+ record.addCustomEvent = () => {};
+ window.rrweb = { record };
+ };
+
+ const execute = sandbox.stub().callsFake((script, rrwebRecordFnCode, fallbackTime) => {
+ serverTime = fallbackTime;
+ const context = vm.createContext({ performance, window });
+ context.Date = browserDate;
+ const browserScript = vm.runInContext(`(${script.toString()})`, context);
+
+ return Promise.resolve(browserScript(rrwebRecordFnCode, fallbackTime));
+ });
+
+ return {
+ session: {
+ capabilities: { browserName: "chrome" },
+ execute,
+ },
+ get serverTime() {
+ return serverTime;
+ },
+ get emitWasSynchronous() {
+ return emitWasSynchronous;
+ },
+ };
+ };
+
+ const createFakeDate = NativeDate => {
+ const fakeDate = function FakeDate() {
+ return new NativeDate(42);
+ };
+ fakeDate.prototype = NativeDate.prototype;
+
+ return fakeDate;
+ };
+
+ describe("event timestamps", () => {
+ it("should use performance timestamp for a fake Date with native date methods", async () => {
+ const nativeDate = createNativeDate();
+ const { session } = createSession({
+ browserDate: createFakeDate(nativeDate),
+ performance: createPerformance(),
+ });
+
+ const events = await installRrwebAndCollectEvents(session, {});
+
+ assert.deepEqual(
+ events.map(event => event.timestamp),
+ [1234, 1234],
+ );
+ });
+
+ it("should use server time when Date and performance are not trusted", async () => {
+ const nativeDate = createNativeDate();
+ const browser = createSession({
+ browserDate: createFakeDate(nativeDate),
+ performance: createPerformance({ isNative: false }),
+ });
+
+ const events = await installRrwebAndCollectEvents(browser.session, {});
+
+ assert.deepEqual(
+ events.map(event => event.timestamp),
+ [browser.serverTime, browser.serverTime],
+ );
+ });
+
+ it("should use native Date when Date.now is overridden", async () => {
+ const nativeDate = createNativeDate();
+ nativeDate.now = () => 42;
+ const browser = createSession({
+ browserDate: nativeDate,
+ performance: createPerformance({ isNative: false }),
+ });
+ const before = Date.now();
+
+ const events = await installRrwebAndCollectEvents(browser.session, {});
+
+ const after = Date.now();
+ assert.notEqual(events[0].timestamp, 42);
+ assert.isAtLeast(events[0].timestamp, before - 1000);
+ assert.isAtMost(events[0].timestamp, after + 1000);
+ });
+
+ it("should not trust a spoofed Date.now.toString", async () => {
+ const nativeDate = createNativeDate();
+ const fakeDateNow = () => 42;
+ fakeDateNow.toString = () => "function now() { [native code] }";
+ nativeDate.now = fakeDateNow;
+ const browser = createSession({
+ browserDate: nativeDate,
+ performance: createPerformance({ isNative: false }),
+ });
+ const before = Date.now();
+
+ const events = await installRrwebAndCollectEvents(browser.session, {});
+
+ const after = Date.now();
+ assert.notEqual(events[0].timestamp, 42);
+ assert.isAtLeast(events[0].timestamp, before - 1000);
+ assert.isAtMost(events[0].timestamp, after + 1000);
+ });
+
+ it("should preserve timestamps from a native Date", async () => {
+ const browser = createSession({
+ browserDate: createNativeDate(),
+ performance: createPerformance({ isNative: false }),
+ });
+ const before = Date.now();
+
+ const events = await installRrwebAndCollectEvents(browser.session, {});
+
+ const after = Date.now();
+ assert.isAtLeast(events[0].timestamp, before - 1000);
+ assert.isAtMost(events[0].timestamp, after + 1000);
+ });
+ });
+
+ it("should collect events synchronously and preserve their order", async () => {
+ const browser = createSession({
+ browserDate: createNativeDate(),
+ performance: createPerformance(),
+ });
+
+ const events = await installRrwebAndCollectEvents(browser.session, {});
+
+ assert.isTrue(browser.emitWasSynchronous);
+ assert.deepEqual(
+ events.map(event => event.data.id),
+ [1, 2],
+ );
+ });
+
describe("internet explorer", () => {
let session;