* 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>
515 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|