Canvas recording: Preserve drawing buffer (#1273)

* Upgrade jest to 29 and puppeteer to 16 in rrweb

* Apply formatting changes

* Upgrade rrweb's puppeteer to v20

* Apply formatting changes

* Canvas: Reduce flickering and capturing of empty canvas elements

Turn on `preserveDrawingBuffer` by default for canvas FPS recording.
Has some negative performance implications, but really helps when capturing canvas.

* Apply formatting changes

* Include all test image snapshots in ci

* Apply formatting changes

* Allow more flexibility when capturing hover

* Apply formatting changes

* Create tiny-chairs-build.md

* Apply formatting changes

* Update hover.test.ts

* Apply formatting changes

* Document snapshotFormat jest config

* Freeze `yarn.lock` in ci for reproducible dependencies

* Apply formatting changes

* Apply formatting changes

* Revert to old style of puppeteer evaluation script notation

* Apply formatting changes

* Make test less flaky

* Apply formatting changes

* Apply formatting changes

* Make tests less flaky

* Apply formatting changes

* Make test more robust

* Apply formatting changes

* Apply formatting changes

* Add debugging code for test

* Apply formatting changes

* Also test not ignored input

* Apply formatting changes

* Apply formatting changes

* Apply formatting changes

* escape ignoreSelector

* Apply formatting changes

* Apply formatting changes
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent d4ce14af98
commit 7d1a278688
20 changed files with 2103 additions and 201 deletions

View File

@@ -0,0 +1,7 @@
---
'rrweb': patch
---
Canvas FPS recording: override `preserveDrawingBuffer: true` on canvas creation.
Canvas replay: fix flickering canvas elemenrs.
Canvas FPS recording: fix bug that wipes webgl(2) canvas backgrounds while recording.

View File

@@ -21,7 +21,7 @@ jobs:
node-version: lts/*
- name: Install Dependencies
run: yarn
run: yarn install --frozen-lockfile
- name: Build Project
run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all
@@ -37,5 +37,5 @@ jobs:
if: failure()
with:
name: image-diff
path: packages/rrweb/test/e2e/__image_snapshots__/__diff_output__/*.png
path: packages/rrweb/test/*/__image_snapshots__/__diff_output__/*.png
if-no-files-found: ignore

View File

@@ -21,7 +21,7 @@ jobs:
node-version: lts/*
- name: Install Dependencies
run: yarn
run: yarn install --frozen-lockfile
- name: Create Release Pull Request or Publish to npm
id: changesets
@@ -29,7 +29,7 @@ jobs:
with:
publish: yarn run release
env:
NODE_OPTIONS: "--max-old-space-size=4096"
NODE_OPTIONS: '--max-old-space-size=4096'
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -18,7 +18,7 @@ jobs:
node-version: 16
cache: 'yarn'
- name: Install Dependencies
run: yarn
run: yarn install --frozen-lockfile
- name: Build Packages
run: NODE_OPTIONS='--max-old-space-size=4096' yarn build:all
- name: Eslint Check

View File

@@ -6,4 +6,13 @@ export default {
moduleNameMapper: {
'\\.css$': 'identity-obj-proxy',
},
/**
* Keeps old (pre-jest 29) snapshot format
* its a bit ugly and harder to read than the new format,
* so we might want to remove this in its own PR
*/
snapshotFormat: {
escapeString: true,
printBasicPrototype: true,
},
};

View File

@@ -53,11 +53,10 @@
"@types/chai": "^4.1.6",
"@types/dom-mediacapture-transform": "^0.1.3",
"@types/inquirer": "^8.2.1",
"@types/jest": "^27.4.1",
"@types/jest-image-snapshot": "^5.1.0",
"@types/jest": "^29.5.0",
"@types/jest-image-snapshot": "^6.1.0",
"@types/node": "^18.15.11",
"@types/offscreencanvas": "^2019.6.4",
"@types/puppeteer": "^5.4.4",
"construct-style-sheets-polyfill": "^3.1.0",
"cross-env": "^5.2.0",
"esbuild": "^0.14.38",
@@ -65,10 +64,11 @@
"identity-obj-proxy": "^3.0.0",
"ignore-styles": "^5.0.1",
"inquirer": "^9.0.0",
"jest": "^27.5.1",
"jest-image-snapshot": "^5.2.0",
"jest-snapshot": "^23.6.0",
"puppeteer": "^11.0.0",
"jest": "^29.6.0",
"jest-environment-jsdom": "^29.6.0",
"jest-image-snapshot": "^6.2.0",
"jest-snapshot": "^29.6.2",
"puppeteer": "^20.9.0",
"rollup": "^2.68.0",
"rollup-plugin-esbuild": "^4.9.1",
"rollup-plugin-postcss": "^3.1.1",
@@ -76,7 +76,7 @@
"rollup-plugin-typescript2": "^0.31.2",
"rollup-plugin-web-worker-loader": "^1.6.1",
"simple-peer-light": "^9.10.0",
"ts-jest": "^27.1.3",
"ts-jest": "^29.1.1",
"ts-node": "^10.9.1",
"tslib": "^2.3.1"
},

