feat: Allow to pass errorHandler as record option (#1107)

* feat: Allow to pass `errorHandler` as record option

* add docs

* Apply formatting changes
This commit is contained in:
Francesco Novy
2026-04-01 12:00:00 +08:00
committed by GitHub
parent 271501b7b9
commit 74411fc544
7 changed files with 918 additions and 310 deletions

View File

@@ -0,0 +1,5 @@
---
'rrweb': minor
---
feat: Allow to pass `errorHandler` as record option

View File

@@ -163,6 +163,7 @@ The parameter of `rrweb.record` accepts the following options.
| collectFonts | false | whether to collect fonts in the website |
| userTriggeredOnInput | false | whether to add `userTriggered` on input events that indicates if this event was triggered directly by the user or not. [What is `userTriggered`?](https://github.com/rrweb-io/rrweb/pull/495) |
| plugins | [] | load plugins to provide extended record functions. [What is plugins?](./docs/recipes/plugin.md) |
| errorHandler | - | A callback that is called if something inside of rrweb throws an error. The callback receives the error as argument. |
#### Privacy

View File

@@ -0,0 +1,36 @@
import type { ErrorHandler } from '../types';
type Callback = (...args: unknown[]) => unknown;
let errorHandler: ErrorHandler | undefined;
export function registerErrorHandler(handler: ErrorHandler | undefined) {
errorHandler = handler;
}
export function unregisterErrorHandler() {
errorHandler = undefined;
}
/**
* Wrap callbacks in a wrapper that allows to pass errors to a configured `errorHandler` method.
*/
export const callbackWrapper = <T extends Callback>(cb: T): T => {
if (!errorHandler) {
return cb;
}
const rrwebWrapped = ((...rest: unknown[]) => {
try {
return cb(...rest);
} catch (error) {
if (errorHandler && errorHandler(error) === true) {
return;
}
throw error;
}
}) as unknown as T;
return rrwebWrapped;
};

View File

@@ -36,6 +36,11 @@ import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
import { StylesheetManager } from './stylesheet-manager';
import {
callbackWrapper,
registerErrorHandler,
unregisterErrorHandler,
} from './error-handler';
function wrapEvent(e: event): eventWithTime {
return {
@@ -85,8 +90,11 @@ function record<T = eventWithTime>(
plugins,
keepIframeSrcFn = () => false,
ignoreCSSAttributes = new Set([]),
errorHandler,
} = options;
registerErrorHandler(errorHandler);
const inEmittingFrame = recordCrossOriginIframes
? window.parent === window
: true;
@@ -416,7 +424,7 @@ function record<T = eventWithTime>(
const handlers: listenerHandler[] = [];
const observe = (doc: Document) => {
return initObservers(
return callbackWrapper(initObservers)(
{
mutationCb: wrappedMutationEmit,
mousemoveCb: (positions, source) =>
@@ -609,6 +617,7 @@ function record<T = eventWithTime>(
return () => {
handlers.forEach((h) => h());
recording = false;
unregisterErrorHandler();
};
} catch (error) {
// TODO: handle internal error

View File

@@ -42,6 +42,7 @@ import {
} from '@rrweb/types';
import MutationBuffer from './mutation';
import ProcessedNodeManager from './processed-node-manager';
import { callbackWrapper } from './error-handler';
type WindowWithStoredMutationObserver = IWindow & {
__rrMutationObserver?: MutationObserver;
@@ -110,7 +111,9 @@ export function initMutationObserver(
}
const observer = new (mutationObserverCtor as new (
callback: MutationCallback,
) => MutationObserver)(mutationBuffer.processMutations.bind(mutationBuffer));
) => MutationObserver)(
callbackWrapper(mutationBuffer.processMutations.bind(mutationBuffer)),
);
observer.observe(rootEl, {
attributes: true,
attributeOldValue: true,
@@ -144,6 +147,7 @@ function initMoveObserver({
let positions: mousePosition[] = [];
let timeBaseline: number | null;
const wrappedCb = throttle(
callbackWrapper(
(
source:
| IncrementalSource.MouseMove
@@ -161,10 +165,12 @@ function initMoveObserver({
positions = [];
timeBaseline = null;
},
),
callbackThreshold,
);
const updatePosition = throttle<MouseEvent | TouchEvent | DragEvent>(
(evt) => {
const updatePosition = callbackWrapper(
throttle<MouseEvent | TouchEvent | DragEvent>(
callbackWrapper((evt) => {
const target = getEventTarget(evt);
const { clientX, clientY } = isTouchEvent(evt)
? evt.changedTouches[0]
@@ -187,20 +193,21 @@ function initMoveObserver({
? IncrementalSource.MouseMove
: IncrementalSource.TouchMove,
);
},
}),
threshold,
{
trailing: false,
},
),
);
const handlers = [
on('mousemove', updatePosition, doc),
on('touchmove', updatePosition, doc),
on('drag', updatePosition, doc),
];
return () => {
return callbackWrapper(() => {
handlers.forEach((h) => h());
};
});
}
function initMouseInteractionObserver({
@@ -235,7 +242,7 @@ function initMouseInteractionObserver({
}
const id = mirror.getId(target);
const { clientX, clientY } = e;
mouseInteractionCb({
callbackWrapper(mouseInteractionCb)({
type: MouseInteractions[eventKey],
id,
x: clientX,
@@ -255,9 +262,9 @@ function initMouseInteractionObserver({
const handler = getHandler(eventKey);
handlers.push(on(eventName, handler, doc));
});
return () => {
return callbackWrapper(() => {
handlers.forEach((h) => h());
};
});
}
export function initScrollObserver({
@@ -271,9 +278,14 @@ export function initScrollObserver({
observerParam,
'scrollCb' | 'doc' | 'mirror' | 'blockClass' | 'blockSelector' | 'sampling'
>): listenerHandler {
const updatePosition = throttle<UIEvent>((evt) => {
const updatePosition = callbackWrapper(
throttle<UIEvent>(
callbackWrapper((evt) => {
const target = getEventTarget(evt);
if (!target || isBlocked(target as Node, blockClass, blockSelector, true)) {
if (
!target ||
isBlocked(target as Node, blockClass, blockSelector, true)
) {
return;
}
const id = mirror.getId(target as Node);
@@ -291,7 +303,10 @@ export function initScrollObserver({
y: (target as HTMLElement).scrollTop,
});
}
}, sampling.scroll || 100);
}),
sampling.scroll || 100,
),
);
return on('scroll', updatePosition, doc);
}
@@ -300,7 +315,9 @@ function initViewportResizeObserver({
}: observerParam): listenerHandler {
let lastH = -1;
let lastW = -1;
const updateDimension = throttle(() => {
const updateDimension = callbackWrapper(
throttle(
callbackWrapper(() => {
const height = getWindowHeight();
const width = getWindowWidth();
if (lastH !== height || lastW !== width) {
@@ -311,7 +328,10 @@ function initViewportResizeObserver({
lastH = height;
lastW = width;
}
}, 200);
}),
200,
),
);
return on('resize', updateDimension, window);
}
@@ -382,7 +402,7 @@ function initInputObserver({
}
cbWithDedup(
target,
wrapEventWithUserTriggeredFlag(
callbackWrapper(wrapEventWithUserTriggeredFlag)(
{ text, isChecked, userTriggered },
userTriggeredOnInput,
),
@@ -397,7 +417,7 @@ function initInputObserver({
if (el !== target) {
cbWithDedup(
el,
wrapEventWithUserTriggeredFlag(
callbackWrapper(wrapEventWithUserTriggeredFlag)(
{
text: (el as HTMLInputElement).value,
isChecked: !isChecked,
@@ -419,7 +439,7 @@ function initInputObserver({
) {
lastInputValueMap.set(target, v);
const id = mirror.getId(target as Node);
inputCb({
callbackWrapper(inputCb)({
...v,
id,
});
@@ -427,7 +447,7 @@ function initInputObserver({
}
const events = sampling.input === 'last' ? ['change'] : ['input', 'change'];
const handlers: Array<listenerHandler | hookResetter> = events.map(
(eventName) => on(eventName, eventHandler, doc),
(eventName) => on(eventName, callbackWrapper(eventHandler), doc),
);
const currentWindow = doc.defaultView;
if (!currentWindow) {
@@ -457,7 +477,7 @@ function initInputObserver({
{
set() {
// mock to a normal event
eventHandler({
callbackWrapper(eventHandler)({
target: this as EventTarget,
isTrusted: false, // userTriggered to false as this could well be programmatic
} as Event);
@@ -469,9 +489,9 @@ function initInputObserver({
),
);
}
return () => {
return callbackWrapper(() => {
handlers.forEach((h) => h());
};
});
}
type GroupingCSSRule =
@@ -548,13 +568,17 @@ function initStyleSheetObserver(
// eslint-disable-next-line @typescript-eslint/unbound-method
const insertRule = win.CSSStyleSheet.prototype.insertRule;
win.CSSStyleSheet.prototype.insertRule = function (
this: CSSStyleSheet,
rule: string,
index?: number,
) {
win.CSSStyleSheet.prototype.insertRule = new Proxy(insertRule, {
apply: callbackWrapper(
(
target: typeof insertRule,
thisArg: CSSStyleSheet,
argumentsList: [string, number | undefined],
) => {
const [rule, index] = argumentsList;
const { id, styleId } = getIdAndStyleId(
this,
thisArg,
mirror,
stylesheetManager.styleMirror,
);
@@ -566,17 +590,24 @@ function initStyleSheetObserver(
adds: [{ rule, index }],
});
}
return insertRule.apply(this, [rule, index]);
};
return target.apply(thisArg, argumentsList);
},
),
});
// eslint-disable-next-line @typescript-eslint/unbound-method
const deleteRule = win.CSSStyleSheet.prototype.deleteRule;
win.CSSStyleSheet.prototype.deleteRule = function (
this: CSSStyleSheet,
index: number,
) {
win.CSSStyleSheet.prototype.deleteRule = new Proxy(deleteRule, {
apply: callbackWrapper(
(
target: typeof deleteRule,
thisArg: CSSStyleSheet,
argumentsList: [number],
) => {
const [index] = argumentsList;
const { id, styleId } = getIdAndStyleId(
this,
thisArg,
mirror,
stylesheetManager.styleMirror,
);
@@ -588,19 +619,27 @@ function initStyleSheetObserver(
removes: [{ index }],
});
}
return deleteRule.apply(this, [index]);
};
return target.apply(thisArg, argumentsList);
},
),
});
let replace: (text: string) => Promise<CSSStyleSheet>;
if (win.CSSStyleSheet.prototype.replace) {
// eslint-disable-next-line @typescript-eslint/unbound-method
replace = win.CSSStyleSheet.prototype.replace;
win.CSSStyleSheet.prototype.replace = function (
this: CSSStyleSheet,
text: string,
) {
win.CSSStyleSheet.prototype.replace = new Proxy(replace, {
apply: callbackWrapper(
(
target: typeof replace,
thisArg: CSSStyleSheet,
argumentsList: [string],
) => {
const [text] = argumentsList;
const { id, styleId } = getIdAndStyleId(
this,
thisArg,
mirror,
stylesheetManager.styleMirror,
);
@@ -612,20 +651,27 @@ function initStyleSheetObserver(
replace: text,
});
}
return replace.apply(this, [text]);
};
return target.apply(thisArg, argumentsList);
},
),
});
}
let replaceSync: (text: string) => void;
if (win.CSSStyleSheet.prototype.replaceSync) {
// eslint-disable-next-line @typescript-eslint/unbound-method
replaceSync = win.CSSStyleSheet.prototype.replaceSync;
win.CSSStyleSheet.prototype.replaceSync = function (
this: CSSStyleSheet,
text: string,
) {
win.CSSStyleSheet.prototype.replaceSync = new Proxy(replaceSync, {
apply: callbackWrapper(
(
target: typeof replaceSync,
thisArg: CSSStyleSheet,
argumentsList: [string],
) => {
const [text] = argumentsList;
const { id, styleId } = getIdAndStyleId(
this,
thisArg,
mirror,
stylesheetManager.styleMirror,
);
@@ -637,8 +683,10 @@ function initStyleSheetObserver(
replaceSync: text,
});
}
return replaceSync.apply(this, [text]);
};
return target.apply(thisArg, argumentsList);
},
),
});
}
const supportedNestedCSSRuleTypes: {
@@ -677,13 +725,19 @@ function initStyleSheetObserver(
deleteRule: type.prototype.deleteRule,
};
type.prototype.insertRule = function (
this: CSSGroupingRule,
rule: string,
index?: number,
) {
type.prototype.insertRule = new Proxy(
unmodifiedFunctions[typeKey].insertRule,
{
apply: callbackWrapper(
(
target: typeof insertRule,
thisArg: CSSRule,
argumentsList: [string, number | undefined],
) => {
const [rule, index] = argumentsList;
const { id, styleId } = getIdAndStyleId(
this.parentStyleSheet,
thisArg.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
@@ -696,22 +750,32 @@ function initStyleSheetObserver(
{
rule,
index: [
...getNestedCSSRulePositions(this as CSSRule),
...getNestedCSSRulePositions(thisArg),
index || 0, // defaults to 0
],
},
],
});
}
return unmodifiedFunctions[typeKey].insertRule.apply(this, [rule, index]);
};
return target.apply(thisArg, argumentsList);
},
),
},
);
type.prototype.deleteRule = new Proxy(
unmodifiedFunctions[typeKey].deleteRule,
{
apply: callbackWrapper(
(
target: typeof deleteRule,
thisArg: CSSRule,
argumentsList: [number],
) => {
const [index] = argumentsList;
type.prototype.deleteRule = function (
this: CSSGroupingRule,
index: number,
) {
const { id, styleId } = getIdAndStyleId(
this.parentStyleSheet,
thisArg.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
@@ -721,15 +785,18 @@ function initStyleSheetObserver(
id,
styleId,
removes: [
{ index: [...getNestedCSSRulePositions(this as CSSRule), index] },
{ index: [...getNestedCSSRulePositions(thisArg), index] },
],
});
}
return unmodifiedFunctions[typeKey].deleteRule.apply(this, [index]);
};
return target.apply(thisArg, argumentsList);
},
),
},
);
});
return () => {
return callbackWrapper(() => {
win.CSSStyleSheet.prototype.insertRule = insertRule;
win.CSSStyleSheet.prototype.deleteRule = deleteRule;
replace && (win.CSSStyleSheet.prototype.replace = replace);
@@ -738,7 +805,7 @@ function initStyleSheetObserver(
type.prototype.insertRule = unmodifiedFunctions[typeKey].insertRule;
type.prototype.deleteRule = unmodifiedFunctions[typeKey].deleteRule;
});
};
});
}
export function initAdoptedStyleSheetObserver(
@@ -792,7 +859,7 @@ export function initAdoptedStyleSheetObserver(
},
});
return () => {
return callbackWrapper(() => {
Object.defineProperty(host, 'adoptedStyleSheets', {
configurable: originalPropertyDescriptor.configurable,
enumerable: originalPropertyDescriptor.enumerable,
@@ -801,7 +868,7 @@ export function initAdoptedStyleSheetObserver(
// eslint-disable-next-line @typescript-eslint/unbound-method
set: originalPropertyDescriptor.set,
});
};
});
}
function initStyleDeclarationObserver(
@@ -815,18 +882,21 @@ function initStyleDeclarationObserver(
): listenerHandler {
// eslint-disable-next-line @typescript-eslint/unbound-method
const setProperty = win.CSSStyleDeclaration.prototype.setProperty;
win.CSSStyleDeclaration.prototype.setProperty = function (
this: CSSStyleDeclaration,
property: string,
value: string,
priority: string,
) {
win.CSSStyleDeclaration.prototype.setProperty = new Proxy(setProperty, {
apply: callbackWrapper(
(
target: typeof setProperty,
thisArg: CSSStyleDeclaration,
argumentsList: [string, string, string],
) => {
const [property, value, priority] = argumentsList;
// ignore this mutation if we do not care about this css attribute
if (ignoreCSSAttributes.has(property)) {
return setProperty.apply(this, [property, value, priority]);
return setProperty.apply(thisArg, [property, value, priority]);
}
const { id, styleId } = getIdAndStyleId(
this.parentRule?.parentStyleSheet,
thisArg.parentRule?.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
@@ -840,24 +910,31 @@ function initStyleDeclarationObserver(
priority,
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
index: getNestedCSSRulePositions(this.parentRule!),
index: getNestedCSSRulePositions(thisArg.parentRule!),
});
}
return setProperty.apply(this, [property, value, priority]);
};
return target.apply(thisArg, argumentsList);
},
),
});
// eslint-disable-next-line @typescript-eslint/unbound-method
const removeProperty = win.CSSStyleDeclaration.prototype.removeProperty;
win.CSSStyleDeclaration.prototype.removeProperty = function (
this: CSSStyleDeclaration,
property: string,
) {
win.CSSStyleDeclaration.prototype.removeProperty = new Proxy(removeProperty, {
apply: callbackWrapper(
(
target: typeof removeProperty,
thisArg: CSSStyleDeclaration,
argumentsList: [string],
) => {
const [property] = argumentsList;
// ignore this mutation if we do not care about this css attribute
if (ignoreCSSAttributes.has(property)) {
return removeProperty.apply(this, [property]);
return removeProperty.apply(thisArg, [property]);
}
const { id, styleId } = getIdAndStyleId(
this.parentRule?.parentStyleSheet,
thisArg.parentRule?.parentStyleSheet,
mirror,
stylesheetManager.styleMirror,
);
@@ -869,16 +946,18 @@ function initStyleDeclarationObserver(
property,
},
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
index: getNestedCSSRulePositions(this.parentRule!),
index: getNestedCSSRulePositions(thisArg.parentRule!),
});
}
return removeProperty.apply(this, [property]);
};
return target.apply(thisArg, argumentsList);
},
),
});
return () => {
return callbackWrapper(() => {
win.CSSStyleDeclaration.prototype.setProperty = setProperty;
win.CSSStyleDeclaration.prototype.removeProperty = removeProperty;
};
});
}
function initMediaInteractionObserver({
@@ -888,8 +967,9 @@ function initMediaInteractionObserver({
mirror,
sampling,
}: observerParam): listenerHandler {
const handler = (type: MediaInteractions) =>
throttle((event: Event) => {
const handler = callbackWrapper((type: MediaInteractions) =>
throttle(
callbackWrapper((event: Event) => {
const target = getEventTarget(event);
if (
!target ||
@@ -907,7 +987,10 @@ function initMediaInteractionObserver({
muted,
playbackRate,
});
}, sampling.media || 500);
}),
sampling.media || 500,
),
);
const handlers = [
on('play', handler(MediaInteractions.Play)),
on('pause', handler(MediaInteractions.Pause)),
@@ -915,9 +998,9 @@ function initMediaInteractionObserver({
on('volumechange', handler(MediaInteractions.VolumeChange)),
on('ratechange', handler(MediaInteractions.RateChange)),
];
return () => {
return callbackWrapper(() => {
handlers.forEach((h) => h());
};
});
}
function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
@@ -956,13 +1039,16 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
'add',
function (original: (font: FontFace) => void) {
return function (this: FontFaceSet, fontFace: FontFace) {
setTimeout(() => {
setTimeout(
callbackWrapper(() => {
const p = fontMap.get(fontFace);
if (p) {
fontCb(p);
fontMap.delete(fontFace);
}
}, 0);
}),
0,
);
return original.apply(this, [fontFace]);
};
},
@@ -973,16 +1059,16 @@ function initFontObserver({ fontCb, doc }: observerParam): listenerHandler {
});
handlers.push(restoreHandler);
return () => {
return callbackWrapper(() => {
handlers.forEach((h) => h());
};
});
}
function initSelectionObserver(param: observerParam): listenerHandler {
const { doc, mirror, blockClass, blockSelector, selectionCb } = param;
let collapsed = true;
const updateSelection = () => {
const updateSelection = callbackWrapper(() => {
const selection = doc.getSelection();
if (!selection || (collapsed && selection?.isCollapsed)) return;
@@ -1012,7 +1098,7 @@ function initSelectionObserver(param: observerParam): listenerHandler {
}
selectionCb({ ranges });
};
});
updateSelection();
@@ -1148,7 +1234,7 @@ export function initObservers(
);
}
return () => {
return callbackWrapper(() => {
mutationBuffers.forEach((b) => b.reset());
mutationObserver.disconnect();
mousemoveHandler();
@@ -1163,7 +1249,7 @@ export function initObservers(
fontObserver();
selectionObserver();
pluginHandlers.forEach((h) => h());
};
});
}
type CSSGroupingProp =

View File

@@ -69,6 +69,7 @@ export type recordOptions<T> = {
// departed, please use sampling options
mousemoveWait?: number;
keepIframeSrcFn?: KeepIframeSrcFn;
errorHandler?: ErrorHandler;
};
export type observerParam = {
@@ -211,3 +212,5 @@ export type CrossOriginIframeMessageEventContent<T = eventWithTime> = {
};
export type CrossOriginIframeMessageEvent =
MessageEvent<CrossOriginIframeMessageEventContent>;
export type ErrorHandler = (error: unknown) => void | boolean;

View File

@@ -0,0 +1,468 @@
import * as fs from 'fs';
import * as path from 'path';
import type * as puppeteer from 'puppeteer';
import type { recordOptions } from '../../src/types';
import { listenerHandler, eventWithTime, EventType } from '@rrweb/types';
import { launchPuppeteer } from '../utils';
import {
callbackWrapper,
registerErrorHandler,
unregisterErrorHandler,
} from '../../src/record/error-handler';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
events: eventWithTime[];
}
interface IWindow extends Window {
rrweb: {
record: (
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
};
emit: (e: eventWithTime) => undefined;
}
const setup = function (
this: ISuite,
content: string,
canvasSample: 'all' | number = 'all',
): ISuite {
const ctx = {} as ISuite;
beforeAll(async () => {
ctx.browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.js');
ctx.code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
ctx.page = await ctx.browser.newPage();
await ctx.page.goto('about:blank');
await ctx.page.setContent(content);
await ctx.page.evaluate(ctx.code);
ctx.events = [];
await ctx.page.exposeFunction('emit', (e: eventWithTime) => {
if (e.type === EventType.DomContentLoaded || e.type === EventType.Load) {
return;
}
ctx.events.push(e);
});
ctx.page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
});
afterEach(async () => {
await ctx.page.close();
});
afterAll(async () => {
await ctx.browser.close();
});
return ctx;
};
describe('error-handler', function (this: ISuite) {
jest.setTimeout(100_000);
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<head>
<style>
body { background: red; }
</style>
</head>
<body>
<div id='in'></div>
<div id='out'></div>
</body>
</html>
`,
);
describe('CSSStyleSheet.prototype', () => {
it('triggers for errors from insertRule', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleSheet.prototype.insertRule = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
// @ts-ignore
document.styleSheets[0].insertRule('body { background: blue; }');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from deleteRule', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleSheet.prototype.deleteRule = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet delete
setTimeout(() => {
document.styleSheets[0].deleteRule(0);
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from replace', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleSheet.prototype.replace = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
// @ts-ignore
document.styleSheets[0].replace('body { background: blue; }');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from replaceSync', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleSheet.prototype.replaceSync = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
// @ts-ignore
document.styleSheets[0].replaceSync('body { background: blue; }');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from CSSGroupingRule.insertRule', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSGroupingRule.prototype.insertRule = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
document.styleSheets[0].insertRule('@media {}');
const atMediaRule = document.styleSheets[0]
.cssRules[0] as CSSMediaRule;
const ruleIdx0 = atMediaRule.insertRule(
'body { background: #000; }',
0,
);
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from CSSGroupingRule.deleteRule', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSGroupingRule.prototype.deleteRule = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet delete
setTimeout(() => {
document.styleSheets[0].insertRule('@media {}');
const atMediaRule = document.styleSheets[0]
.cssRules[0] as CSSMediaRule;
const ruleIdx0 = atMediaRule.deleteRule(0);
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from CSSStyleDeclaration.setProperty', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleDeclaration.prototype.setProperty = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
(
document.styleSheets[0].cssRules[0] as unknown as {
style: CSSStyleDeclaration;
}
).style.setProperty('background', 'blue');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
it('triggers for errors from CSSStyleDeclaration.removeProperty', async () => {
await ctx.page.evaluate(() => {
// @ts-ignore rewrite this to something buggy
window.CSSStyleDeclaration.prototype.removeProperty = function () {
// @ts-ignore
window.doSomethingWrong();
};
});
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy style sheet insert
setTimeout(() => {
(
document.styleSheets[0].cssRules[0] as unknown as {
style: CSSStyleDeclaration;
}
).style.removeProperty('background');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual(
'TypeError: window.doSomethingWrong is not a function',
);
});
});
it('triggers for errors from mutation observer', async () => {
await ctx.page.evaluate(() => {
const { record } = (window as unknown as IWindow).rrweb;
record({
errorHandler: (error) => {
document.getElementById('out')!.innerText = `${error}`;
},
emit: (window as unknown as IWindow).emit,
});
// Trigger buggy mutation observer
setTimeout(() => {
const el = document.getElementById('in')!;
// @ts-ignore we want to trigger an error in the mutation observer, which uses this
el.getAttribute = undefined;
el.setAttribute('data-attr', 'new');
}, 50);
});
await ctx.page.waitForTimeout(100);
const element = await ctx.page.$('#out');
const text = await element!.evaluate((el) => el.textContent);
expect(text).toEqual('TypeError: m.target.getAttribute is not a function');
});
});
describe('errorHandler unit', function () {
afterEach(function () {
unregisterErrorHandler();
});
it('does not swallow if no errorHandler set', () => {
unregisterErrorHandler();
const wrapped = callbackWrapper(() => {
throw new Error('test');
});
expect(() => wrapped()).toThrowError('test');
});
it('does not swallow if errorHandler returns void', () => {
registerErrorHandler(() => {
// do nothing
});
const wrapped = callbackWrapper(() => {
throw new Error('test');
});
expect(() => wrapped()).toThrowError('test');
});
it('does not swallow if errorHandler returns false', () => {
registerErrorHandler(() => {
return false;
});
const wrapped = callbackWrapper(() => {
throw new Error('test');
});
expect(() => wrapped()).toThrowError('test');
});
it('swallows if errorHandler returns true', () => {
registerErrorHandler(() => {
return true;
});
const wrapped = callbackWrapper(() => {
throw new Error('test');
});
expect(() => wrapped()).not.toThrowError('test');
});
});