Files
rrweb/packages/rrweb/test/record/cross-origin-iframes.test.ts
Justin Halsall 2a80949948 Cross origin iframe support (#1035)
* Add `recordCrossOriginIframe` setting

* Set up messaging between iframes

* should emit full snapshot event from iframe as mutation event

* this.mirror was dropped on attachIframe

* should use unique id for child of iframe

* Cross origin iframe recording in `yarn live-stream`

* Root iframe check thats supported by firefox

* Live stream: Inject script in all frames

* Record same origin and cross origin iframes differently

* Should map Input events correctly

* Turn on other tests

* Fix compatibility with newer puppeteer

* puppeteer vs 12 seems stable without to many changes needed

* normalize port numbers in snapshots

* Handle scroll and ViewportResize events in cross origin iframe

* Correctly map cross origin mutations

* Map selection events for cross origin iframes

* Map canvas mutations for cross origin iframes

* Update snapshot to include canvas events

* Skip all meta events

* Support custom events as best we can in cross origin iframes

* Use earliest version of puppeteer that works with cross origin live-stream

* Map mouse/touch interaction events

* Update snapshots for correctly mapped click events

* Tweak tests for new puppeteer version

* Map MediaInteraction correctly for cross origin iframes

* Make tests consistent between high and low dpi devices

* Make test less flaky

* Make test less flaky

* Make test less flaky

* Make test less flaky

* Add support for styles in cross origin iframes

* Map traditional stylesheet mutations on cross origin iframes

* Add todo

* Add iframe mirror

* Get iframe manager to use iframe mirrors internally

* Rename `IframeMirror` to `CrossOriginIframeMirror`

* Setup basic cross origin canvas webrtc streaming

* Clean up removed canvas elements

* reset style mirror on new full snapshot

* Fix cross origin canvas webrtc streaming

* Make emit optional

* Run tests on github actions

* Upload image artifacts from failed tests

* Use newer github actions

* Test: hopefully adding more wait will fix it

* add extra wait

* Fix image snapshot tests

* Make tests run with new puppeteer version

* upgrade eslint-plugin-jest

* Chore: Remove travis ci as ci's running on github actions

* Chore: Support recording cross origin iframe in repl

* Force developers to update the cross origin iframe mapping when adding new events

https://github.com/rrweb-io/rrweb/pull/1035#discussion_r1012516277

* Document cross origin iframe recording

* Docs: cross origin iframes recording methods

* Docs: AI translated, cross origin iframe recording

* rename getParentId to getId

* Migrate to @rrweb/types

* Run on pull request

* doc: improve Chinese doc

* Rename `parentId` to `Id`

Co-authored-by: Mark-Fenng <f18846188605@gmail.com>
2022-11-16 13:11:11 +08:00

515 lines
15 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,
stripBase64,
waitForRAF,
} from '../utils';
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;
};
emit: (e: eventWithTime) => undefined;
snapshots: eventWithTime[];
}
async function injectRecordScript(frame: puppeteer.Frame) {
await frame.addScriptTag({
path: path.resolve(__dirname, '../../dist/rrweb.js'),
});
await frame.evaluate(() => {
((window as unknown) as IWindow).snapshots = [];
const { record } = ((window as unknown) as IWindow).rrweb;
record({
recordCrossOriginIframes: true,
recordCanvas: true,
emit(event) {
((window as unknown) as IWindow).snapshots.push(event);
((window as unknown) as IWindow).emit(event);
},
});
});
for (const child of frame.childFrames()) {
await injectRecordScript(child);
}
}
const setup = function (this: ISuite, content: string): ISuite {
const ctx = {} as ISuite;
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());
});
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('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);
});
});