View File

@@ -114,6 +114,7 @@ export class CanvasManager {
win,
blockClass,
blockSelector,
true,
);
const snapshotInProgressMap: Map<number, boolean> = new Map();
const worker =
@@ -198,9 +199,12 @@ export class CanvasManager {
) {
// Hack to load canvas back into memory so `createImageBitmap` can grab it's contents.
// Context: https://twitter.com/Juice10/status/1499775271758704643
// This hack might change the background color of the canvas in the unlikely event that
// Preferably we set `preserveDrawingBuffer` to true, but that's not always possible,
// especially when canvas is loaded before rrweb.
// This hack can wipe the background color of the canvas in the (unlikely) event that
// the canvas background was changed but clear was not called directly afterwards.
context?.clear(context.COLOR_BUFFER_BIT);
// Example of this hack having negative side effect: https://visgl.github.io/react-map-gl/examples/layers
context.clear(context.COLOR_BUFFER_BIT);
}
}
const bitmap = await createImageBitmap(canvas);
@@ -238,6 +242,7 @@ export class CanvasManager {
win,
blockClass,
blockSelector,
false,
);
const canvas2DReset = initCanvas2DMutationObserver(
this.processMutation.bind(this),

View File

@@ -2,10 +2,15 @@ import type { ICanvas } from 'rrweb-snapshot';
import type { blockClass, IWindow, listenerHandler } from '@rrweb/types';
import { isBlocked, patch } from '../../../utils';
function getNormalizedContextName(contextType: string) {
return contextType === 'experimental-webgl' ? 'webgl' : contextType;
}
export default function initCanvasContextObserver(
win: IWindow,
blockClass: blockClass,
blockSelector: string | null,
setPreserveDrawingBufferToTrue: boolean,
): listenerHandler {
const handlers: listenerHandler[] = [];
try {
@@ -25,7 +30,24 @@ export default function initCanvasContextObserver(
...args: Array<unknown>
) {
if (!isBlocked(this, blockClass, blockSelector, true)) {
if (!('__context' in this)) this.__context = contextType;
const ctxName = getNormalizedContextName(contextType);
if (!('__context' in this)) this.__context = ctxName;
if (
setPreserveDrawingBufferToTrue &&
['webgl', 'webgl2'].includes(ctxName)
) {
if (args[0] && typeof args[0] === 'object') {
const contextAttributes = args[0] as WebGLContextAttributes;
if (!contextAttributes.preserveDrawingBuffer) {
contextAttributes.preserveDrawingBuffer = true;
}
} else {
args.splice(0, 1, {
preserveDrawingBuffer: true,
});
}
}
}
return original.apply(this, [contextType, ...args]);
};

View File

@@ -4,48 +4,63 @@ import { deserializeArg } from './deserialize-args';
export default async function canvasMutation({
event,
mutation,
mutations,
target,
imageMap,
errorHandler,
}: {
event: Parameters<Replayer['applyIncremental']>[0];
mutation: canvasMutationCommand;
mutations: canvasMutationCommand[];
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): Promise<void> {
try {
const ctx = target.getContext('2d')!;
const ctx = target.getContext('2d');
if (mutation.setter) {
// skip some read-only type checks
(ctx as unknown as Record<string, unknown>)[mutation.property] =
mutation.args[0];
return;
}
const original = ctx[
mutation.property as Exclude<keyof typeof ctx, 'canvas'>
] as (ctx: CanvasRenderingContext2D, args: unknown[]) => void;
/**
* We have serialized the image source into base64 string during recording,
* which has been preloaded before replay.
* So we can get call drawImage SYNCHRONOUSLY which avoid some fragile cast.
*/
if (
mutation.property === 'drawImage' &&
typeof mutation.args[0] === 'string'
) {
imageMap.get(event);
original.apply(ctx, mutation.args);
} else {
const args = await Promise.all(
mutation.args.map(deserializeArg(imageMap, ctx)),
);
original.apply(ctx, args);
}
} catch (error) {
errorHandler(mutation, error);
if (!ctx) {
errorHandler(mutations[0], new Error('Canvas context is null'));
return;
}
// step 1, deserialize args, they may be async
const mutationArgsPromises = mutations.map(
async (mutation: canvasMutationCommand): Promise<unknown[]> => {
return Promise.all(mutation.args.map(deserializeArg(imageMap, ctx)));
},
);
const args = await Promise.all(mutationArgsPromises);
// step 2 apply all mutations
args.forEach((args, index) => {
const mutation = mutations[index];
try {
if (mutation.setter) {
// skip some read-only type checks
(ctx as unknown as Record<string, unknown>)[mutation.property] =
mutation.args[0];
return;
}
const original = ctx[
mutation.property as Exclude<keyof typeof ctx, 'canvas'>
] as (ctx: CanvasRenderingContext2D, args: unknown[]) => void;
/**
* We have serialized the image source into base64 string during recording,
* which has been preloaded before replay.
* So we can get call drawImage SYNCHRONOUSLY which avoid some fragile cast.
*/
if (
mutation.property === 'drawImage' &&
typeof mutation.args[0] === 'string'
) {
imageMap.get(event);
original.apply(ctx, mutation.args);
} else {
original.apply(ctx, args);
}
} catch (error) {
errorHandler(mutation, error);
}
return;
});
}

View File

@@ -46,16 +46,13 @@ export default async function canvasMutation({
return;
}
// default is '2d' for backwards compatibility (rrweb below 1.1.x)
for (let i = 0; i < commands.length; i++) {
const command = commands[i];
await canvas2DMutation({
event,
mutation: command,
target,
imageMap,
errorHandler,
});
}
await canvas2DMutation({
event,
mutations: commands,
target,
imageMap,
errorHandler,
});
} catch (error) {
errorHandler(mutation, error);
}

View File

@@ -8712,13 +8712,11 @@ exports[`record integration tests should not record input events on ignored elem
{
\\"type\\": 2,
\\"tagName\\": \\"label\\",
\\"attributes\\": {
\\"for\\": \\"ignore text\\"
},
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\" \\",
\\"textContent\\": \\"Input ignored here: \\",
\\"id\\": 21
},
{
@@ -8733,7 +8731,7 @@ exports[`record integration tests should not record input events on ignored elem
},
{
\\"type\\": 3,
\\"textContent\\": \\" \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 23
}
],
@@ -8741,8 +8739,74 @@ exports[`record integration tests should not record input events on ignored elem
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 24
},
{
\\"type\\": 2,
\\"tagName\\": \\"label\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Input ignored by selector here: \\",
\\"id\\": 26
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"data-rr-ignore\\": \\"\\"
},
\\"childNodes\\": [],
\\"id\\": 27
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 28
}
],
\\"id\\": 25
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 29
},
{
\\"type\\": 2,
\\"tagName\\": \\"label\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"Input not ignored here: \\",
\\"id\\": 31
},
{
\\"type\\": 2,
\\"tagName\\": \\"input\\",
\\"attributes\\": {
\\"type\\": \\"text\\",
\\"class\\": \\"dont-ignore\\"
},
\\"childNodes\\": [],
\\"id\\": 32
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 33
}
],
\\"id\\": 30
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 34
}
],
\\"id\\": 18
@@ -8750,7 +8814,7 @@ exports[`record integration tests should not record input events on ignored elem
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 25
\\"id\\": 35
},
{
\\"type\\": 2,
@@ -8760,15 +8824,15 @@ exports[`record integration tests should not record input events on ignored elem
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 27
\\"id\\": 37
}
],
\\"id\\": 26
\\"id\\": 36
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 28
\\"id\\": 38
}
],
\\"id\\": 16
@@ -8792,6 +8856,128 @@ exports[`record integration tests should not record input events on ignored elem
\\"type\\": 5,
\\"id\\": 22
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 6,
\\"id\\": 22
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 27
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 6,
\\"id\\": 27
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 2,
\\"type\\": 5,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"n\\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"no\\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"not\\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"not \\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"not s\\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"not se\\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"not sec\\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"not secr\\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"not secre\\",
\\"isChecked\\": false,
\\"id\\": 32
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 5,
\\"text\\": \\"not secret\\",
\\"isChecked\\": false,
\\"id\\": 32
}
}
]"
`;

View File

@@ -28,6 +28,7 @@
ctx.moveTo(0, 0);
ctx.lineTo(200, 100);
ctx.stroke();
window.canvasMutationApplied = true;
}, 10);
</script>
</body>

View File

@@ -9,8 +9,15 @@
<body>
<form>
<label for="ignore text"> <input type="text" class="rr-ignore" /> </label>
<label for="ignore selector"> <input type="text" data-rr-ignore /> </label>
<label
>Input ignored here: <input type="text" class="rr-ignore" />
</label>
<label
>Input ignored by selector here: <input type="text" data-rr-ignore />
</label>
<label
>Input not ignored here: <input type="text" class="dont-ignore" />
</label>
</form>
</body>
</html>

View File

@@ -7,6 +7,7 @@ import {
getServerURL,
launchPuppeteer,
waitForRAF,
waitForIFrameLoad,
replaceLast,
generateRecordSnippet,
ISuite,
@@ -71,7 +72,7 @@ describe('record integration tests', function (this: ISuite) {
// also tap on the span
const span = await page.waitForSelector('span');
const center = await page.evaluate((el) => {
const { x, y, width, height } = el.getBoundingClientRect();
const { x, y, width, height } = el!.getBoundingClientRect();
return {
x: Math.round(x + width / 2),
y: Math.round(y + height / 2),
@@ -81,7 +82,9 @@ describe('record integration tests', function (this: ISuite) {
await page.click('a');
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -186,7 +189,9 @@ describe('record integration tests', function (this: ISuite) {
li.removeAttribute('aria-label');
});
const snapshots = await page.evaluate('window.snapshots');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
});
@@ -306,11 +311,9 @@ describe('record integration tests', function (this: ISuite) {
await page.type('.rr-ignore', 'secret');
await page.type('[data-rr-ignore]', 'secret');
await page.type('.dont-ignore', 'not secret');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
await assertSnapshot(page);
});
it('should not record input values if maskAllInputs is enabled', async () => {
@@ -547,6 +550,7 @@ describe('record integration tests', function (this: ISuite) {
recordCanvas: true,
}),
);
await page.waitForFunction('window.canvasMutationApplied');
await waitForRAF(page);
const snapshots = (await page.evaluate(
'window.snapshots',
@@ -581,10 +585,7 @@ describe('record integration tests', function (this: ISuite) {
await page.type('#input', 'moo');
const snapshots = (await page.evaluate(
'window.snapshots',
)) as eventWithTime[];
assertSnapshot(snapshots);
await assertSnapshot(page);
});
it('should record webgl canvas mutations', async () => {
@@ -757,13 +758,9 @@ describe('record integration tests', function (this: ISuite) {
await page.goto(`${serverURL}/html`);
await page.setContent(getHtml.call(this, 'main.html'));
await page.waitForSelector('#two');
const frameIdTwo = await page.frames()[2];
await frameIdTwo.waitForSelector('#four');
const frameIdFour = frameIdTwo.childFrames()[1];
await frameIdFour.waitForSelector('#five');
await page.waitForTimeout(50);
const frameIdTwo = await waitForIFrameLoad(page, '#two');
const frameIdFour = await waitForIFrameLoad(frameIdTwo, '#four');
await waitForIFrameLoad(frameIdFour, '#five');
const snapshots = (await page.evaluate(
'window.snapshots',

View File

@@ -37,7 +37,7 @@ exports[`cross origin iframes audio.html should emit contents of iframe once 1`]
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -202,7 +202,7 @@ exports[`cross origin iframes audio.html should emit contents of iframe once 1`]
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -370,7 +370,7 @@ exports[`cross origin iframes blank.html should filter out forwarded cross origi
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -448,7 +448,7 @@ exports[`cross origin iframes blank.html should filter out forwarded cross origi
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -544,7 +544,7 @@ exports[`cross origin iframes blank.html should filter out forwarded cross origi
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -632,7 +632,7 @@ exports[`cross origin iframes blank.html should record same-origin iframe in cro
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -710,7 +710,7 @@ exports[`cross origin iframes blank.html should record same-origin iframe in cro
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -877,7 +877,7 @@ exports[`cross origin iframes blank.html should support packFn option in record(
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -956,7 +956,7 @@ exports[`cross origin iframes blank.html should support packFn option in record(
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -1045,7 +1045,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] =
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -1211,7 +1211,7 @@ exports[`cross origin iframes form.html should map input events correctly 1`] =
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -2042,7 +2042,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] =
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -2208,7 +2208,7 @@ exports[`cross origin iframes form.html should map scroll events correctly 1`] =
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -2682,7 +2682,7 @@ exports[`cross origin iframes move-node.html captures mutations on adopted style
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -2768,7 +2768,7 @@ exports[`cross origin iframes move-node.html captures mutations on adopted style
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -3096,7 +3096,7 @@ exports[`cross origin iframes move-node.html captures mutations on stylesheets 1
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -3182,7 +3182,7 @@ exports[`cross origin iframes move-node.html captures mutations on stylesheets 1
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -3515,7 +3515,7 @@ exports[`cross origin iframes move-node.html should record DOM attribute changes
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -3601,7 +3601,7 @@ exports[`cross origin iframes move-node.html should record DOM attribute changes
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -3803,7 +3803,7 @@ exports[`cross origin iframes move-node.html should record DOM node movement 1`]
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -3889,7 +3889,7 @@ exports[`cross origin iframes move-node.html should record DOM node movement 1`]
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -4188,7 +4188,7 @@ exports[`cross origin iframes move-node.html should record DOM node removal 1`]
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -4274,7 +4274,7 @@ exports[`cross origin iframes move-node.html should record DOM node removal 1`]
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -4474,7 +4474,7 @@ exports[`cross origin iframes move-node.html should record DOM text changes 1`]
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -4560,7 +4560,7 @@ exports[`cross origin iframes move-node.html should record DOM text changes 1`]
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -4760,7 +4760,7 @@ exports[`cross origin iframes move-node.html should record canvas elements 1`] =
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -4846,7 +4846,7 @@ exports[`cross origin iframes move-node.html should record canvas elements 1`] =
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -5083,7 +5083,7 @@ exports[`cross origin iframes move-node.html should record custom events 1`] = `
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -5169,7 +5169,7 @@ exports[`cross origin iframes move-node.html should record custom events 1`] = `
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -5365,7 +5365,7 @@ exports[`same origin iframes should emit contents of iframe once 1`] = `
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [
{
@@ -5479,7 +5479,7 @@ exports[`same origin iframes should emit contents of iframe once 1`] = `
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {
\\"type\\": \\"\\"
\\"type\\": \\"text/javascript\\"
},
\\"childNodes\\": [],
\\"rootId\\": 11,

View File

@@ -0,0 +1,81 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import canvas2DMutation from '../../src/replay/canvas/2d';
import type { Replayer } from '../../src/replay';
let canvas: HTMLCanvasElement;
describe('canvas2DMutation', () => {
beforeEach(() => {
jest.useFakeTimers();
canvas = document.createElement('canvas');
});
afterEach(() => {
jest.clearAllMocks();
jest.useRealTimers();
});
it('should execute all mutations after args are parsed', async () => {
let resolve: (value: unknown) => void;
const promise = new Promise((r) => {
resolve = r;
});
const context = {
clearRect: jest.fn(),
drawImage: jest.fn(),
} as unknown as CanvasRenderingContext2D;
jest.spyOn(canvas, 'getContext').mockImplementation(() => {
return context;
});
const createImageBitmapMock = jest.fn(() => {
return new Promise((r) => {
setTimeout(r, 1000);
});
});
(global as any).createImageBitmap = createImageBitmapMock;
const mutation = canvas2DMutation({
event: {} as Parameters<Replayer['applyIncremental']>[0],
mutations: [
{
property: 'clearRect',
args: [0, 0, 1000, 1000],
},
{
property: 'drawImage',
args: [
{
rr_type: 'ImageBitmap',
args: [],
},
0,
0,
],
},
],
target: canvas,
imageMap: new Map(),
errorHandler: () => {},
});
await jest.advanceTimersByTimeAsync(100);
await expect(createImageBitmapMock).toHaveBeenCalled();
expect(context.clearRect).not.toBeCalled();
expect(context.drawImage).not.toBeCalled();
await jest.advanceTimersByTimeAsync(1000);
await mutation;
expect(context.clearRect).toHaveBeenCalledWith(0, 0, 1000, 1000);
expect(context.drawImage).toHaveBeenCalled();
});
});

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -65,7 +65,9 @@ describe('replayer', function () {
await waitForRAF(page);
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
expect(image).toMatchImageSnapshot({
failureThreshold: 40,
});
});
});
});

View File

@@ -7,6 +7,7 @@ import {
Optional,
mouseInteractionData,
event,
pluginEvent,
} from '@rrweb/types';
import type { recordOptions } from '../src/types';
import * as puppeteer from 'puppeteer';
@@ -20,7 +21,7 @@ export async function launchPuppeteer(
options?: Parameters<(typeof puppeteer)['launch']>[0],
) {
return await puppeteer.launch({
headless: process.env.PUPPETEER_HEADLESS ? true : false,
headless: process.env.PUPPETEER_HEADLESS ? 'new' : false,
defaultViewport: {
width: 1920,
height: 1080,
@@ -108,7 +109,8 @@ function stringifySnapshots(snapshots: eventWithTime[]): string {
.filter((s) => {
if (
s.type === EventType.IncrementalSnapshot &&
s.data.source === IncrementalSource.MouseMove
(s.data.source === IncrementalSource.MouseMove ||
s.data.source === IncrementalSource.ViewportResize)
) {
return false;
}
@@ -195,6 +197,33 @@ function stringifySnapshots(snapshots: eventWithTime[]): string {
if (s.data.currentTime) {
s.data.currentTime = Math.round(s.data.currentTime * 10) / 10;
}
} else if (
s.type === EventType.Plugin &&
s.data.plugin === 'rrweb/console@1'
) {
const pluginPayload = (
s as pluginEvent<{
trace: string[];
payload: string[];
}>
).data.payload;
if (pluginPayload?.trace.length) {
pluginPayload.trace = pluginPayload.trace.map((trace) => {
return trace.replace(
/^pptr:evaluate;.*?:(\d+:\d+)/,
'__puppeteer_evaluation_script__:$1',
);
});
}
if (pluginPayload?.payload.length) {
pluginPayload.payload = pluginPayload.payload.map((payload) => {
return payload.replace(
/pptr:evaluate;.*?:(\d+:\d+)/g,
'__puppeteer_evaluation_script__:$1',
);
});
}
}
delete (s as Optional<eventWithTime, 'timestamp'>).timestamp;
return s as event;
@@ -260,7 +289,24 @@ function stringifyDomSnapshot(mhtml: string): string {
return newResult.map((asset) => Object.values(asset).join('\n')).join('\n\n');
}
export function assertSnapshot(snapshots: eventWithTime[]) {
export async function assertSnapshot(
snapshotsOrPage: eventWithTime[] | puppeteer.Page,
) {
let snapshots: eventWithTime[];
if (!Array.isArray(snapshotsOrPage)) {
// make sure page has finished executing js
await waitForRAF(snapshotsOrPage);
await snapshotsOrPage.waitForFunction(
'window.snapshots && window.snapshots.length > 0',
);
snapshots = (await snapshotsOrPage.evaluate(
'window.snapshots',
)) as eventWithTime[];
} else {
snapshots = snapshotsOrPage;
}
expect(snapshots).toBeDefined();
expect(stringifySnapshots(snapshots)).toMatchSnapshot();
}
@@ -590,14 +636,59 @@ export async function waitForRAF(
});
}
export async function waitForIFrameLoad(
page: puppeteer.Frame | puppeteer.Page,
iframeSelector: string,
timeout = 10000,
): Promise<puppeteer.Frame> {
const el = await page.waitForSelector(iframeSelector);
if (!el)
throw new Error('Waiting for iframe load has timed out - no element found');
let frame = await el.contentFrame();
if (frame && frame.isDetached()) {
throw new Error(
'Waiting for iframe load has timed out - frame is detached',
);
}
if (frame && frame.url() !== '') {
return frame;
}
await page.$eval(
iframeSelector,
(el, timeout) => {
const p = new Promise((resolve, reject) => {
(el as HTMLIFrameElement).onload = () => {
resolve(el as HTMLIFrameElement);
};
setTimeout(() => {
reject(
new Error(
'Waiting for iframe load has timed out - onload not fired',
),
);
}, timeout);
});
return p;
},
timeout,
);
frame = await el.contentFrame();
if (!frame)
throw new Error('Waiting for iframe load has timed out - no frame found');
return frame;
}
export function generateRecordSnippet(options: recordOptions<eventWithTime>) {
return `
window.snapshots = [];
rrweb.record({
emit: event => {
if (!window.snapshots) window.snapshots = [];
window.snapshots.push(event);
},
ignoreSelector: ${options.ignoreSelector},
ignoreSelector: ${JSON.stringify(options.ignoreSelector)},
maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)},

1632
yarn.lock

File diff suppressed because it is too large Load Diff