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;