Skip to content

Commit c74e2ff

Browse files
authored
fix: older browsers support in time travel and assertView (#1281)
* fix: support assertView on older chrome versions, like chrome 53 * fix: handle old browsers gracefully in time travel logic * fix: unsubscribe from signal handler in case of multiple quit() calls * fix: fix path resolutions in browser-env tests * fix: enhance debug logging for rrweb and client-bridge * fix: fix rollback invalid calculation when safe area changes mid-capture * fix: do not use optional chaining when waiting for selectors to settle to support older browsers
1 parent c33e1ca commit c74e2ff

18 files changed

Lines changed: 393 additions & 117 deletions

File tree

src/browser/client-bridge/index.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import path from "path";
33
import fs from "fs";
44
import makeDebug from "debug";
55
import { ClientBridgeError } from "./error";
6+
import { WdioBrowser } from "../../types";
67

78
const debug = makeDebug("testplane:client-bridge");
89

@@ -28,13 +29,27 @@ export class ClientBridge<T extends Record<string, (...args: any[]) => any>> {
2829
const scriptFileName = needsCompatLib ? "bundle.compat.js" : "bundle.native.js";
2930
const scriptFilePath = path.join(__dirname, "..", "client-scripts", namespace, "build", scriptFileName);
3031

32+
let debugBrowserId = "";
33+
if (debug.enabled) {
34+
debugBrowserId = `${(browser as WdioBrowser)?.capabilities?.browserName} ${
35+
(browser as WdioBrowser)?.capabilities?.browserVersion
36+
}:${(browser as WdioBrowser)?.sessionId}`;
37+
}
38+
3139
if (bundlesCache[scriptFilePath]) {
40+
debug(
41+
`creating ClientBridge with cached script for namespace ${namespace} at ${scriptFilePath} for browser ${debugBrowserId}`,
42+
);
3243
return new ClientBridge(browser, bundlesCache[scriptFilePath], namespace);
3344
}
3445

3546
const bundle = await fs.promises.readFile(scriptFilePath, { encoding: "utf8" });
3647
bundlesCache[scriptFilePath] = bundle;
3748

49+
debug(
50+
`creating ClientBridge with new script for namespace ${namespace} at ${scriptFilePath} for browser ${debugBrowserId}`,
51+
);
52+
3853
return new this(browser, bundle, namespace);
3954
}
4055

@@ -89,10 +104,7 @@ export class ClientBridge<T extends Record<string, (...args: any[]) => any>> {
89104
}
90105

91106
private async _inject(): Promise<void> {
92-
debug(` > injecting script into namespace ${this._namespace}`);
93-
if (debug.enabled) {
94-
console.log(this._script);
95-
}
107+
debug(` > injecting script into namespace ${this._namespace}: ${this._script.slice(0, 256)}...`);
96108
await this._browser.execute(this._script, this._namespace);
97109
}
98110
}

src/browser/client-scripts/calibrate.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,14 @@
5353
}
5454

5555
function needsCompatLib() {
56-
return !hasCSS3Selectors() || !window.getComputedStyle || !window.matchMedia || !String.prototype.trim;
56+
return (
57+
!hasCSS3Selectors() ||
58+
!window.getComputedStyle ||
59+
!window.matchMedia ||
60+
!String.prototype.trim ||
61+
!window.Node ||
62+
!window.Node.prototype.getRootNode
63+
);
5764
}
5865

5966
// In safari `window.innerWidth` always returns default 980px and and even viewport meta tag setting does not change it.

src/browser/client-scripts/screen-shooter/utils/dom.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import * as lib from "@lib";
12
import { ScreenshooterNamespaceData } from "../types";
23

