Files
rrweb/packages/rrweb/test/record/cross-origin-iframes.test.ts
Yun Feng fc82869409 Fix cross origin iframe bugs (#1093)
* 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
2023-01-16 15:26:33 +08:00

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);
});
});