Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 21 additions & 6 deletions src/browser/history/rrweb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,33 +168,48 @@ 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) {
/**/
}

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) {
Expand Down
30 changes: 30 additions & 0 deletions test/e2e/fixtures/basic-report/static/time-travel-delayed.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Time Travel delayed rrweb installation</title>
</head>
<body>
<input id="value" value="" />
<script>
const nativeEval = window.eval;
let shouldDelayRrweb = true;

window.eval = function evalWithDelayedRrwebInstallation(code) {
if (shouldDelayRrweb && String(code).includes('define("rrweb"')) {
shouldDelayRrweb = false;
const startedAt = performance.now();
const deadline = startedAt + 1200;

while (performance.now() < deadline) {
// Intentionally delay only the recorder installation.
}

window.rrwebInstallDelayMs = performance.now() - startedAt;
}

return nativeEval.call(window, code);
};
</script>
</body>
</html>
10 changes: 10 additions & 0 deletions test/e2e/fixtures/basic-report/static/time-travel.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Time Travel baseline</title>
</head>
<body>
<input id="value" value="" />
</body>
</html>
26 changes: 25 additions & 1 deletion test/e2e/fixtures/basic-report/testplane.config.ts
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -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"],
},
},

Expand All @@ -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: {
Expand Down
23 changes: 23 additions & 0 deletions test/e2e/fixtures/basic-report/tests/time-travel.testplane.js
Original file line number Diff line number Diff line change
@@ -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");
});
});
});
1 change: 1 addition & 0 deletions test/e2e/module-aliases/testplane.js
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
module.exports = require("../../../build/src");
4 changes: 4 additions & 0 deletions test/e2e/testplane.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
141 changes: 141 additions & 0 deletions test/e2e/tests/time-travel.testplane.js
Original file line number Diff line number Diff line change
@@ -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"',
);
});
});
Loading
Loading