* fix: error data while recording some websites are integrated with stripe These websites will usually have an iframe with src "https://js.stripe.com/v3/m-outer-xxx.html" * add test case for the bug * fix: recordCrossOriginIframes: true does not work with pack/unpack fn 1. bugfix 2. add test case 3. add rrweb-all.js to the result of command: bundle:browser 4. make puppeteer headless in CI by default to increase the speed and stability
589 lines
18 KiB
TypeScript
589 lines
18 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import type * as puppeteer from 'puppeteer';
|
|
import type { recordOptions } from '../../src/types';
|
|
import type {
|
|
listenerHandler,
|
|
eventWithTime,
|
|
mutationData,
|
|
} from '@rrweb/types';
|
|
import { EventType, IncrementalSource } from '@rrweb/types';
|
|
import {
|
|
assertSnapshot,
|
|
getServerURL,
|
|
launchPuppeteer,
|
|
startServer,
|
|
waitForRAF,
|
|
} from '../utils';
|
|
import { unpack } from '../../src/packer/unpack';
|
|
import type * as http from 'http';
|
|
|
|
interface ISuite {
|
|
code: string;
|
|
browser: puppeteer.Browser;
|
|
page: puppeteer.Page;
|
|
events: eventWithTime[];
|
|
server: http.Server;
|
|
serverURL: string;
|
|
}
|
|
|
|
interface IWindow extends Window {
|
|
rrweb: {
|
|
record: (
|
|
options: recordOptions<eventWithTime>,
|
|
) => listenerHandler | undefined;
|
|
addCustomEvent<T>(tag: string, payload: T): void;
|
|
pack: (e: eventWithTime) => string;
|
|
};
|
|
emit: (e: eventWithTime) => undefined;
|
|
snapshots: eventWithTime[];
|
|
}
|
|
type ExtraOptions = {
|
|
usePackFn?: boolean;
|
|
};
|
|
|
|
async function injectRecordScript(
|
|
frame: puppeteer.Frame,
|
|
options?: ExtraOptions,
|
|
) {
|
|
await frame.addScriptTag({
|
|
path: path.resolve(__dirname, '../../dist/rrweb-all.js'),
|
|
});
|
|
options = options || {};
|
|
await frame.evaluate((options) => {
|
|
((window as unknown) as IWindow).snapshots = [];
|
|
const { record, pack } = ((window as unknown) as IWindow).rrweb;
|
|
const config: recordOptions<eventWithTime> = {
|
|
recordCrossOriginIframes: true,
|
|
recordCanvas: true,
|
|
emit(event) {
|
|
((window as unknown) as IWindow).snapshots.push(event);
|
|
((window as unknown) as IWindow).emit(event);
|
|
},
|
|
};
|
|
if (options.usePackFn) {
|
|
config.packFn = pack;
|
|
}
|
|
record(config);
|
|
}, options);
|
|
|
|
for (const child of frame.childFrames()) {
|
|
await injectRecordScript(child, options);
|
|
}
|
|
}
|
|
|
|
const setup = function (
|
|
this: ISuite,
|
|
content: string,
|
|
options?: ExtraOptions,
|
|
): ISuite {
|
|
const ctx = {} as ISuite & {
|
|
serverB: http.Server;
|
|
serverBURL: string;
|
|
};
|
|
|
|
beforeAll(async () => {
|
|
ctx.browser = await launchPuppeteer();
|
|
ctx.server = await startServer();
|
|
ctx.serverURL = getServerURL(ctx.server);
|
|
ctx.serverB = await startServer();
|
|
ctx.serverBURL = getServerURL(ctx.serverB);
|
|
|
|
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.replace(/\{SERVER_URL\}/g, ctx.serverURL),
|
|
);
|
|
// 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()));
|
|
await injectRecordScript(ctx.page.mainFrame(), options);
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await ctx.page.close();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await ctx.browser.close();
|
|
ctx.server.close();
|
|
ctx.serverB.close();
|
|
});
|
|
|
|
return ctx;
|
|
};
|
|
|
|
describe('cross origin iframes', function (this: ISuite) {
|
|
jest.setTimeout(100_000);
|
|
|
|
describe('form.html', function (this: ISuite) {
|
|
const ctx: ISuite = setup.call(
|
|
this,
|
|
`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<iframe src="{SERVER_URL}/html/form.html" style="width: 400px; height: 400px;"></iframe>
|
|
</body>
|
|
</html>
|
|
`,
|
|
);
|
|
|
|
it("won't emit events if it isn't in the top level iframe", async () => {
|
|
const el = (await ctx.page.$(
|
|
'body > iframe',
|
|
)) as puppeteer.ElementHandle<Element>;
|
|
|
|
const frame = await el.contentFrame();
|
|
const events = await frame?.evaluate(
|
|
() => ((window as unknown) as IWindow).snapshots,
|
|
);
|
|
expect(events).toMatchObject([]);
|
|
});
|
|
|
|
it('will emit events if it is in the top level iframe', async () => {
|
|
const events = await ctx.page.evaluate(
|
|
() => ((window as unknown) as IWindow).snapshots,
|
|
);
|
|
expect(events.length).not.toBe(0);
|
|
});
|
|
|
|
it('should emit contents of iframe', async () => {
|
|
const events = await ctx.page.evaluate(
|
|
() => ((window as unknown) as IWindow).snapshots,
|
|
);
|
|
await waitForRAF(ctx.page);
|
|
// two events (full snapshot + meta) from main frame, and one full snapshot from iframe
|
|
expect(events.length).toBe(3);
|
|
});
|
|
|
|
it('should emit full snapshot event from iframe as mutation event', async () => {
|
|
const events = await ctx.page.evaluate(
|
|
() => ((window as unknown) as IWindow).snapshots,
|
|
);
|
|
await waitForRAF(ctx.page);
|
|
// two events from main frame, and two from iframe
|
|
expect(events[events.length - 1]).toMatchObject({
|
|
type: EventType.IncrementalSnapshot,
|
|
data: {
|
|
source: IncrementalSource.Mutation,
|
|
adds: [
|
|
{
|
|
parentId: expect.any(Number),
|
|
node: {
|
|
id: expect.any(Number),
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should use unique id for child of iframes', async () => {
|
|
const events: eventWithTime[] = await ctx.page.evaluate(
|
|
() => ((window as unknown) as IWindow).snapshots,
|
|
);
|
|
await waitForRAF(ctx.page);
|
|
expect(
|
|
(events[events.length - 1].data as mutationData).adds[0].node.id,
|
|
).not.toBe(1);
|
|
});
|
|
|
|
it('should replace the existing DOM nodes on iframe navigation with `isAttachIframe`', async () => {
|
|
await ctx.page.evaluate((url) => {
|
|
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
|
|
iframe.src = `${url}/html/form.html?2`;
|
|
}, ctx.serverURL);
|
|
await waitForRAF(ctx.page); // loads iframe
|
|
|
|
await injectRecordScript(ctx.page.mainFrame().childFrames()[0]); // injects script into new iframe
|
|
|
|
const events: eventWithTime[] = await ctx.page.evaluate(
|
|
() => ((window as unknown) as IWindow).snapshots,
|
|
);
|
|
expect(
|
|
(events[events.length - 1].data as mutationData).removes,
|
|
).toMatchObject([]);
|
|
expect(
|
|
(events[events.length - 1].data as mutationData).isAttachIframe,
|
|
).toBeTruthy();
|
|
});
|
|
|
|
it('should map input events correctly', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await frame.type('input[type="text"]', 'test');
|
|
await frame.click('input[type="radio"]');
|
|
await frame.click('input[type="checkbox"]');
|
|
await frame.type('input[type="password"]', 'password');
|
|
await frame.type('textarea', 'textarea test');
|
|
await frame.select('select', '1');
|
|
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
it('should map scroll events correctly', async () => {
|
|
// force scrollbars in iframe
|
|
ctx.page.evaluate(() => {
|
|
const iframe = document.querySelector('iframe') as HTMLIFrameElement;
|
|
iframe.style.width = '50px';
|
|
iframe.style.height = '50px';
|
|
});
|
|
|
|
await waitForRAF(ctx.page);
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
|
|
// scroll a little
|
|
frame.evaluate(() => {
|
|
window.scrollTo(0, 10);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
});
|
|
|
|
describe('move-node.html', function (this: ISuite) {
|
|
const ctx: ISuite = setup.call(
|
|
this,
|
|
`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<iframe src="{SERVER_URL}/html/move-node.html"></iframe>
|
|
</body>
|
|
</html>
|
|
`,
|
|
);
|
|
|
|
it('should record DOM node movement', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await frame.evaluate(() => {
|
|
const div = document.createElement('div');
|
|
const span = document.querySelector('span')!;
|
|
document.body.appendChild(div);
|
|
div.appendChild(span);
|
|
});
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
it('should record DOM node removal', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await frame.evaluate(() => {
|
|
const span = document.querySelector('span')!;
|
|
span.remove();
|
|
});
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
it('should record DOM attribute changes', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await frame.evaluate(() => {
|
|
const span = document.querySelector('span')!;
|
|
span.className = 'added-class-name';
|
|
});
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
it('should record DOM text changes', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await frame.evaluate(() => {
|
|
const b = document.querySelector('b')!;
|
|
b.childNodes[0].textContent = 'replaced text';
|
|
});
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
it('should record canvas elements', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await frame.evaluate(() => {
|
|
var canvas = document.createElement('canvas');
|
|
var gl = canvas.getContext('webgl')!;
|
|
var program = gl.createProgram()!;
|
|
gl.linkProgram(program);
|
|
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
document.body.appendChild(canvas);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
it('should record custom events', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await frame.evaluate(() => {
|
|
((window as unknown) as IWindow).rrweb.addCustomEvent('test', {
|
|
id: 1,
|
|
parentId: 1,
|
|
nextId: 2,
|
|
});
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
it('captures mutations on adopted stylesheets', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await ctx.page.evaluate(() => {
|
|
const sheet = new CSSStyleSheet();
|
|
// Add stylesheet to a document.
|
|
document.adoptedStyleSheets = [sheet];
|
|
});
|
|
await frame.evaluate(() => {
|
|
const sheet = new CSSStyleSheet();
|
|
// Add stylesheet to a document.
|
|
document.adoptedStyleSheets = [sheet];
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
document.adoptedStyleSheets![0].replace!('div { color: yellow; }');
|
|
});
|
|
await frame.evaluate(() => {
|
|
document.adoptedStyleSheets![0].replace!('h1 { color: blue; }');
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
document.adoptedStyleSheets![0].replaceSync!(
|
|
'div { display: inline ; }',
|
|
);
|
|
});
|
|
await frame.evaluate(() => {
|
|
document.adoptedStyleSheets![0].replaceSync!(
|
|
'h1 { font-size: large; }',
|
|
);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
(document.adoptedStyleSheets![0]
|
|
.cssRules[0] as CSSStyleRule).style.setProperty('color', 'green');
|
|
(document.adoptedStyleSheets![0]
|
|
.cssRules[0] as CSSStyleRule).style.removeProperty('display');
|
|
});
|
|
await frame.evaluate(() => {
|
|
(document.adoptedStyleSheets![0]
|
|
.cssRules[0] as CSSStyleRule).style.setProperty(
|
|
'font-size',
|
|
'medium',
|
|
'important',
|
|
);
|
|
document.adoptedStyleSheets![0].insertRule('h2 { color: red; }');
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
document.adoptedStyleSheets![0].insertRule(
|
|
'body { border: 2px solid blue; }',
|
|
1,
|
|
);
|
|
});
|
|
await frame.evaluate(() => {
|
|
document.adoptedStyleSheets![0].deleteRule(0);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
it('captures mutations on stylesheets', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await ctx.page.evaluate(() => {
|
|
// Add stylesheet to a document.
|
|
const style = document.createElement('style');
|
|
document.head.appendChild(style);
|
|
});
|
|
await frame.evaluate(() => {
|
|
// Add stylesheet to a document.
|
|
const style = document.createElement('style');
|
|
document.head.appendChild(style);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
document.styleSheets[0].insertRule('div { color: yellow; }');
|
|
});
|
|
await frame.evaluate(() => {
|
|
document.styleSheets[0].insertRule('h1 { color: blue; }');
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
(document.styleSheets[0].cssRules[0] as CSSStyleRule).style.setProperty(
|
|
'color',
|
|
'green',
|
|
);
|
|
(document.styleSheets[0]
|
|
.cssRules[0] as CSSStyleRule).style.removeProperty('display');
|
|
});
|
|
await frame.evaluate(() => {
|
|
(document.styleSheets[0].cssRules[0] as CSSStyleRule).style.setProperty(
|
|
'font-size',
|
|
'medium',
|
|
'important',
|
|
);
|
|
document.styleSheets[0].insertRule('h2 { color: red; }');
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
await ctx.page.evaluate(() => {
|
|
document.styleSheets[0].insertRule(
|
|
'body { border: 2px solid blue; }',
|
|
1,
|
|
);
|
|
});
|
|
await frame.evaluate(() => {
|
|
document.styleSheets[0].deleteRule(0);
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
});
|
|
|
|
describe('audio.html', function (this: ISuite) {
|
|
jest.setTimeout(100_000);
|
|
|
|
const ctx: ISuite = setup.call(
|
|
this,
|
|
`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<iframe src="{SERVER_URL}/html/audio.html"></iframe>
|
|
</body>
|
|
</html>
|
|
`,
|
|
);
|
|
|
|
it('should emit contents of iframe once', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await frame.evaluate(() => {
|
|
const el = document.querySelector('audio')!;
|
|
el.play();
|
|
});
|
|
await waitForRAF(ctx.page);
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
});
|
|
|
|
describe('blank.html', function (this: ISuite) {
|
|
const content = `
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<iframe src="{SERVER_URL}/html/blank.html"></iframe>
|
|
</body>
|
|
</html>
|
|
`;
|
|
const ctx = setup.call(this, content) as ISuite & {
|
|
serverBURL: string;
|
|
};
|
|
|
|
it('should filter out forwarded cross origin rrweb messages', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
const iframe2URL = `${ctx.serverBURL}/html/blank.html`;
|
|
await frame.evaluate((iframe2URL) => {
|
|
// Add a message proxy to forward messages from child frames to its parent frame.
|
|
window.addEventListener('message', (event) => {
|
|
if (event.source !== window)
|
|
window.parent.postMessage(event.data, '*');
|
|
});
|
|
const iframe2 = document.createElement('iframe');
|
|
iframe2.src = iframe2URL;
|
|
document.body.appendChild(iframe2);
|
|
}, iframe2URL);
|
|
|
|
// Wait for iframe2 to load
|
|
await ctx.page.waitForFrame(iframe2URL);
|
|
// Record iframe2
|
|
await injectRecordScript(frame.childFrames()[0]);
|
|
|
|
await waitForRAF(frame.childFrames()[0]);
|
|
const snapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as eventWithTime[];
|
|
assertSnapshot(snapshots);
|
|
});
|
|
|
|
describe('should support packFn option in record()', () => {
|
|
const ctx = setup.call(this, content, { usePackFn: true });
|
|
it('', async () => {
|
|
const frame = ctx.page.mainFrame().childFrames()[0];
|
|
await waitForRAF(frame);
|
|
const packedSnapshots = (await ctx.page.evaluate(
|
|
'window.snapshots',
|
|
)) as string[];
|
|
const unpackedSnapshots = packedSnapshots.map((packed) =>
|
|
unpack(packed),
|
|
) as eventWithTime[];
|
|
assertSnapshot(unpackedSnapshots);
|
|
});
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('same origin iframes', function (this: ISuite) {
|
|
jest.setTimeout(100_000);
|
|
|
|
const ctx: ISuite = setup.call(
|
|
this,
|
|
`
|
|
<!DOCTYPE html>
|
|
<html>
|
|
<body>
|
|
<iframe src="about:blank"></iframe>
|
|
</body>
|
|
</html>
|
|
`,
|
|
);
|
|
|
|
it('should emit contents of iframe once', async () => {
|
|
const events = await ctx.page.evaluate(
|
|
() => ((window as unknown) as IWindow).snapshots,
|
|
);
|
|
await waitForRAF(ctx.page);
|
|
// two events (full snapshot + meta) from main frame,
|
|
// and two (full snapshot + mutation) from iframe
|
|
expect(events.length).toBe(4);
|
|
assertSnapshot(events);
|
|
});
|
|
});
|