From a4021d8f434ba9e62e8c30b9dc2733df6ca84c57 Mon Sep 17 00:00:00 2001 From: sipayrt Date: Wed, 16 Sep 2026 18:06:02 +0300 Subject: [PATCH] fix: preserve time travel timestamps with mocked dates Validate native Date constructors and methods before trusting rrweb timestamps. Fall back to the existing performance clock for mocked constructors. Add unit coverage and an end-to-end regression for delayed recorder installation after navigation. Refs: TESTPLANE-870 --- src/browser/history/rrweb.ts | 27 ++- .../static/time-travel-delayed.html | 30 ++++ .../basic-report/static/time-travel.html | 10 ++ .../fixtures/basic-report/testplane.config.ts | 26 ++- .../tests/time-travel.testplane.js | 23 +++ test/e2e/module-aliases/testplane.js | 1 + test/e2e/testplane.config.ts | 4 + test/e2e/tests/time-travel.testplane.js | 141 ++++++++++++++++ test/src/browser/history/rrweb.js | 156 ++++++++++++++++++ 9 files changed, 411 insertions(+), 7 deletions(-) create mode 100644 test/e2e/fixtures/basic-report/static/time-travel-delayed.html create mode 100644 test/e2e/fixtures/basic-report/static/time-travel.html create mode 100644 test/e2e/fixtures/basic-report/tests/time-travel.testplane.js create mode 100644 test/e2e/module-aliases/testplane.js create mode 100644 test/e2e/tests/time-travel.testplane.js 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;