34
declare global {
@@ -59,7 +60,7 @@ export function forEachRoot(cb: (root: Element | ShadowRoot) => void): void {
5960
export function getParentElement(node: Node): Element | null {
6061
if (node instanceof ShadowRoot) return node.host;
6162
if (node instanceof Element) {
62-
const root = node.getRootNode();
63+
const root = lib.getRootNode(node);
6364
return node.parentElement || (root instanceof ShadowRoot ? root.host : null);
6465
}
6566
return node.parentNode instanceof Element ? node.parentNode : null;

src/browser/client-scripts/shared/lib.compat.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,5 +23,15 @@ export function trim(str: string): string {
2323
return str.replace(/^[\s\uFEFF\xA0]+|[\s\uFEFF\xA0]+$/g, "");
2424
}
2525

26+
export function getRootNode(node: Node): Node {
27+
let root = node;
28+
29+
while (root.parentNode) {
30+
root = root.parentNode;
31+
}
32+
33+
return root;
34+
}
35+
2636
export { getComputedStyle } from "./polyfills/getComputedStyle";
2737
export { matchMedia } from "./polyfills/matchMedia";

src/browser/client-scripts/shared/lib.native.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ export function matchMedia(mediaQuery: string): MediaQueryList {
2525
export function trim(str: string): string {
2626
return str.trim();
2727
}
28+
29+
export function getRootNode(node: Node): Node {
30+
return node.getRootNode();
31+
}

src/browser/commands/assert-view/index.js

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,18 +36,8 @@ const getIgnoreDiffPixelCountRatio = value => {
3636
};
3737

3838
module.exports.default = browser => {
39-
const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser;
40-
const browserProperties = { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib };
41-
const elementsScreenShooterPromise = ElementsScreenShooter.create({
42-
camera: browser.camera,
43-
browser: browser.publicAPI,
44-
browserProperties,
45-
});
46-
const viewportScreenShooterPromise = ViewportScreenShooter.create({
47-
camera: browser.camera,
48-
browser: browser.publicAPI,
49-
browserProperties,
50-
});
39+
let elementsScreenShooterPromise;
40+
let viewportScreenShooterPromise;
5141
const { publicAPI: session, config } = browser;
5242
const {
5343
assertViewOpts,
@@ -192,6 +182,15 @@ module.exports.default = browser => {
192182
debug(`[${debugId}] assertView selectors: %O`, selectors);
193183
debug(`[${debugId}] assertView opts: %O`, opts);
194184

185+
if (!elementsScreenShooterPromise) {
186+
const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser;
187+
elementsScreenShooterPromise = ElementsScreenShooter.create({
188+
camera: browser.camera,
189+
browser: browser.publicAPI,
190+
browserProperties: { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib },
191+
});
192+
}
193+
195194
const screenShooter = await elementsScreenShooterPromise;
196195
await waitForStaticToLoad(opts);
197196
const { image, meta } = await screenShooter.capture(selectors, opts);
@@ -238,6 +237,15 @@ module.exports.default = browser => {
238237

239238
debug(`assertViewByViewport state: ${state}, opts: %O`, opts);
240239

240+
if (!viewportScreenShooterPromise) {
241+
const { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib } = browser;
242+
viewportScreenShooterPromise = ViewportScreenShooter.create({
243+
camera: browser.camera,
244+
browser: browser.publicAPI,
245+
browserProperties: { isWebdriverProtocol, shouldUsePixelRatio, needsCompatLib },
246+
});
247+
}
248+
241249
const vpScreenShooter = await viewportScreenShooterPromise;
242250
await waitForStaticToLoad(opts);
243251
const { image, meta } = await vpScreenShooter.capture(opts);

src/browser/history/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { cleanupRrweb, filterEvents, installRrwebAndCollectEvents, sendFilteredE
99
import { getHistoryContext, runWithHistoryContext } from "./async-local-storage";
1010

1111
const debug = makeDebug("testplane:browser:history");
12+
const debugTimeTravel = makeDebug("testplane:time-travel:history");
1213

1314
interface NodeData {
1415
name: string;
@@ -79,8 +80,10 @@ export const requestDomSnapshots = ({
7980
attempt,
8081
currentTest,
8182
}: RequestDomSnapshotsData): void => {
83+
debugTimeTravel("requestDomSnapshots, called");
8284
try {
8385
if (!callstack) {
86+
debugTimeTravel("requestDomSnapshots, callstack is not defined");
8487
return;
8588
}
8689

@@ -90,6 +93,7 @@ export const requestDomSnapshots = ({
9093
const test = currentTest ?? session.executionContext?.ctx?.currentTest;
9194

9295
if (shouldRecord && process.send && test) {
96+
debugTimeTravel("requestDomSnapshots, shouldRecord and process.send and test are true");
9397
const rrwebPromise = installRrwebAndCollectEvents(session, callstack)
9498
.then(rrwebEvents => {
9599
const rrwebEventsFiltered = filterEvents(rrwebEvents);
@@ -101,6 +105,7 @@ export const requestDomSnapshots = ({
101105

102106
snapshotsPromiseRef.current = snapshotsPromiseRef.current.then(() => rrwebPromise);
103107
}
108+
debugTimeTravel("requestDomSnapshots, done");
104109
} catch (e) {
105110
debug("An error occurred during capturing snapshots in browser: %O", e);
106111
}

src/browser/history/rrweb.ts

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,26 @@
11
import fs from "fs";
2+
import path from "path";
23
import { eventWithTime } from "@rrweb/types";
4+
import makeDebug from "debug";
35
import type { Callstack } from "./callstack";
46
import { MasterEvents } from "../../events";
57
import type { SnapshotsData, Test, TestContext } from "../../types";
68
import { runWithoutHistory } from "./index";
7-
import path from "path";
9+
10+
const debug = makeDebug("testplane:time-travel:rrweb");
811

912
// Built from branch https://github.com/gemini-testing/rrweb/tree/TESTPLANE-712.syntax_err
1013
// PR: https://github.com/rrweb-io/rrweb/pull/1735
1114
// Issue: https://github.com/rrweb-io/rrweb/issues/1734
1215
const rrwebCode = fs.readFileSync(path.join(__dirname, "../client-scripts/rrweb-record.min.js"), "utf-8");
13-
const sessionsWithRrwebRequested = new WeakSet<WebdriverIO.Browser>();
16+
const sessionsWithRrwebSent = new WeakSet<WebdriverIO.Browser>();
17+
const sessionsWithUnsupportedRrweb = new WeakSet<WebdriverIO.Browser>();
1418

1519
interface CollectRrwebEventsResult {
20+
isRrwebSupported?: false;
1621
isRrwebInstalled: boolean;
1722
rrwebEvents: eventWithTime[];
23+
evalError?: string;
1824
}
1925

2026
/* eslint-disable @typescript-eslint/ban-ts-comment */
@@ -23,19 +29,47 @@ export async function installRrwebAndCollectEvents(
2329
callstack: Callstack,
2430
): Promise<eventWithTime[]> {
2531
return runWithoutHistory<Promise<eventWithTime[]>>({ callstack }, async () => {
26-
const shouldSendRrwebCode = !sessionsWithRrwebRequested.has(session);
32+
if (sessionsWithUnsupportedRrweb.has(session)) {
33+
return [];
34+
}
35+
36+
const shouldSendRrwebCode = !sessionsWithRrwebSent.has(session);
2737

2838
if (shouldSendRrwebCode) {
29-
sessionsWithRrwebRequested.add(session);
39+
sessionsWithRrwebSent.add(session);
3040
}
3141

32-
const result = await collectRrwebEvents(session, shouldSendRrwebCode ? rrwebCode : null);
42+
let result = await collectRrwebEvents(session, shouldSendRrwebCode ? rrwebCode : undefined);
3343

44+
let debugBrowserId = "";
45+
if (debug.enabled) {
46+
debugBrowserId = `${session?.capabilities?.browserName} ${session?.capabilities?.browserVersion}:${session?.sessionId}`;
47+
}
48+
49+
if (result.isRrwebSupported === false) {
50+
debug("rrweb is not supported in browser %s, error: %s", debugBrowserId, result.evalError);
51+
sessionsWithUnsupportedRrweb.add(session);
52+
53+
return [];
54+
}
55+
56+
// If rrweb is installed (success) or we already provided the code (no point in trying again)
3457
if (result.isRrwebInstalled || shouldSendRrwebCode) {
3558
return result.rrwebEvents;
3659
}
3760

38-
return (await collectRrwebEvents(session, rrwebCode)).rrwebEvents;
61+
debug("collectRrwebEvents, rrweb was not installed, sending code again");
62+
63+
result = await collectRrwebEvents(session, rrwebCode);
64+
65+
if (result.isRrwebSupported === false) {
66+
debug("rrweb is not supported in browser %s, error: %s", debugBrowserId, result.evalError);
67+
sessionsWithUnsupportedRrweb.add(session);
68+
69+
return [];
70+
}
71+
72+
return result.rrwebEvents;
3973
});
4074
}
4175

@@ -46,9 +80,9 @@ export async function cleanupRrweb(session: WebdriverIO.Browser, callstack: Call
4680
try {
4781
// @ts-expect-error
4882
const rrwebEvents = window.rrwebEvents;
49-
const rrwebData = rrwebEvents?.testplane;
83+
const rrwebData = rrwebEvents && rrwebEvents.testplane;
5084

51-
if (rrwebData?.stopRecording) {
85+
if (rrwebData && rrwebData.stopRecording) {
5286
try {
5387
rrwebData.stopRecording();
5488
} catch (e) {
@@ -57,8 +91,8 @@ export async function cleanupRrweb(session: WebdriverIO.Browser, callstack: Call
5791
}
5892

5993
try {
60-
const colorSchemeMedia = rrwebData?.colorSchemeMedia;
61-
const colorSchemeListener = rrwebData?.colorSchemeListener;
94+
const colorSchemeMedia = rrwebData && rrwebData.colorSchemeMedia;
95+
const colorSchemeListener = rrwebData && rrwebData.colorSchemeListener;
6296

6397
if (colorSchemeMedia && colorSchemeListener) {
6498
colorSchemeMedia.removeEventListener("change", colorSchemeListener);
@@ -67,7 +101,7 @@ export async function cleanupRrweb(session: WebdriverIO.Browser, callstack: Call
67101
/**/
68102
}
69103

70-
if (rrwebData?.isInstalledByTestplane) {
104+
if (rrwebData && rrwebData.isInstalledByTestplane) {
71105
// @ts-expect-error
72106
delete window.rrweb;
73107

@@ -86,21 +120,22 @@ export async function cleanupRrweb(session: WebdriverIO.Browser, callstack: Call
86120
} catch (e) {
87121
/**/
88122
} finally {
89-
sessionsWithRrwebRequested.delete(session);
123+
sessionsWithRrwebSent.delete(session);
124+
sessionsWithUnsupportedRrweb.delete(session);
90125
}
91126
}
92127

93128
function collectRrwebEvents(
94129
session: WebdriverIO.Browser,
95-
rrwebRecordFnCode: string | null,
130+
rrwebRecordFnCode: string | undefined,
96131
): Promise<CollectRrwebEventsResult> {
97132
return session.execute(
98133
(rrwebRecordFnCode, serverTime) => {
99134
const isRrwebInstalled = (): boolean => {
100135
try {
101136
// @ts-expect-error
102137
return Boolean(window.rrweb);
103-
} catch {
138+
} catch (e) {
104139
return false;
105140
}
106141
};
@@ -112,7 +147,7 @@ function collectRrwebEvents(
112147
result = window.rrwebEvents.slice(window.lastProcessedRrwebEvent + 1);
113148
// @ts-expect-error
114149
window.lastProcessedRrwebEvent = window.rrwebEvents.length - 1;
115-
} catch {
150+
} catch (e) {
116151
result = [];
117152
}
118153

@@ -159,7 +194,17 @@ function collectRrwebEvents(
159194

160195
try {
161196
if (!isRrwebInstalled() && rrwebRecordFnCode) {
162-
window.eval(rrwebRecordFnCode);
197+
try {
198+
window.eval(rrwebRecordFnCode);
199+
} catch (e) {
200+
return {
201+
isRrwebSupported: false,
202+
isRrwebInstalled: false,
203+
rrwebEvents: [],
204+
evalError: String(e),
205+
};
206+
}
207+
163208
// @ts-expect-error
164209
window.lastProcessedRrwebEvent = -1;
165210
// @ts-expect-error

src/browser/new-browser.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,13 @@ const headlessBrowserOptions: HeadlessBrowserOptions = {
5454
};
5555

5656
export class NewBrowser extends Browser {
57+
private _onExit: (err?: Error) => Promise<void> = async () => {};
58+
5759
constructor(config: Config, opts: BrowserOpts) {
5860
super(config, opts);
5961

60-
signalHandler.on("exit", (err?: Error) => this.quit(err));
62+
this._onExit = async (err?: Error): Promise<void> => await this.quit(err);
63+
signalHandler.on("exit", this._onExit);
6164
}
6265

6366
async init(): Promise<NewBrowser> {
@@ -76,6 +79,7 @@ export class NewBrowser extends Browser {
7679

7780
async quit(err?: Error): Promise<void> {
7881
this._exitError = err;
82+
signalHandler.off("exit", this._onExit);
7983

8084
try {
8185
this.setHttpTimeout(this._config.sessionQuitTimeout);

0 commit comments

Comments
 (0)