Add WebGL support (#756)

* Add very basic webgl support

* document the default

* only capture rr_dataURL in 2d canvas contexts

* rr_dataURL no longer part of webgl snapshot

* ignore __diff_output__ from jest-image-snapshot

* Rename generic "Monorepo" to "RRWeb Monorepo"

* Serialize WebGL variables

* Move rrweb test port number to unique port

rrweb-snapshot uses 3030, rrweb uses 3031

* Prepare for WebGL2

* Split up canvas replay and record webgl vars

* fix typo

* fix typo part 2

* fix typo

* Handle non-variables too

* provide correct context for warning

* (De)Serialize a lot of different objects

* monorepo root should be the first in the list

* Upgrade puppeteer to 11.x

* Correctly de-serialize webgl variables

* Encode arrayBuffers contents to base64

* rename contents to base64

* add webgl2 support and serialize HTMLImageElements

* Support serializing ImageData

* Correctly classify WebGL2 events

* Serialize format changed

* check if canvas has contents before we save the dataURL

* Remove blank dataURL

* reference original file not type defintion file

* update types

* rename code worspace

* update dependencies

* add spector to inspect webgl

* remove live server settings from code workspace

* Save canvas context in the node

Prevents from saving webgl canvases as 2d dataUrls

* remove extra braces

* add ICanvas type

* use ICanvas from rrweb-snapshot in rrweb instead of OgmentedCanvas

* add snapshots and webgl 2 tests

* Upgrade to puppeteer 12.0.1

* Revert back to puppeteer 9.1.1

* Keep index order consistent between replay and record

* keep correct index order in webgl2

* fixed forgotten import

* buffer up pending canvas mutations

* unify the way webgl and webgl2 get patched

* fix parsing error

* Add types for serialize-args

* Add debugging for webgl replay

* Move start-server to utils

* turn off debug mode by default

* Move pendingCanvasMutations to local object and fix if/else statement

* Always save pending mutations

* only use assert snapshot as it's clearer whats going on

* Ugly fix for now

* Making the tests more DRY

* flush at the end of each request animation frame

* Looks like the promise made this test more predictable

* add waitForRAF

* Make nested iframe recording robust no matter the test speed

* mute noisy error in test

* force a requestAnimationFrame

* Bundle events within one frame together as much as possible

WebGL events need to be bundled together as much as possible so they don't accidentally get split over multiple animation frames.

 `newFrame: true` is used to indicate the start of an new animation frame in the recording, and that the event shouldn't be bundled with the previous events.

* Rename RafStamps

* Override event.delay

* cleanup

* Add tests for addDelay

* Add webgl e2e test

* Remove settimeout

* DRY-up test

* Preload images in webgl

* Add e2e test for webgl image preloading

* don't turn on devtools by default!

* Remove spector

* close server after use

* Add imageMap parameter

* Make e2e image test more robust

* document debug mode

* cleanup

* WebGL recording in iframes & Safari 14 support

* fix tests

* don't save null objects as WebGLVar

* group (de)serialized webgl variables by context

* Fix test

* fix tests

* bundle webgl mutations on request animation frame

Instead of fixing it on the replay side we buffer up webgl canvas mutations and wait for a new RAF to flush them. This allows us to remove `newFrame` from the events and simplify things a little

* Add canvas element to mutation observer file

* Add Canvas (Mutation) Manager

Allows you to do `record.freezePage()` and canvas events will get paused.

Based on https://github.com/rrweb-io/rrweb/pull/756#issuecomment-1007566907

* cleanup

* Make sure the correct </body> gets replaced

* Perf: Speed up check to see if canvas is blank

* Access unpatched getImageData

* Use is2DCanvasBlank only for 2d context
This commit is contained in:
Justin Halsall
2026-04-01 12:00:00 +08:00
committed by GitHub
parent b3fb1f13ba
commit 7cd03662a4
63 changed files with 5695 additions and 328 deletions

2
.gitignore vendored
View File

@@ -1,5 +1,5 @@
.vscode/*
!/.vscode/monorepo.code-workspace
!/.vscode/rrweb-monorepo.code-workspace
.idea
node_modules
package-lock.json

View File

@@ -1,26 +1,31 @@
{
"folders": [
{
"name": "Monorepo",
"name": " rrweb monorepo", // added a space to bump it to the top
"path": ".."
},
{
"name": "rrdom (package)",
"path": "../packages/rrdom"
},
{
"name": "rrweb (package)",
"path": "../packages/rrweb"
},
{
"name": "rrweb-player (package)",
"path": "../packages/rrweb-player"
},
{
"name": "rrweb-snapshot (package)",
"path": "../packages/rrweb-snapshot"
}
],
"settings": {
"jest.disabledWorkspaceFolders": [
"Monorepo",
"rrweb-player"
" rrweb monorepo",
"rrweb-player (package)",
"rrdom (package)"
]
}
}

View File

@@ -10,8 +10,14 @@ import {
MaskTextFn,
MaskInputFn,
KeepIframeSrcFn,
ICanvas,
} from './types';
import { isElement, isShadowRoot, maskInputValue } from './utils';
import {
is2DCanvasBlank,
isElement,
isShadowRoot,
maskInputValue,
} from './utils';
let _id = 1;
const tagNameRegex = new RegExp('[^a-z0-9-_:]');
@@ -504,7 +510,26 @@ function serializeNode(
}
// canvas image data
if (tagName === 'canvas' && recordCanvas) {
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();
if ((n as ICanvas).__context === '2d') {
// only record this on 2d canvas
if (!is2DCanvasBlank(n as HTMLCanvasElement)) {
attributes.rr_dataURL = (n as HTMLCanvasElement).toDataURL();
}
} else if (!('__context' in n)) {
// context is unknown, better not call getContext to trigger it
const canvasDataURL = (n as HTMLCanvasElement).toDataURL();
// create blank canvas of same dimensions
const blankCanvas = document.createElement('canvas');
blankCanvas.width = (n as HTMLCanvasElement).width;
blankCanvas.height = (n as HTMLCanvasElement).height;
const blankCanvasDataURL = blankCanvas.toDataURL();
// no need to save dataURL if it's the same as blank canvas
if (canvasDataURL !== blankCanvasDataURL) {
attributes.rr_dataURL = canvasDataURL;
}
}
}
// save image offline
if (tagName === 'img' && inlineImages) {
@@ -592,7 +617,10 @@ function serializeNode(
);
}
} catch (err) {
console.warn(`Cannot get CSS styles from text's parentNode. Error: ${err}`, n);
console.warn(
`Cannot get CSS styles from text's parentNode. Error: ${err}`,
n,
);
}
textContent = absoluteToStylesheet(textContent, getHref());
}

View File

@@ -71,6 +71,10 @@ export interface INode extends Node {
__sn: serializedNodeWithId;
}
export interface ICanvas extends HTMLCanvasElement {
__context: string;
}
export type idNodeMap = {
[key: number]: INode;
};

View File

@@ -35,3 +35,40 @@ export function maskInputValue({
}
return text;
}
const ORIGINAL_ATTRIBUTE_NAME = '__rrweb_original__';
type PatchedGetImageData = {
[ORIGINAL_ATTRIBUTE_NAME]: CanvasImageData['getImageData'];
} & CanvasImageData['getImageData'];
export function is2DCanvasBlank(canvas: HTMLCanvasElement): boolean {
const ctx = canvas.getContext('2d');
if (!ctx) return true;
const chunkSize = 50;
// get chunks of the canvas and check if it is blank
for (let x = 0; x < canvas.width; x += chunkSize) {
for (let y = 0; y < canvas.height; y += chunkSize) {
const getImageData = ctx.getImageData as PatchedGetImageData;
const originalGetImageData =
ORIGINAL_ATTRIBUTE_NAME in getImageData
? getImageData[ORIGINAL_ATTRIBUTE_NAME]
: getImageData;
// by getting the canvas in chunks we avoid an expensive
// `getImageData` call that retrieves everything
// even if we can already tell from the first chunk(s) that
// the canvas isn't blank
const pixelBuffer = new Uint32Array(
originalGetImageData(
x,
y,
Math.min(chunkSize, canvas.width - x),
Math.min(chunkSize, canvas.height - y),
).data.buffer,
);
if (pixelBuffer.some((pixel) => pixel !== 0)) return false;
}
}
return true;
}

View File

@@ -55,6 +55,9 @@ export declare type tagMap = {
export interface INode extends Node {
__sn: serializedNodeWithId;
}
export interface ICanvas extends HTMLCanvasElement {
__context: string;
}
export declare type idNodeMap = {
[key: number]: INode;
};

View File

@@ -8,3 +8,4 @@ export declare function maskInputValue({ maskInputOptions, tagName, type, value,
value: string | null;
maskInputFn?: MaskInputFn;
}): string;
export declare function is2DCanvasBlank(canvas: HTMLCanvasElement): boolean;

View File

@@ -13,3 +13,4 @@ temp
*.log
.env
__diff_output__

View File

@@ -3,4 +3,7 @@ module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
testMatch: ['**/**.test.ts'],
moduleNameMapper: {
'\\.css$': 'identity-obj-proxy',
},
};

View File

@@ -46,15 +46,18 @@
"@types/chai": "^4.1.6",
"@types/inquirer": "0.0.43",
"@types/jest": "^27.0.2",
"@types/jest-image-snapshot": "^4.3.1",
"@types/jsdom": "^16.2.12",
"@types/node": "^12.20.16",
"@types/prettier": "^2.3.2",
"@types/puppeteer": "^5.4.3",
"@types/puppeteer": "^5.4.4",
"cross-env": "^5.2.0",
"fast-mhtml": "^1.1.9",
"identity-obj-proxy": "^3.0.0",
"ignore-styles": "^5.0.1",
"inquirer": "^6.2.1",
"jest": "^27.2.4",
"jest-image-snapshot": "^4.5.1",
"jest-snapshot": "^23.6.0",
"jsdom": "^17.0.0",
"jsdom-global": "^3.0.2",
@@ -73,6 +76,7 @@
"dependencies": {
"@types/css-font-loading-module": "0.0.7",
"@xstate/fsm": "^1.4.0",
"base64-arraybuffer": "^1.0.1",
"fflate": "^0.4.4",
"mitt": "^1.1.3",
"rrweb-snapshot": "^1.1.12"

View File

@@ -18,9 +18,11 @@ import {
listenerHandler,
mutationCallbackParam,
scrollCallback,
canvasMutationParam,
} from '../types';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
function wrapEvent(e: event): eventWithTime {
return {
@@ -180,11 +182,28 @@ function record<T = eventWithTime>(
},
}),
);
const wrappedCanvasMutationEmit = (p: canvasMutationParam) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CanvasMutation,
...p,
},
}),
);
const iframeManager = new IframeManager({
mutationCb: wrappedMutationEmit,
});
const canvasManager = new CanvasManager({
mutationCb: wrappedCanvasMutationEmit,
win: window,
blockClass,
mirror,
});
const shadowDomManager = new ShadowDomManager({
mutationCb: wrappedMutationEmit,
scrollCb: wrappedScrollEmit,
@@ -202,6 +221,7 @@ function record<T = eventWithTime>(
sampling,
slimDOMOptions,
iframeManager,
canvasManager,
},
mirror,
});
@@ -365,16 +385,7 @@ function record<T = eventWithTime>(
},
}),
),
canvasMutationCb: (p) =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CanvasMutation,
...p,
},
}),
),
canvasMutationCb: wrappedCanvasMutationEmit,
fontCb: (p) =>
wrappedEmit(
wrapEvent({
@@ -404,6 +415,7 @@ function record<T = eventWithTime>(
mirror,
iframeManager,
shadowDomManager,
canvasManager,
plugins:
plugins?.map((p) => ({
observer: p.observer,

View File

@@ -31,6 +31,7 @@ import {
hasShadowRoot,
} from '../utils';
import { IframeManager } from './iframe-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
import { ShadowDomManager } from './shadow-dom-manager';
type DoubleLinkedListNode = {
@@ -179,6 +180,7 @@ export default class MutationBuffer {
private mirror: Mirror;
private iframeManager: IframeManager;
private shadowDomManager: ShadowDomManager;
private canvasManager: CanvasManager;
public init(
cb: mutationCallBack,
@@ -197,6 +199,7 @@ export default class MutationBuffer {
mirror: Mirror,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
canvasManager: CanvasManager,
) {
this.blockClass = blockClass;
this.blockSelector = blockSelector;
@@ -214,14 +217,17 @@ export default class MutationBuffer {
this.mirror = mirror;
this.iframeManager = iframeManager;
this.shadowDomManager = shadowDomManager;
this.canvasManager = canvasManager;
}
public freeze() {
this.frozen = true;
this.canvasManager.freeze();
}
public unfreeze() {
this.frozen = false;
this.canvasManager.unfreeze();
this.emit();
}
@@ -231,16 +237,22 @@ export default class MutationBuffer {
public lock() {
this.locked = true;
this.canvasManager.lock();
}
public unlock() {
this.locked = false;
this.canvasManager.unlock();
this.emit();
}
public reset() {
this.canvasManager.reset();
}
public processMutations = (mutations: mutationRecord[]) => {
mutations.forEach(this.processMutation);
this.emit();
mutations.forEach(this.processMutation); // adds mutations to the buffer
this.emit(); // clears buffer if not locked/frozen
};
public emit = () => {

View File

@@ -49,6 +49,7 @@ import {
import MutationBuffer from './mutation';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
type WindowWithStoredMutationObserver = IWindow & {
__rrMutationObserver?: MutationObserver;
@@ -102,6 +103,7 @@ export function initMutationObserver(
mirror: Mirror,
iframeManager: IframeManager,
shadowDomManager: ShadowDomManager,
canvasManager: CanvasManager,
rootEl: Node,
): MutationObserver {
const mutationBuffer = new MutationBuffer();
@@ -124,6 +126,7 @@ export function initMutationObserver(
mirror,
iframeManager,
shadowDomManager,
canvasManager,
);
let mutationObserverCtor =
window.MutationObserver ||
@@ -721,88 +724,6 @@ function initMediaInteractionObserver(
};
}
function initCanvasMutationObserver(
cb: canvasMutationCallback,
win: IWindow,
blockClass: blockClass,
mirror: Mirror,
): listenerHandler {
const props = Object.getOwnPropertyNames(
win.CanvasRenderingContext2D.prototype,
);
const handlers: listenerHandler[] = [];
for (const prop of props) {
try {
if (
typeof win.CanvasRenderingContext2D.prototype[
prop as keyof CanvasRenderingContext2D
] !== 'function'
) {
continue;
}
const restoreHandler = patch(
win.CanvasRenderingContext2D.prototype,
prop,
function (original) {
return function (
this: CanvasRenderingContext2D,
...args: Array<unknown>
) {
if (!isBlocked(this.canvas, blockClass)) {
setTimeout(() => {
const recordArgs = [...args];
if (prop === 'drawImage') {
if (
recordArgs[0] &&
recordArgs[0] instanceof HTMLCanvasElement
) {
const canvas = recordArgs[0];
const ctx = canvas.getContext('2d');
let imgd = ctx?.getImageData(
0,
0,
canvas.width,
canvas.height,
);
let pix = imgd?.data;
recordArgs[0] = JSON.stringify(pix);
}
}
cb({
id: mirror.getId((this.canvas as unknown) as INode),
property: prop,
args: recordArgs,
});
}, 0);
}
return original.apply(this, args);
};
},
);
handlers.push(restoreHandler);
} catch {
const hookHandler = hookSetter<CanvasRenderingContext2D>(
win.CanvasRenderingContext2D.prototype,
prop,
{
set(v) {
cb({
id: mirror.getId((this.canvas as unknown) as INode),
property: prop,
args: [v],
setter: true,
});
},
},
);
handlers.push(hookHandler);
}
}
return () => {
handlers.forEach((h) => h());
};
}
function initFontObserver(cb: fontCallback, doc: Document): listenerHandler {
const win = doc.defaultView as IWindow;
if (!win) {
@@ -965,6 +886,7 @@ export function initObservers(
o.mirror,
o.iframeManager,
o.shadowDomManager,
o.canvasManager,
o.doc,
);
const mousemoveHandler = initMoveObserver(
@@ -1016,14 +938,6 @@ export function initObservers(
currentWindow,
o.mirror,
);
const canvasMutationObserver = o.recordCanvas
? initCanvasMutationObserver(
o.canvasMutationCb,
currentWindow,
o.blockClass,
o.mirror,
)
: () => {};
const fontObserver = o.collectFonts
? initFontObserver(o.fontCb, o.doc)
: () => {};
@@ -1036,6 +950,7 @@ export function initObservers(
}
return () => {
mutationBuffers.forEach((b) => b.reset());
mutationObserver.disconnect();
mousemoveHandler();
mouseInteractionHandler();
@@ -1045,7 +960,6 @@ export function initObservers(
mediaInteractionHandler();
styleSheetObserver();
styleDeclarationObserver();
canvasMutationObserver();
fontObserver();
pluginHandlers.forEach((h) => h());
};

View File

@@ -0,0 +1,94 @@
import { INode } from 'rrweb-snapshot';
import {
blockClass,
CanvasContext,
canvasManagerMutationCallback,
IWindow,
listenerHandler,
Mirror,
} from '../../../types';
import { hookSetter, isBlocked, patch } from '../../../utils';
export default function initCanvas2DMutationObserver(
cb: canvasManagerMutationCallback,
win: IWindow,
blockClass: blockClass,
mirror: Mirror,
): listenerHandler {
const handlers: listenerHandler[] = [];
const props2D = Object.getOwnPropertyNames(
win.CanvasRenderingContext2D.prototype,
);
for (const prop of props2D) {
try {
if (
typeof win.CanvasRenderingContext2D.prototype[
prop as keyof CanvasRenderingContext2D
] !== 'function'
) {
continue;
}
const restoreHandler = patch(
win.CanvasRenderingContext2D.prototype,
prop,
function (original) {
return function (
this: CanvasRenderingContext2D,
...args: Array<unknown>
) {
if (!isBlocked((this.canvas as unknown) as INode, blockClass)) {
// Using setTimeout as getImageData + JSON.stringify can be heavy
// and we'd rather not block the main thread
setTimeout(() => {
const recordArgs = [...args];
if (prop === 'drawImage') {
if (
recordArgs[0] &&
recordArgs[0] instanceof HTMLCanvasElement
) {
const canvas = recordArgs[0];
const ctx = canvas.getContext('2d');
let imgd = ctx?.getImageData(
0,
0,
canvas.width,
canvas.height,
);
let pix = imgd?.data;
recordArgs[0] = JSON.stringify(pix);
}
}
cb(this.canvas, {
type: CanvasContext['2D'],
property: prop,
args: recordArgs,
});
}, 0);
}
return original.apply(this, args);
};
},
);
handlers.push(restoreHandler);
} catch {
const hookHandler = hookSetter<CanvasRenderingContext2D>(
win.CanvasRenderingContext2D.prototype,
prop,
{
set(v) {
cb(this.canvas, {
type: CanvasContext['2D'],
property: prop,
args: [v],
setter: true,
});
},
},
);
handlers.push(hookHandler);
}
}
return () => {
handlers.forEach((h) => h());
};
}

View File

@@ -0,0 +1,152 @@
import { INode } from 'rrweb-snapshot';
import {
blockClass,
canvasManagerMutationCallback,
canvasMutationCallback,
canvasMutationCommand,
canvasMutationWithType,
IWindow,
listenerHandler,
Mirror,
} from '../../../types';
import initCanvas2DMutationObserver from './2d';
import initCanvasContextObserver from './canvas';
import initCanvasWebGLMutationObserver from './webgl';
export type RafStamps = { latestId: number; invokeId: number | null };
type pendingCanvasMutationsMap = Map<
HTMLCanvasElement,
canvasMutationWithType[]
>;
export class CanvasManager {
private pendingCanvasMutations: pendingCanvasMutationsMap = new Map();
private rafStamps: RafStamps = { latestId: 0, invokeId: null };
private mirror: Mirror;
private mutationCb: canvasMutationCallback;
private resetObservers: listenerHandler;
private frozen: boolean = false;
private locked: boolean = false;
public reset() {
this.pendingCanvasMutations.clear();
this.resetObservers();
}
public freeze() {
this.frozen = true;
}
public unfreeze() {
this.frozen = false;
}
public lock() {
this.locked = true;
}
public unlock() {
this.locked = false;
}
constructor(options: {
mutationCb: canvasMutationCallback;
win: IWindow;
blockClass: blockClass;
mirror: Mirror;
}) {
this.mutationCb = options.mutationCb;
this.mirror = options.mirror;
this.initCanvasMutationObserver(options.win, options.blockClass);
}
private processMutation: canvasManagerMutationCallback = function (
target,
mutation,
) {
const newFrame =
this.rafStamps.invokeId &&
this.rafStamps.latestId !== this.rafStamps.invokeId;
if (newFrame || !this.rafStamps.invokeId)
this.rafStamps.invokeId = this.rafStamps.latestId;
if (!this.pendingCanvasMutations.has(target)) {
this.pendingCanvasMutations.set(target, []);
}
this.pendingCanvasMutations.get(target)!.push(mutation);
};
private initCanvasMutationObserver(
win: IWindow,
blockClass: blockClass,
): void {
this.startRAFTimestamping();
this.startPendingCanvasMutationFlusher();
const canvasContextReset = initCanvasContextObserver(win, blockClass);
const canvas2DReset = initCanvas2DMutationObserver(
this.processMutation.bind(this),
win,
blockClass,
this.mirror,
);
const canvasWebGL1and2Reset = initCanvasWebGLMutationObserver(
this.processMutation.bind(this),
win,
blockClass,
this.mirror,
);
this.resetObservers = () => {
canvasContextReset();
canvas2DReset();
canvasWebGL1and2Reset();
};
}
private startPendingCanvasMutationFlusher() {
requestAnimationFrame(() => this.flushPendingCanvasMutations());
}
private startRAFTimestamping() {
const setLatestRAFTimestamp = (timestamp: DOMHighResTimeStamp) => {
this.rafStamps.latestId = timestamp;
requestAnimationFrame(setLatestRAFTimestamp);
};
requestAnimationFrame(setLatestRAFTimestamp);
}
flushPendingCanvasMutations() {
this.pendingCanvasMutations.forEach(
(values: canvasMutationCommand[], canvas: HTMLCanvasElement) => {
const id = this.mirror.getId((canvas as unknown) as INode);
this.flushPendingCanvasMutationFor(canvas, id);
},
);
requestAnimationFrame(() => this.flushPendingCanvasMutations());
}
flushPendingCanvasMutationFor(canvas: HTMLCanvasElement, id: number) {
if (this.frozen || this.locked) {
return;
}
const valuesWithType = this.pendingCanvasMutations.get(canvas);
if (!valuesWithType || id === -1) return;
const values = valuesWithType.map((value) => {
const { type, ...rest } = value;
return rest;
});
const { type } = valuesWithType[0];
this.mutationCb({ id, type, commands: values });
this.pendingCanvasMutations.delete(canvas);
}
}

View File

@@ -0,0 +1,35 @@
import { INode, ICanvas } from 'rrweb-snapshot';
import { blockClass, IWindow, listenerHandler } from '../../../types';
import { isBlocked, patch } from '../../../utils';
export default function initCanvasContextObserver(
win: IWindow,
blockClass: blockClass,
): listenerHandler {
const handlers: listenerHandler[] = [];
try {
const restoreHandler = patch(
win.HTMLCanvasElement.prototype,
'getContext',
function (original) {
return function (
this: ICanvas,
contextType: string,
...args: Array<unknown>
) {
if (!isBlocked((this as unknown) as INode, blockClass)) {
if (!('__context' in this))
(this as ICanvas).__context = contextType;
}
return original.apply(this, [contextType, ...args]);
};
},
);
handlers.push(restoreHandler);
} catch {
console.error('failed to patch HTMLCanvasElement.prototype.getContext');
}
return () => {
handlers.forEach((h) => h());
};
}

View File

@@ -0,0 +1,166 @@
import { encode } from 'base64-arraybuffer';
import { IWindow, SerializedWebGlArg } from '../../../types';
// TODO: unify with `replay/webgl.ts`
type GLVarMap = Map<string, any[]>;
const webGLVarMap: Map<
WebGLRenderingContext | WebGL2RenderingContext,
GLVarMap
> = new Map();
export function variableListFor(
ctx: WebGLRenderingContext | WebGL2RenderingContext,
ctor: string,
) {
let contextMap = webGLVarMap.get(ctx);
if (!contextMap) {
contextMap = new Map();
webGLVarMap.set(ctx, contextMap);
}
if (!contextMap.has(ctor)) {
contextMap.set(ctor, []);
}
return contextMap.get(ctor) as any[];
}
export const saveWebGLVar = (
value: any,
win: IWindow,
ctx: WebGL2RenderingContext | WebGLRenderingContext,
): number | void => {
if (
!value ||
!(isInstanceOfWebGLObject(value, win) || typeof value === 'object')
)
return;
const name = value.constructor.name;
const list = variableListFor(ctx, name);
let index = list.indexOf(value);
if (index === -1) {
index = list.length;
list.push(value);
}
return index;
};
// from webgl-recorder: https://github.com/evanw/webgl-recorder/blob/bef0e65596e981ee382126587e2dcbe0fc7748e2/webgl-recorder.js#L50-L77
export function serializeArg(
value: any,
win: IWindow,
ctx: WebGL2RenderingContext | WebGLRenderingContext,
): SerializedWebGlArg {
if (value instanceof Array) {
return value.map((arg) => serializeArg(arg, win, ctx));
} else if (value === null) {
return value;
} else if (
value instanceof Float32Array ||
value instanceof Float64Array ||
value instanceof Int32Array ||
value instanceof Uint32Array ||
value instanceof Uint8Array ||
value instanceof Uint16Array ||
value instanceof Int16Array ||
value instanceof Int8Array ||
value instanceof Uint8ClampedArray
) {
const name = value.constructor.name;
return {
rr_type: name,
args: [Object.values(value)],
};
} else if (
// SharedArrayBuffer disabled on most browsers due to spectre.
// More info: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/SharedArrayBuffer/SharedArrayBuffer
// value instanceof SharedArrayBuffer ||
value instanceof ArrayBuffer
) {
const name = value.constructor.name as 'ArrayBuffer';
const base64 = encode(value);
return {
rr_type: name,
base64,
};
} else if (value instanceof DataView) {
const name = value.constructor.name;
return {
rr_type: name,
args: [
serializeArg(value.buffer, win, ctx),
value.byteOffset,
value.byteLength,
],
};
} else if (value instanceof HTMLImageElement) {
const name = value.constructor.name;
const { src } = value;
return {
rr_type: name,
src,
};
} else if (value instanceof ImageData) {
const name = value.constructor.name;
return {
rr_type: name,
args: [serializeArg(value.data, win, ctx), value.width, value.height],
};
} else if (isInstanceOfWebGLObject(value, win) || typeof value === 'object') {
const name = value.constructor.name;
const index = saveWebGLVar(value, win, ctx) as number;
return {
rr_type: name,
index: index,
};
}
return value;
}
export const serializeArgs = (
args: Array<any>,
win: IWindow,
ctx: WebGLRenderingContext | WebGL2RenderingContext,
) => {
return [...args].map((arg) => serializeArg(arg, win, ctx));
};
export const isInstanceOfWebGLObject = (
value: any,
win: IWindow,
): value is
| WebGLActiveInfo
| WebGLBuffer
| WebGLFramebuffer
| WebGLProgram
| WebGLRenderbuffer
| WebGLShader
| WebGLShaderPrecisionFormat
| WebGLTexture
| WebGLUniformLocation
| WebGLVertexArrayObject => {
const webGLConstructorNames: string[] = [
'WebGLActiveInfo',
'WebGLBuffer',
'WebGLFramebuffer',
'WebGLProgram',
'WebGLRenderbuffer',
'WebGLShader',
'WebGLShaderPrecisionFormat',
'WebGLTexture',
'WebGLUniformLocation',
'WebGLVertexArrayObject',
// In old Chrome versions, value won't be an instanceof WebGLVertexArrayObject.
'WebGLVertexArrayObjectOES',
];
const supportedWebGLConstructorNames = webGLConstructorNames.filter(
(name: string) => typeof win[name as keyof Window] === 'function',
);
return Boolean(
supportedWebGLConstructorNames.find(
(name: string) => value instanceof win[name as keyof Window],
),
);
};

View File

@@ -0,0 +1,106 @@
import { INode } from 'rrweb-snapshot';
import {
blockClass,
CanvasContext,
canvasManagerMutationCallback,
canvasMutationWithType,
IWindow,
listenerHandler,
Mirror,
} from '../../../types';
import { hookSetter, isBlocked, patch } from '../../../utils';
import { saveWebGLVar, serializeArgs } from './serialize-args';
function patchGLPrototype(
prototype: WebGLRenderingContext | WebGL2RenderingContext,
type: CanvasContext,
cb: canvasManagerMutationCallback,
blockClass: blockClass,
mirror: Mirror,
win: IWindow,
): listenerHandler[] {
const handlers: listenerHandler[] = [];
const props = Object.getOwnPropertyNames(prototype);
for (const prop of props) {
try {
if (typeof prototype[prop as keyof typeof prototype] !== 'function') {
continue;
}
const restoreHandler = patch(prototype, prop, function (original) {
return function (this: typeof prototype, ...args: Array<unknown>) {
const result = original.apply(this, args);
saveWebGLVar(result, win, prototype);
if (!isBlocked((this.canvas as unknown) as INode, blockClass)) {
const id = mirror.getId((this.canvas as unknown) as INode);
const recordArgs = serializeArgs([...args], win, prototype);
const mutation: canvasMutationWithType = {
type,
property: prop,
args: recordArgs,
};
// TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement
cb(this.canvas as HTMLCanvasElement, mutation);
}
return result;
};
});
handlers.push(restoreHandler);
} catch {
const hookHandler = hookSetter<typeof prototype>(prototype, prop, {
set(v) {
// TODO: this could potentially also be an OffscreenCanvas as well as HTMLCanvasElement
cb(this.canvas as HTMLCanvasElement, {
type,
property: prop,
args: [v],
setter: true,
});
},
});
handlers.push(hookHandler);
}
}
return handlers;
}
export default function initCanvasWebGLMutationObserver(
cb: canvasManagerMutationCallback,
win: IWindow,
blockClass: blockClass,
mirror: Mirror,
): listenerHandler {
const handlers: listenerHandler[] = [];
handlers.push(
...patchGLPrototype(
win.WebGLRenderingContext.prototype,
CanvasContext.WebGL,
cb,
blockClass,
mirror,
win,
),
);
if (typeof win.WebGL2RenderingContext !== 'undefined') {
handlers.push(
...patchGLPrototype(
win.WebGL2RenderingContext.prototype,
CanvasContext.WebGL2,
cb,
blockClass,
mirror,
win,
),
);
}
return () => {
handlers.forEach((h) => h());
};
}

View File

@@ -14,6 +14,7 @@ import {
} from 'rrweb-snapshot';
import { IframeManager } from './iframe-manager';
import { initMutationObserver, initScrollObserver } from './observer';
import { CanvasManager } from './observers/canvas/canvas-manager';
type BypassOptions = {
blockClass: blockClass;
@@ -29,6 +30,7 @@ type BypassOptions = {
sampling: SamplingStrategy;
slimDOMOptions: SlimDOMOptions;
iframeManager: IframeManager;
canvasManager: CanvasManager;
};
export class ShadowDomManager {
@@ -67,6 +69,7 @@ export class ShadowDomManager {
this.mirror,
this.bypassOptions.iframeManager,
this,
this.bypassOptions.canvasManager,
shadowRoot,
);
initScrollObserver(

View File

@@ -0,0 +1,48 @@
import { Replayer } from '../';
import { canvasMutationCommand } from '../../types';
export default function canvasMutation({
event,
mutation,
target,
imageMap,
errorHandler,
}: {
event: Parameters<Replayer['applyIncremental']>[0];
mutation: canvasMutationCommand;
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void {
try {
const ctx = ((target as unknown) as HTMLCanvasElement).getContext('2d')!;
if (mutation.setter) {
// skip some read-only type checks
// tslint:disable-next-line:no-any
(ctx as any)[mutation.property] = mutation.args[0];
return;
}
const original = ctx[
mutation.property as Exclude<keyof typeof ctx, 'canvas'>
] as Function;
/**
* 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'
) {
const image = imageMap.get(event);
mutation.args[0] = image;
original.apply(ctx, mutation.args);
} else {
original.apply(ctx, mutation.args);
}
} catch (error) {
errorHandler(mutation, error);
}
}

View File

@@ -0,0 +1,51 @@
import { Replayer } from '..';
import {
CanvasContext,
canvasMutationCommand,
canvasMutationData,
} from '../../types';
import webglMutation from './webgl';
import canvas2DMutation from './2d';
export default function canvasMutation({
event,
mutation,
target,
imageMap,
errorHandler,
}: {
event: Parameters<Replayer['applyIncremental']>[0];
mutation: canvasMutationData;
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void {
try {
const mutations: canvasMutationCommand[] =
'commands' in mutation ? mutation.commands : [mutation];
if ([CanvasContext.WebGL, CanvasContext.WebGL2].includes(mutation.type)) {
return mutations.forEach((command) => {
webglMutation({
mutation: command,
type: mutation.type,
target,
imageMap,
errorHandler,
});
});
}
// default is '2d' for backwards compatibility (rrweb below 1.1.x)
return mutations.forEach((command) => {
canvas2DMutation({
event,
mutation: command,
target,
imageMap,
errorHandler,
});
});
} catch (error) {
errorHandler(mutation, error);
}
}

View File

@@ -0,0 +1,175 @@
import { decode } from 'base64-arraybuffer';
import { Replayer } from '../';
import {
CanvasContext,
canvasMutationCommand,
SerializedWebGlArg,
} from '../../types';
// TODO: add ability to wipe this list
type GLVarMap = Map<string, any[]>;
const webGLVarMap: Map<
WebGLRenderingContext | WebGL2RenderingContext,
GLVarMap
> = new Map();
export function variableListFor(
ctx: WebGLRenderingContext | WebGL2RenderingContext,
ctor: string,
) {
let contextMap = webGLVarMap.get(ctx);
if (!contextMap) {
contextMap = new Map();
webGLVarMap.set(ctx, contextMap);
}
if (!contextMap.has(ctor)) {
contextMap.set(ctor, []);
}
return contextMap.get(ctor) as any[];
}
function getContext(
target: HTMLCanvasElement,
type: CanvasContext,
): WebGLRenderingContext | WebGL2RenderingContext | null {
// Note to whomever is going to implement support for `contextAttributes`:
// if `preserveDrawingBuffer` is set to true,
// you might have to do `ctx.flush()` before every webgl canvas event
try {
if (type === CanvasContext.WebGL) {
return (
target.getContext('webgl')! || target.getContext('experimental-webgl')
);
}
return target.getContext('webgl2')!;
} catch (e) {
return null;
}
}
const WebGLVariableConstructorsNames = [
'WebGLActiveInfo',
'WebGLBuffer',
'WebGLFramebuffer',
'WebGLProgram',
'WebGLRenderbuffer',
'WebGLShader',
'WebGLShaderPrecisionFormat',
'WebGLTexture',
'WebGLUniformLocation',
'WebGLVertexArrayObject',
];
function saveToWebGLVarMap(
ctx: WebGLRenderingContext | WebGL2RenderingContext,
result: any,
) {
if (!result?.constructor) return; // probably null or undefined
const { name } = result.constructor;
if (!WebGLVariableConstructorsNames.includes(name)) return; // not a WebGL variable
const variables = variableListFor(ctx, name);
if (!variables.includes(result)) variables.push(result);
}
export function deserializeArg(
imageMap: Replayer['imageMap'],
ctx: WebGLRenderingContext | WebGL2RenderingContext,
): (arg: SerializedWebGlArg) => any {
return (arg: SerializedWebGlArg): any => {
if (arg && typeof arg === 'object' && 'rr_type' in arg) {
if ('index' in arg) {
const { rr_type: name, index } = arg;
return variableListFor(ctx, name)[index];
} else if ('args' in arg) {
const { rr_type: name, args } = arg;
const ctor = window[name as keyof Window];
return new ctor(...args.map(deserializeArg(imageMap, ctx)));
} else if ('base64' in arg) {
return decode(arg.base64);
} else if ('src' in arg) {
const image = imageMap.get(arg.src);
if (image) {
return image;
} else {
const image = new Image();
image.src = arg.src;
imageMap.set(arg.src, image);
return image;
}
}
} else if (Array.isArray(arg)) {
return arg.map(deserializeArg(imageMap, ctx));
}
return arg;
};
}
export default function webglMutation({
mutation,
target,
type,
imageMap,
errorHandler,
}: {
mutation: canvasMutationCommand;
target: HTMLCanvasElement;
type: CanvasContext;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void {
try {
const ctx = getContext(target, type);
if (!ctx) return;
// NOTE: if `preserveDrawingBuffer` is set to true,
// we must flush the buffers on every new canvas event
// if (mutation.newFrame) ctx.flush();
if (mutation.setter) {
// skip some read-only type checks
// tslint:disable-next-line:no-any
(ctx as any)[mutation.property] = mutation.args[0];
return;
}
const original = ctx[
mutation.property as Exclude<keyof typeof ctx, 'canvas'>
] as Function;
const args = mutation.args.map(deserializeArg(imageMap, ctx));
const result = original.apply(ctx, args);
saveToWebGLVarMap(ctx, result);
// Slows down replay considerably, only use for debugging
const debugMode = false;
if (debugMode) {
if (mutation.property === 'compileShader') {
if (!ctx.getShaderParameter(args[0], ctx.COMPILE_STATUS))
console.warn(
'something went wrong in replay',
ctx.getShaderInfoLog(args[0]),
);
} else if (mutation.property === 'linkProgram') {
ctx.validateProgram(args[0]);
if (!ctx.getProgramParameter(args[0], ctx.LINK_STATUS))
console.warn(
'something went wrong in replay',
ctx.getProgramInfoLog(args[0]),
);
}
const webglError = ctx.getError();
if (webglError !== ctx.NO_ERROR) {
console.warn(
'WEBGL ERROR',
webglError,
'on command:',
mutation.property,
...args,
);
}
}
} catch (error) {
errorHandler(mutation, error);
}
}

View File

@@ -39,6 +39,7 @@ import {
styleValueWithPriority,
mouseMovePos,
IWindow,
canvasMutationCommand,
} from '../types';
import {
createMirror,
@@ -62,6 +63,7 @@ import {
getNestedRule,
getPositionsAndIndex,
} from './virtual-styles';
import canvasMutation from './canvas';
const SKIP_TIME_THRESHOLD = 10 * 1000;
const SKIP_TIME_INTERVAL = 5 * 1000;
@@ -120,7 +122,7 @@ export class Replayer {
// The replayer uses the cache to speed up replay and scrubbing.
private cache: BuildCache = createCache();
private imageMap: Map<eventWithTime, HTMLImageElement> = new Map();
private imageMap: Map<eventWithTime | string, HTMLImageElement> = new Map();
private mirror: Mirror = createMirror();
@@ -810,6 +812,37 @@ export class Replayer {
}
}
private hasImageArg(args: any[]): boolean {
for (const arg of args) {
if (!arg || typeof arg !== 'object') {
// do nothing
} else if ('rr_type' in arg && 'args' in arg) {
if (this.hasImageArg(arg.args)) return true;
} else if ('rr_type' in arg && arg.rr_type === 'HTMLImageElement') {
return true; // has image!
} else if (arg instanceof Array) {
if (this.hasImageArg(arg)) return true;
}
}
return false;
}
private getImageArgs(args: any[]): string[] {
const images: string[] = [];
for (const arg of args) {
if (!arg || typeof arg !== 'object') {
// do nothing
} else if ('rr_type' in arg && 'args' in arg) {
images.push(...this.getImageArgs(arg.args));
} else if ('rr_type' in arg && arg.rr_type === 'HTMLImageElement') {
images.push(arg.src);
} else if (arg instanceof Array) {
images.push(...this.getImageArgs(arg));
}
}
return images;
}
/**
* pause when there are some canvas drawImage args need to be loaded
*/
@@ -823,18 +856,34 @@ export class Replayer {
for (const event of this.service.state.context.events) {
if (
event.type === EventType.IncrementalSnapshot &&
event.data.source === IncrementalSource.CanvasMutation &&
event.data.property === 'drawImage' &&
typeof event.data.args[0] === 'string' &&
!this.imageMap.has(event)
) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const imgd = ctx?.createImageData(canvas.width, canvas.height);
let d = imgd?.data;
d = JSON.parse(event.data.args[0]);
ctx?.putImageData(imgd!, 0, 0);
}
event.data.source === IncrementalSource.CanvasMutation
)
if ('commands' in event.data) {
event.data.commands.forEach((c) => this.preloadImages(c, event));
} else {
this.preloadImages(event.data, event);
}
}
}
private preloadImages(data: canvasMutationCommand, event: eventWithTime) {
if (
data.property === 'drawImage' &&
typeof data.args[0] === 'string' &&
!this.imageMap.has(event)
) {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const imgd = ctx?.createImageData(canvas.width, canvas.height);
let d = imgd?.data;
d = JSON.parse(data.args[0]);
ctx?.putImageData(imgd!, 0, 0);
} else if (this.hasImageArg(data.args)) {
this.getImageArgs(data.args).forEach((url) => {
const image = new Image();
image.src = url; // this preloads the image
this.imageMap.set(url, image);
});
}
}
@@ -1210,34 +1259,15 @@ export class Replayer {
if (!target) {
return this.debugNodeNotFound(d, d.id);
}
try {
const ctx = ((target as unknown) as HTMLCanvasElement).getContext(
'2d',
)!;
if (d.setter) {
// skip some read-only type checks
// tslint:disable-next-line:no-any
(ctx as any)[d.property] = d.args[0];
return;
}
const original = ctx[
d.property as keyof CanvasRenderingContext2D
] as Function;
/**
* 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 (d.property === 'drawImage' && typeof d.args[0] === 'string') {
const image = this.imageMap.get(e);
d.args[0] = image;
original.apply(ctx, d.args);
} else {
original.apply(ctx, d.args);
}
} catch (error) {
this.warnCanvasMutationFailed(d, d.id, error);
}
canvasMutation({
event: e,
mutation: d,
target: (target as unknown) as HTMLCanvasElement,
imageMap: this.imageMap,
errorHandler: this.warnCanvasMutationFailed.bind(this),
});
break;
}
case IncrementalSource.Font: {
@@ -1866,11 +1896,10 @@ export class Replayer {
}
private warnCanvasMutationFailed(
d: canvasMutationData,
id: number,
d: canvasMutationData | canvasMutationCommand,
error: unknown,
) {
this.warn(`Has error on update canvas '${id}'`, d, error);
this.warn(`Has error on canvas update`, error, 'canvas mutation:', d);
}
private debugNodeNotFound(d: incrementalData, id: number) {

View File

@@ -167,6 +167,7 @@ export function createPlayerService(
play(ctx) {
const { timer, events, baselineTime, lastPlayedEvent } = ctx;
timer.clear();
for (const event of events) {
// TODO: improve this API
addDelay(event, baselineTime);

View File

@@ -44,6 +44,7 @@ export class Timer {
lastTimestamp = time;
while (actions.length) {
const action = actions[0];
if (self.timeOffset >= action.delay) {
actions.shift();
action.doAction();
@@ -111,6 +112,7 @@ export function addDelay(event: eventWithTime, baselineTime: number): number {
event.delay = firstTimestamp - baselineTime;
return firstTimestamp - baselineTime;
}
event.delay = event.timestamp - baselineTime;
return event.delay;
}

View File

@@ -11,6 +11,7 @@ import { PackFn, UnpackFn } from './packer/base';
import { IframeManager } from './record/iframe-manager';
import { ShadowDomManager } from './record/shadow-dom-manager';
import type { Replayer } from './replay';
import { CanvasManager } from './record/observers/canvas/canvas-manager';
export enum EventType {
DomContentLoaded,
@@ -267,6 +268,7 @@ export type observerParam = {
mirror: Mirror;
iframeManager: IframeManager;
shadowDomManager: ShadowDomManager;
canvasManager: CanvasManager;
plugins: Array<{
observer: Function;
callback: Function;
@@ -386,6 +388,35 @@ export enum MouseInteractions {
TouchCancel,
}
export enum CanvasContext {
'2D',
WebGL,
WebGL2,
}
export type SerializedWebGlArg =
| {
rr_type: 'ArrayBuffer';
base64: string; // base64
}
| {
rr_type: string;
src: string; // url of image
}
| {
rr_type: string;
args: Array<SerializedWebGlArg>;
}
| {
rr_type: string;
index: number;
}
| string
| number
| boolean
| null
| SerializedWebGlArg[];
type mouseInteractionParam = {
type: MouseInteractions;
id: number;
@@ -435,15 +466,34 @@ export type styleDeclarationParam = {
export type styleDeclarationCallback = (s: styleDeclarationParam) => void;
export type canvasMutationCallback = (p: canvasMutationParam) => void;
export type canvasMutationParam = {
id: number;
export type canvasMutationCommand = {
property: string;
args: Array<unknown>;
setter?: true;
};
export type canvasMutationParam =
| {
id: number;
type: CanvasContext;
commands: canvasMutationCommand[];
}
| ({
id: number;
type: CanvasContext;
} & canvasMutationCommand);
export type canvasMutationWithType = {
type: CanvasContext;
} & canvasMutationCommand;
export type canvasMutationCallback = (p: canvasMutationParam) => void;
export type canvasManagerMutationCallback = (
target: HTMLCanvasElement,
p: canvasMutationWithType,
) => void;
export type fontParam = {
family: string;
fontSource: string;

View File

@@ -98,9 +98,21 @@ exports[`record integration tests can freeze mutations 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -109,15 +121,15 @@ exports[`record integration tests can freeze mutations 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -141,7 +153,7 @@ exports[`record integration tests can freeze mutations 1`] = `
\\"texts\\": [],
\\"attributes\\": [
{
\\"id\\": 18,
\\"id\\": 20,
\\"attributes\\": {
\\"foo\\": \\"bar\\"
}
@@ -165,7 +177,7 @@ exports[`record integration tests can freeze mutations 1`] = `
\\"foo\\": \\"bar\\"
},
\\"childNodes\\": [],
\\"id\\": 18
\\"id\\": 20
}
}
]
@@ -272,9 +284,21 @@ exports[`record integration tests can mask character data mutations 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -283,15 +307,15 @@ exports[`record integration tests can mask character data mutations 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -338,16 +362,16 @@ exports[`record integration tests can mask character data mutations 1`] = `
\\"class\\": \\"rr-mask\\"
},
\\"childNodes\\": [],
\\"id\\": 18
\\"id\\": 20
}
},
{
\\"parentId\\": 18,
\\"parentId\\": 20,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"*** **** ****\\",
\\"id\\": 19
\\"id\\": 21
}
},
{
@@ -356,7 +380,7 @@ exports[`record integration tests can mask character data mutations 1`] = `
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"*******\\",
\\"id\\": 20
\\"id\\": 22
}
}
]
@@ -463,9 +487,21 @@ exports[`record integration tests can record attribute mutation 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -474,15 +510,15 @@ exports[`record integration tests can record attribute mutation 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -622,9 +658,21 @@ exports[`record integration tests can record character data muatations 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -633,15 +681,15 @@ exports[`record integration tests can record character data muatations 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -681,7 +729,7 @@ exports[`record integration tests can record character data muatations 1`] = `
\\"node\\": {
\\"type\\": 3,
\\"textContent\\": \\"mutated\\",
\\"id\\": 18
\\"id\\": 20
}
}
]
@@ -788,9 +836,21 @@ exports[`record integration tests can record childList mutations 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -799,15 +859,15 @@ exports[`record integration tests can record childList mutations 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -845,7 +905,7 @@ exports[`record integration tests can record childList mutations 1`] = `
\\"tagName\\": \\"span\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 18
\\"id\\": 20
}
}
]
@@ -7315,33 +7375,28 @@ exports[`record integration tests should record canvas mutations 1`] = `
\\"data\\": {
\\"source\\": 9,
\\"id\\": 16,
\\"property\\": \\"moveTo\\",
\\"args\\": [
0,
0
\\"type\\": 0,
\\"commands\\": [
{
\\"property\\": \\"moveTo\\",
\\"args\\": [
0,
0
]
},
{
\\"property\\": \\"lineTo\\",
\\"args\\": [
200,
100
]
},
{
\\"property\\": \\"stroke\\",
\\"args\\": []
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 16,
\\"property\\": \\"lineTo\\",
\\"args\\": [
200,
100
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 16,
\\"property\\": \\"stroke\\",
\\"args\\": []
}
}
]"
`;
@@ -9522,6 +9577,221 @@ exports[`record integration tests should record shadow DOM 1`] = `
]"
`;
exports[`record integration tests should record webgl canvas mutations 1`] = `
"[
{
\\"type\\": 0,
\\"data\\": {}
},
{
\\"type\\": 1,
\\"data\\": {}
},
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {
\\"lang\\": \\"en\\"
},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 5
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"charset\\": \\"UTF-8\\"
},
\\"childNodes\\": [],
\\"id\\": 6
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 7
},
{
\\"type\\": 2,
\\"tagName\\": \\"meta\\",
\\"attributes\\": {
\\"name\\": \\"viewport\\",
\\"content\\": \\"width=device-width, initial-scale=1.0\\"
},
\\"childNodes\\": [],
\\"id\\": 8
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 9
},
{
\\"type\\": 2,
\\"tagName\\": \\"title\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"canvas\\",
\\"id\\": 11
}
],
\\"id\\": 10
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 12
}
],
\\"id\\": 4
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 13
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 15
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"myCanvas\\",
\\"width\\": \\"200\\",
\\"height\\": \\"100\\",
\\"style\\": \\"border: 1px solid #000000\\"
},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 17
}
],
\\"id\\": 16
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 18
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 20
}
],
\\"id\\": 19
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 21
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 23
}
],
\\"id\\": 22
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\\\n\\",
\\"id\\": 24
}
],
\\"id\\": 14
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 16,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"clearColor\\",
\\"args\\": [
1,
0,
0,
1
]
},
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record integration tests will serialize node before record 1`] = `
"[
{
@@ -9620,9 +9890,21 @@ exports[`record integration tests will serialize node before record 1`] = `
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 14
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 15
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n\\\\n \\",
\\"id\\": 16
},
{
\\"type\\": 2,
\\"tagName\\": \\"script\\",
@@ -9631,15 +9913,15 @@ exports[`record integration tests will serialize node before record 1`] = `
{
\\"type\\": 3,
\\"textContent\\": \\"SCRIPT_PLACEHOLDER\\",
\\"id\\": 16
\\"id\\": 18
}
],
\\"id\\": 15
\\"id\\": 17
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\",
\\"id\\": 17
\\"textContent\\": \\"\\\\n \\\\n \\\\n\\",
\\"id\\": 19
}
],
\\"id\\": 5
@@ -9667,28 +9949,6 @@ exports[`record integration tests will serialize node before record 1`] = `
{
\\"parentId\\": 10,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 18
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 18,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 19
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 19,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
@@ -9696,6 +9956,28 @@ exports[`record integration tests will serialize node before record 1`] = `
\\"childNodes\\": [],
\\"id\\": 20
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 20,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 21
}
},
{
\\"parentId\\": 10,
\\"nextId\\": 21,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"li\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 22
}
}
]
}

View File

@@ -0,0 +1,175 @@
import * as http from 'http';
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import {
startServer,
launchPuppeteer,
getServerURL,
replaceLast,
waitForRAF,
} from '../utils';
import {
recordOptions,
eventWithTime,
EventType,
IncrementalSource,
} from '../../src/types';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
expect.extend({ toMatchImageSnapshot });
interface ISuite {
code: string;
browser: puppeteer.Browser;
server: http.Server;
page: puppeteer.Page;
events: eventWithTime[];
serverURL: string;
}
describe('e2e webgl', () => {
let code: ISuite['code'];
let page: ISuite['page'];
let browser: ISuite['browser'];
let server: ISuite['server'];
let serverURL: ISuite['serverURL'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
code = fs.readFileSync(bundlePath, 'utf8');
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await server.close();
await browser.close();
});
const getHtml = (
fileName: string,
options: recordOptions<eventWithTime> = {},
): string => {
const filePath = path.resolve(__dirname, `../html/${fileName}`);
const html = fs.readFileSync(filePath, 'utf8');
return replaceLast(
html,
'</body>',
`
<script>
${code}
window.snapshots = [];
rrweb.record({
emit: event => {
window.snapshots.push(event);
},
maskTextSelector: ${JSON.stringify(options.maskTextSelector)},
maskAllInputs: ${options.maskAllInputs},
maskInputOptions: ${JSON.stringify(options.maskAllInputs)},
userTriggeredOnInput: ${options.userTriggeredOnInput},
maskTextFn: ${options.maskTextFn},
recordCanvas: ${options.recordCanvas},
plugins: ${options.plugins}
});
</script>
</body>
`,
);
};
const fakeGoto = async (page: puppeteer.Page, url: string) => {
const intercept = async (request: puppeteer.HTTPRequest) => {
await request.respond({
status: 200,
contentType: 'text/html',
body: ' ', // non-empty string or page will load indefinitely
});
};
await page.setRequestInterception(true);
page.on('request', intercept);
await page.goto(url);
page.off('request', intercept);
await page.setRequestInterception(false);
};
const hideMouseAnimation = async (page: puppeteer.Page) => {
await page.addStyleTag({
content: '.replayer-mouse-tail{display: none !important;}',
});
};
it('will record and replay a webgl square', async () => {
page = await browser.newPage();
await fakeGoto(page, `${serverURL}/html/canvas-webgl-square.html`);
await page.setContent(
getHtml.call(this, 'canvas-webgl-square.html', { recordCanvas: true }),
);
await waitForRAF(page);
const snapshots: eventWithTime[] = await page.evaluate('window.snapshots');
page = await browser.newPage();
await page.goto('about:blank');
await page.evaluate(code);
await hideMouseAnimation(page);
await page.evaluate(`let events = ${JSON.stringify(snapshots)}`);
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events, {
UNSAFE_replayCanvas: true,
});
replayer.play(500);
`);
await page.waitForTimeout(50);
const element = await page.$('iframe');
const frameImage = await element!.screenshot();
expect(frameImage).toMatchImageSnapshot();
});
it('will record and replay a webgl image', async () => {
page = await browser.newPage();
await fakeGoto(page, `${serverURL}/html/canvas-webgl-image.html`);
await page.setContent(
getHtml.call(this, 'canvas-webgl-image.html', { recordCanvas: true }),
);
await page.waitForTimeout(100);
const snapshots: eventWithTime[] = await page.evaluate('window.snapshots');
page = await browser.newPage();
await page.goto('about:blank');
await page.evaluate(code);
await hideMouseAnimation(page);
await page.evaluate(`let events = ${JSON.stringify(snapshots)}`);
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events, {
UNSAFE_replayCanvas: true,
});
`);
// wait for iframe to get added and `preloadAllImages` to ge called
await page.waitForSelector('iframe');
await page.evaluate(`replayer.play(500);`);
await page.waitForTimeout(50);
const element = await page.$('iframe');
const frameImage = await element!.screenshot();
expect(frameImage).toMatchImageSnapshot();
});
});

View File

@@ -0,0 +1,118 @@
export default [
{
type: 4,
data: {
href: '',
width: 1600,
height: 900,
},
timestamp: 1636379531385,
},
{
type: 2,
data: {
node: {
type: 0,
childNodes: [
{ type: 1, name: 'html', publicId: '', systemId: '', id: 2 },
{
type: 2,
tagName: 'html',
attributes: { lang: 'en' },
childNodes: [
{
type: 2,
tagName: 'head',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 5 },
{
type: 2,
tagName: 'meta',
attributes: { charset: 'UTF-8' },
childNodes: [],
id: 6,
},
{ type: 3, textContent: '\n ', id: 7 },
{
type: 2,
tagName: 'meta',
attributes: {
name: 'viewport',
content: 'width=device-width, initial-scale=1.0',
},
childNodes: [],
id: 8,
},
{ type: 3, textContent: '\n ', id: 9 },
{
type: 2,
tagName: 'title',
attributes: {},
childNodes: [{ type: 3, textContent: 'canvas', id: 11 }],
id: 10,
},
{ type: 3, textContent: '\n ', id: 12 },
],
id: 4,
},
{ type: 3, textContent: '\n ', id: 13 },
{
type: 2,
tagName: 'body',
attributes: {},
childNodes: [
{ type: 3, textContent: '\n ', id: 15 },
{
type: 2,
tagName: 'canvas',
attributes: {
id: 'myCanvas',
width: '200',
height: '100',
style: 'border: 1px solid #000000',
},
childNodes: [{ type: 3, textContent: '\n ', id: 17 }],
id: 16,
},
{ type: 3, textContent: '\n ', id: 18 },
{
type: 2,
tagName: 'script',
attributes: {},
childNodes: [
{ type: 3, textContent: 'SCRIPT_PLACEHOLDER', id: 20 },
],
id: 19,
},
{ type: 3, textContent: '\n \n\n', id: 21 },
],
id: 14,
},
],
id: 3,
},
],
id: 1,
},
initialOffset: { left: 0, top: 0 },
},
timestamp: 1636379531389,
},
{
type: 3,
data: {
source: 9,
id: 16,
type: 1,
property: 'clearColor',
args: [1, 0, 0, 1],
},
timestamp: 1636379532355,
},
{
type: 3,
data: { source: 9, id: 16, type: 1, property: 'clear', args: [16384] },
timestamp: 1636379532356,
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<!-- FROM https://stackoverflow.com/a/12268575/543604 -->
<script src="./assets/webgl-utils.js"></script>
<canvas id="c"></canvas>
<!-- vertex shader -->
<script id="2d-vertex-shader" type="x-shader/x-vertex">
attribute vec2 a_position;
uniform vec2 u_resolution;
uniform mat3 u_matrix;
varying vec2 v_texCoord;
void main() {
gl_Position = vec4(u_matrix * vec3(a_position, 1), 1);
v_texCoord = a_position;
}
</script>
<!-- fragment shader -->
<script id="2d-fragment-shader" type="x-shader/x-fragment">
precision mediump float;
// our texture
uniform sampler2D u_image;
// the texCoords passed in from the vertex shader.
varying vec2 v_texCoord;
void main() {
gl_FragColor = texture2D(u_image, v_texCoord);
}
</script>
<script>
'use strict';
window.onload = main;
function main() {
var image = new Image();
image.src =
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAACXBIWXMAAAsTAAALEwEAmpwYAAAKT2lDQ1BQaG90b3Nob3AgSUNDIHByb2ZpbGUAAHjanVNnVFPpFj333vRCS4iAlEtvUhUIIFJCi4AUkSYqIQkQSoghodkVUcERRUUEG8igiAOOjoCMFVEsDIoK2AfkIaKOg6OIisr74Xuja9a89+bN/rXXPues852zzwfACAyWSDNRNYAMqUIeEeCDx8TG4eQuQIEKJHAAEAizZCFz/SMBAPh+PDwrIsAHvgABeNMLCADATZvAMByH/w/qQplcAYCEAcB0kThLCIAUAEB6jkKmAEBGAYCdmCZTAKAEAGDLY2LjAFAtAGAnf+bTAICd+Jl7AQBblCEVAaCRACATZYhEAGg7AKzPVopFAFgwABRmS8Q5ANgtADBJV2ZIALC3AMDOEAuyAAgMADBRiIUpAAR7AGDIIyN4AISZABRG8lc88SuuEOcqAAB4mbI8uSQ5RYFbCC1xB1dXLh4ozkkXKxQ2YQJhmkAuwnmZGTKBNA/g88wAAKCRFRHgg/P9eM4Ors7ONo62Dl8t6r8G/yJiYuP+5c+rcEAAAOF0ftH+LC+zGoA7BoBt/qIl7gRoXgugdfeLZrIPQLUAoOnaV/Nw+H48PEWhkLnZ2eXk5NhKxEJbYcpXff5nwl/AV/1s+X48/Pf14L7iJIEyXYFHBPjgwsz0TKUcz5IJhGLc5o9H/LcL//wd0yLESWK5WCoU41EScY5EmozzMqUiiUKSKcUl0v9k4t8s+wM+3zUAsGo+AXuRLahdYwP2SycQWHTA4vcAAPK7b8HUKAgDgGiD4c93/+8//UegJQCAZkmScQAAXkQkLlTKsz/HCAAARKCBKrBBG/TBGCzABhzBBdzBC/xgNoRCJMTCQhBCCmSAHHJgKayCQiiGzbAdKmAv1EAdNMBRaIaTcA4uwlW4Dj1wD/phCJ7BKLyBCQRByAgTYSHaiAFiilgjjggXmYX4IcFIBBKLJCDJiBRRIkuRNUgxUopUIFVIHfI9cgI5h1xGupE7yAAygvyGvEcxlIGyUT3UDLVDuag3GoRGogvQZHQxmo8WoJvQcrQaPYw2oefQq2gP2o8+Q8cwwOgYBzPEbDAuxsNCsTgsCZNjy7EirAyrxhqwVqwDu4n1Y8+xdwQSgUXACTYEd0IgYR5BSFhMWE7YSKggHCQ0EdoJNwkDhFHCJyKTqEu0JroR+cQYYjIxh1hILCPWEo8TLxB7iEPENyQSiUMyJ7mQAkmxpFTSEtJG0m5SI+ksqZs0SBojk8naZGuyBzmULCAryIXkneTD5DPkG+Qh8lsKnWJAcaT4U+IoUspqShnlEOU05QZlmDJBVaOaUt2ooVQRNY9aQq2htlKvUYeoEzR1mjnNgxZJS6WtopXTGmgXaPdpr+h0uhHdlR5Ol9BX0svpR+iX6AP0dwwNhhWDx4hnKBmbGAcYZxl3GK+YTKYZ04sZx1QwNzHrmOeZD5lvVVgqtip8FZHKCpVKlSaVGyovVKmqpqreqgtV81XLVI+pXlN9rkZVM1PjqQnUlqtVqp1Q61MbU2epO6iHqmeob1Q/pH5Z/YkGWcNMw09DpFGgsV/jvMYgC2MZs3gsIWsNq4Z1gTXEJrHN2Xx2KruY/R27iz2qqaE5QzNKM1ezUvOUZj8H45hx+Jx0TgnnKKeX836K3hTvKeIpG6Y0TLkxZVxrqpaXllirSKtRq0frvTau7aedpr1Fu1n7gQ5Bx0onXCdHZ4/OBZ3nU9lT3acKpxZNPTr1ri6qa6UbobtEd79up+6Ynr5egJ5Mb6feeb3n+hx9L/1U/W36p/VHDFgGswwkBtsMzhg8xTVxbzwdL8fb8VFDXcNAQ6VhlWGX4YSRudE8o9VGjUYPjGnGXOMk423GbcajJgYmISZLTepN7ppSTbmmKaY7TDtMx83MzaLN1pk1mz0x1zLnm+eb15vft2BaeFostqi2uGVJsuRaplnutrxuhVo5WaVYVVpds0atna0l1rutu6cRp7lOk06rntZnw7Dxtsm2qbcZsOXYBtuutm22fWFnYhdnt8Wuw+6TvZN9un2N/T0HDYfZDqsdWh1+c7RyFDpWOt6azpzuP33F9JbpL2dYzxDP2DPjthPLKcRpnVOb00dnF2e5c4PziIuJS4LLLpc+Lpsbxt3IveRKdPVxXeF60vWdm7Obwu2o26/uNu5p7ofcn8w0nymeWTNz0MPIQ+BR5dE/C5+VMGvfrH5PQ0+BZ7XnIy9jL5FXrdewt6V3qvdh7xc+9j5yn+M+4zw33jLeWV/MN8C3yLfLT8Nvnl+F30N/I/9k/3r/0QCngCUBZwOJgUGBWwL7+Hp8Ib+OPzrbZfay2e1BjKC5QRVBj4KtguXBrSFoyOyQrSH355jOkc5pDoVQfujW0Adh5mGLw34MJ4WHhVeGP45wiFga0TGXNXfR3ENz30T6RJZE3ptnMU85ry1KNSo+qi5qPNo3ujS6P8YuZlnM1VidWElsSxw5LiquNm5svt/87fOH4p3iC+N7F5gvyF1weaHOwvSFpxapLhIsOpZATIhOOJTwQRAqqBaMJfITdyWOCnnCHcJnIi/RNtGI2ENcKh5O8kgqTXqS7JG8NXkkxTOlLOW5hCepkLxMDUzdmzqeFpp2IG0yPTq9MYOSkZBxQqohTZO2Z+pn5mZ2y6xlhbL+xW6Lty8elQfJa7OQrAVZLQq2QqboVFoo1yoHsmdlV2a/zYnKOZarnivN7cyzytuQN5zvn//tEsIS4ZK2pYZLVy0dWOa9rGo5sjxxedsK4xUFK4ZWBqw8uIq2Km3VT6vtV5eufr0mek1rgV7ByoLBtQFr6wtVCuWFfevc1+1dT1gvWd+1YfqGnRs+FYmKrhTbF5cVf9go3HjlG4dvyr+Z3JS0qavEuWTPZtJm6ebeLZ5bDpaql+aXDm4N2dq0Dd9WtO319kXbL5fNKNu7g7ZDuaO/PLi8ZafJzs07P1SkVPRU+lQ27tLdtWHX+G7R7ht7vPY07NXbW7z3/T7JvttVAVVN1WbVZftJ+7P3P66Jqun4lvttXa1ObXHtxwPSA/0HIw6217nU1R3SPVRSj9Yr60cOxx++/p3vdy0NNg1VjZzG4iNwRHnk6fcJ3/ceDTradox7rOEH0x92HWcdL2pCmvKaRptTmvtbYlu6T8w+0dbq3nr8R9sfD5w0PFl5SvNUyWna6YLTk2fyz4ydlZ19fi753GDborZ752PO32oPb++6EHTh0kX/i+c7vDvOXPK4dPKy2+UTV7hXmq86X23qdOo8/pPTT8e7nLuarrlca7nuer21e2b36RueN87d9L158Rb/1tWeOT3dvfN6b/fF9/XfFt1+cif9zsu72Xcn7q28T7xf9EDtQdlD3YfVP1v+3Njv3H9qwHeg89HcR/cGhYPP/pH1jw9DBY+Zj8uGDYbrnjg+OTniP3L96fynQ89kzyaeF/6i/suuFxYvfvjV69fO0ZjRoZfyl5O/bXyl/erA6xmv28bCxh6+yXgzMV70VvvtwXfcdx3vo98PT+R8IH8o/2j5sfVT0Kf7kxmTk/8EA5jz/GMzLdsAAAAgY0hSTQAAeiUAAICDAAD5/wAAgOkAAHUwAADqYAAAOpgAABdvkl/FRgAAAM9JREFUeNrs2+EJgzAQBtBccTIXcQ8HcA8XcbV0gjZiONKS9/1VAnl43KExaq2lJxHRt0B/4tvF1v5eZfIAAAAAAICZE60+2erz53EN3cC2r11zghIAAAAAAAAzzwGllJ/u89lzghIAAAAAAAATZ8nus71zRPb6SgAAAAAAAJgDnif7fUH2+koAAAAAAACYA/Jy4/u9OUAJAAAAAACAMYkb9/z1OcHzuJwTBAAAAAAAAB7OAa0+v+3r0P8GW33eEwAAAAAAAAB8zBsAAP//AwB6eysS2pA5KAAAAABJRU5ErkJggg=='; // MUST BE SAME DOMAIN!!!
image.onload = () => render(image);
}
function render(image) {
// Get A WebGL context
var canvas = document.getElementById('c');
var gl = canvas.getContext('webgl');
if (!gl) {
return;
}
// setup GLSL program
var program = webglUtils.createProgramFromScripts(gl, [
'2d-vertex-shader',
'2d-fragment-shader',
]);
gl.useProgram(program);
// look up where the vertex data needs to go.
var positionLocation = gl.getAttribLocation(program, 'a_position');
// look up uniform locations
var u_imageLoc = gl.getUniformLocation(program, 'u_image');
var u_matrixLoc = gl.getUniformLocation(program, 'u_matrix');
// provide texture coordinates for the rectangle.
var positionBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([
0.0,
0.0,
1.0,
0.0,
0.0,
1.0,
0.0,
1.0,
1.0,
0.0,
1.0,
1.0,
]),
gl.STATIC_DRAW,
);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
var texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
// Set the parameters so we can render any size image.
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
// Upload the image into the texture.
gl.texImage2D(
gl.TEXTURE_2D,
0,
gl.RGBA,
gl.RGBA,
gl.UNSIGNED_BYTE,
image,
);
var dstX = 20;
var dstY = 30;
var dstWidth = 64;
var dstHeight = 64;
// convert dst pixel coords to clipspace coords
var clipX = (dstX / gl.canvas.width) * 2 - 1;
var clipY = (dstY / gl.canvas.height) * -2 + 1;
var clipWidth = (dstWidth / gl.canvas.width) * 2;
var clipHeight = (dstHeight / gl.canvas.height) * -2;
// build a matrix that will stretch our
// unit quad to our desired size and location
gl.uniformMatrix3fv(u_matrixLoc, false, [
clipWidth,
0,
0,
0,
clipHeight,
0,
clipX,
clipY,
1,
]);
// Draw the rectangle.
gl.drawArrays(gl.TRIANGLES, 0, 6);
}
</script>
</body>
</html>

View File

@@ -0,0 +1,110 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>canvas webgl square</title>
</head>
<body>
<canvas
id="myCanvas"
width="200"
height="100"
style="border: 1px solid #000000"
>
</canvas>
<script id="vertex" type="x-shader">
attribute vec2 aVertexPosition;
void main() {
gl_Position = vec4(aVertexPosition, 0.0, 1.0);
}
</script>
<script id="fragment" type="x-shader">
#ifdef GL_ES
precision highp float;
#endif
uniform vec4 uColor;
void main() {
gl_FragColor = uColor;
}
</script>
<script>
// example from https://www.creativebloq.com/javascript/get-started-webgl-draw-square-7112981
const canvas = document.getElementById('myCanvas');
const ctx = canvas.getContext('webgl2');
ctx.viewport(0, 0, canvas.width, canvas.height);
ctx.clearColor(0, 0.5, 0, 1);
ctx.clear(ctx.COLOR_BUFFER_BIT);
const v = document.getElementById('vertex').firstChild.nodeValue;
const f = document.getElementById('fragment').firstChild.nodeValue;
const vs = ctx.createShader(ctx.VERTEX_SHADER);
ctx.shaderSource(vs, v);
ctx.compileShader(vs);
const fs = ctx.createShader(ctx.FRAGMENT_SHADER);
ctx.shaderSource(fs, f);
ctx.compileShader(fs);
program = ctx.createProgram();
ctx.attachShader(program, vs);
ctx.attachShader(program, fs);
ctx.linkProgram(program);
if (!ctx.getShaderParameter(vs, ctx.COMPILE_STATUS))
console.log(ctx.getShaderInfoLog(vs));
if (!ctx.getShaderParameter(fs, ctx.COMPILE_STATUS))
console.log(ctx.getShaderInfoLog(fs));
if (!ctx.getProgramParameter(program, ctx.LINK_STATUS))
console.log(ctx.getProgramInfoLog(program));
const aspect = canvas.width / canvas.height;
const vertices = new Float32Array([
-0.5,
0.5 * aspect,
0.5,
0.5 * aspect,
0.5,
-0.5 * aspect, // Triangle 1
-0.5,
0.5 * aspect,
0.5,
-0.5 * aspect,
-0.5,
-0.5 * aspect, // Triangle 2
]);
vbuffer = ctx.createBuffer();
ctx.bindBuffer(ctx.ARRAY_BUFFER, vbuffer);
ctx.bufferData(ctx.ARRAY_BUFFER, vertices, ctx.STATIC_DRAW);
itemSize = 2;
numItems = vertices.length / itemSize;
ctx.useProgram(program);
const uColor = ctx.getUniformLocation(program, 'uColor');
ctx.uniform4fv(uColor, [0.0, 0.3, 0.0, 1.0]);
const aVertexPosition = ctx.getAttribLocation(program, 'aVertexPosition');
ctx.enableVertexAttribArray(aVertexPosition);
ctx.vertexAttribPointer(
aVertexPosition,
itemSize,
ctx.FLOAT,
false,
0,
0,
);
ctx.drawArrays(ctx.TRIANGLES, 0, numItems);
</script>
</body>
</html>

View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>canvas</title>
</head>
<body>
<canvas
id="myCanvas"
width="200"
height="100"
style="border: 1px solid #000000"
>
</canvas>
<script>
setTimeout(() => {
const c = document.getElementById('myCanvas');
const ctx = c.getContext('webgl');
// Set clear color to red, fully opaque
ctx.clearColor(1.0, 0.0, 0.0, 1.0);
// Clear the color buffer with specified clear color
ctx.clear(ctx.COLOR_BUFFER_BIT);
}, 10);
</script>
</body>
</html>

View File

@@ -4,4 +4,5 @@
<ul>
<li></li>
</ul>
</body>
<canvas></canvas>
</body>

View File

@@ -1,14 +1,21 @@
import * as fs from 'fs';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as puppeteer from 'puppeteer';
import { assertSnapshot, launchPuppeteer } from './utils';
import {
assertSnapshot,
startServer,
getServerURL,
launchPuppeteer,
waitForRAF,
replaceLast,
} from './utils';
import { recordOptions, eventWithTime, EventType } from '../src/types';
import { visitSnapshot, NodeType } from 'rrweb-snapshot';
interface ISuite {
server: http.Server;
serverURL: string;
code: string;
browser: puppeteer.Browser;
}
@@ -17,39 +24,6 @@ interface IMimeType {
[key: string]: string;
}
const startServer = () =>
new Promise<http.Server>((resolve) => {
const mimeType: IMimeType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
};
const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!);
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
let pathname = path.join(__dirname, sanitizePath);
try {
const data = fs.readFileSync(pathname);
const ext = path.parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
setTimeout(() => {
res.end(data);
// mock delay
}, 100);
} catch (error) {
res.end();
}
});
s.listen(3030).on('listening', () => {
resolve(s);
});
});
describe('record integration tests', function (this: ISuite) {
jest.setTimeout(10_000);
@@ -59,7 +33,8 @@ describe('record integration tests', function (this: ISuite) {
): string => {
const filePath = path.resolve(__dirname, `./html/${fileName}`);
const html = fs.readFileSync(filePath, 'utf8');
return html.replace(
return replaceLast(
html,
'</body>',
`
<script>
@@ -85,11 +60,13 @@ describe('record integration tests', function (this: ISuite) {
};
let server: ISuite['server'];
let serverURL: string;
let code: ISuite['code'];
let browser: ISuite['browser'];
beforeAll(async () => {
server = await startServer();
serverURL = getServerURL(server);
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
@@ -197,7 +174,9 @@ describe('record integration tests', function (this: ISuite) {
it('can freeze mutations', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(getHtml.call(this, 'mutation-observer.html'));
await page.setContent(
getHtml.call(this, 'mutation-observer.html', { recordCanvas: true }),
);
await page.evaluate(() => {
const li = document.createElement('li');
@@ -209,6 +188,9 @@ describe('record integration tests', function (this: ISuite) {
await page.evaluate('rrweb.freezePage()');
await page.evaluate(() => {
document.body.setAttribute('test', 'bad');
const canvas = document.querySelector('canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl') as WebGLRenderingContext;
gl.getExtension('bad');
const ul = document.querySelector('ul') as HTMLUListElement;
const li = document.createElement('li');
li.setAttribute('bad-attr', 'bad');
@@ -216,6 +198,9 @@ describe('record integration tests', function (this: ISuite) {
ul.appendChild(li);
document.body.removeChild(ul);
});
await waitForRAF(page);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});
@@ -391,7 +376,7 @@ describe('record integration tests', function (this: ISuite) {
recordCanvas: true,
}),
);
await page.waitForTimeout(50);
await waitForRAF(page);
const snapshots = await page.evaluate('window.snapshots');
for (const event of snapshots) {
if (event.type === EventType.FullSnapshot) {
@@ -405,6 +390,19 @@ describe('record integration tests', function (this: ISuite) {
assertSnapshot(snapshots);
});
it('should record webgl canvas mutations', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
await page.setContent(
getHtml.call(this, 'canvas-webgl.html', {
recordCanvas: true,
}),
);
await page.waitForTimeout(50);
const snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});
it('will serialize node before record', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto('about:blank');
@@ -487,10 +485,17 @@ describe('record integration tests', function (this: ISuite) {
it('should nest record iframe', async () => {
const page: puppeteer.Page = await browser.newPage();
await page.goto(`http://localhost:3030/html`);
await page.goto(`${serverURL}/html`);
await page.setContent(getHtml.call(this, 'main.html'));
await page.waitForTimeout(500);
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 snapshots = await page.evaluate('window.snapshots');
assertSnapshot(snapshots);
});

View File

@@ -27,7 +27,14 @@ describe('unpack', () => {
});
it('stop on unknown data format', () => {
const consoleSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
expect(() => unpack('[""]')).toThrow('');
expect(consoleSpy).toHaveBeenCalled();
jest.resetAllMocks();
});
it('can unpack packed data', () => {

View File

@@ -0,0 +1,814 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`record webgl should batch events by RAF 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 1
}
]
},
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record changes to a canvas element 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record changes to a canvas element before the canvas gets added (webgl2) 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 5,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 9,
\\"type\\": 2,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
},
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record changes to a canvas element before the canvas gets added 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 0,
\\"texts\\": [],
\\"attributes\\": [],
\\"removes\\": [],
\\"adds\\": [
{
\\"parentId\\": 5,
\\"nextId\\": null,
\\"node\\": {
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 9
}
}
]
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 9,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
},
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record changes to a webgl2 canvas element 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 2,
\\"commands\\": [
{
\\"property\\": \\"clear\\",
\\"args\\": [
16384
]
}
]
}
}
]"
`;
exports[`record webgl will record webgl variables 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
},
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 1
}
]
}
]
}
}
]"
`;
exports[`record webgl will record webgl variables in reverse order 1`] = `
"[
{
\\"type\\": 4,
\\"data\\": {
\\"href\\": \\"about:blank\\",
\\"width\\": 1920,
\\"height\\": 1080
}
},
{
\\"type\\": 2,
\\"data\\": {
\\"node\\": {
\\"type\\": 0,
\\"childNodes\\": [
{
\\"type\\": 1,
\\"name\\": \\"html\\",
\\"publicId\\": \\"\\",
\\"systemId\\": \\"\\",
\\"id\\": 2
},
{
\\"type\\": 2,
\\"tagName\\": \\"html\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 2,
\\"tagName\\": \\"head\\",
\\"attributes\\": {},
\\"childNodes\\": [],
\\"id\\": 4
},
{
\\"type\\": 2,
\\"tagName\\": \\"body\\",
\\"attributes\\": {},
\\"childNodes\\": [
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\",
\\"id\\": 6
},
{
\\"type\\": 2,
\\"tagName\\": \\"canvas\\",
\\"attributes\\": {
\\"id\\": \\"canvas\\"
},
\\"childNodes\\": [],
\\"id\\": 7
},
{
\\"type\\": 3,
\\"textContent\\": \\"\\\\n \\\\n \\\\n \\",
\\"id\\": 8
}
],
\\"id\\": 5
}
],
\\"id\\": 3
}
],
\\"id\\": 1
},
\\"initialOffset\\": {
\\"left\\": 0,
\\"top\\": 0
}
}
},
{
\\"type\\": 3,
\\"data\\": {
\\"source\\": 9,
\\"id\\": 7,
\\"type\\": 1,
\\"commands\\": [
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"createProgram\\",
\\"args\\": []
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 1
}
]
},
{
\\"property\\": \\"linkProgram\\",
\\"args\\": [
{
\\"rr_type\\": \\"WebGLProgram\\",
\\"index\\": 0
}
]
}
]
}
}
]"
`;

View File

@@ -0,0 +1,179 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import { serializeArg } from '../../src/record/observers/canvas/serialize-args';
const createContext = () => {
const ctx = new WebGL2RenderingContext();
return ctx;
};
let context: WebGL2RenderingContext;
describe('serializeArg', () => {
beforeEach(() => {
context = createContext();
});
it('should serialize Float32Array values', async () => {
const float32Array = new Float32Array([-1, -1, 3, -1, -1, 3]);
const expected = {
rr_type: 'Float32Array',
args: [[-1, -1, 3, -1, -1, 3]],
};
expect(serializeArg(float32Array, window, context)).toStrictEqual(expected);
});
it('should serialize Float64Array values', async () => {
const float64Array = new Float64Array([-1, -1, 3, -1, -1, 3]);
const expected = {
rr_type: 'Float64Array',
args: [[-1, -1, 3, -1, -1, 3]],
};
expect(serializeArg(float64Array, window, context)).toStrictEqual(expected);
});
it('should serialize ArrayBuffer values', async () => {
const arrayBuffer = new Uint8Array([1, 2, 0, 4]).buffer;
const expected = {
rr_type: 'ArrayBuffer',
base64: 'AQIABA==',
};
expect(serializeArg(arrayBuffer, window, context)).toStrictEqual(expected);
});
it('should serialize Uint8Array values', async () => {
const object = new Uint8Array([1, 2, 0, 4]);
const expected = {
rr_type: 'Uint8Array',
args: [[1, 2, 0, 4]],
};
expect(serializeArg(object, window, context)).toStrictEqual(expected);
});
it('should serialize DataView values', async () => {
const dataView = new DataView(new ArrayBuffer(16), 0, 16);
const expected = {
rr_type: 'DataView',
args: [
{
rr_type: 'ArrayBuffer',
base64: 'AAAAAAAAAAAAAAAAAAAAAA==',
},
0,
16,
],
};
expect(serializeArg(dataView, window, context)).toStrictEqual(expected);
});
it('should leave arrays intact', async () => {
const array = [1, 2, 3, 4];
expect(serializeArg(array, window, context)).toStrictEqual(array);
});
it('should serialize complex objects', async () => {
const dataView = [new DataView(new ArrayBuffer(16), 0, 16), 5, 6];
const expected = [
{
rr_type: 'DataView',
args: [
{
rr_type: 'ArrayBuffer',
base64: 'AAAAAAAAAAAAAAAAAAAAAA==',
},
0,
16,
],
},
5,
6,
];
expect(serializeArg(dataView, window, context)).toStrictEqual(expected);
});
it('should serialize arraybuffer contents', async () => {
const buffer = new Float32Array([1, 2, 3, 4]).buffer;
const expected = {
rr_type: 'ArrayBuffer',
base64: 'AACAPwAAAEAAAEBAAACAQA==',
};
expect(serializeArg(buffer, window, context)).toStrictEqual(expected);
});
it('should leave null as-is', async () => {
expect(serializeArg(null, window, context)).toStrictEqual(null);
});
it('should support indexed variables', async () => {
const webGLProgram = new WebGLProgram();
expect(serializeArg(webGLProgram, window, context)).toStrictEqual({
rr_type: 'WebGLProgram',
index: 0,
});
const webGLProgram2 = new WebGLProgram();
expect(serializeArg(webGLProgram2, window, context)).toStrictEqual({
rr_type: 'WebGLProgram',
index: 1,
});
});
it('should support indexed variables grouped by context', async () => {
const context1 = createContext();
const webGLProgram1 = new WebGLProgram();
expect(serializeArg(webGLProgram1, window, context1)).toStrictEqual({
rr_type: 'WebGLProgram',
index: 0,
});
const context2 = createContext();
const webGLProgram2 = new WebGLProgram();
expect(serializeArg(webGLProgram2, window, context2)).toStrictEqual({
rr_type: 'WebGLProgram',
index: 0,
});
});
it('should support HTMLImageElements', async () => {
const image = new Image();
image.src = 'http://example.com/image.png';
expect(serializeArg(image, window, context)).toStrictEqual({
rr_type: 'HTMLImageElement',
src: 'http://example.com/image.png',
});
});
it('should serialize ImageData', async () => {
const arr = new Uint8ClampedArray(40000);
// Iterate through every pixel
for (let i = 0; i < arr.length; i += 4) {
arr[i + 0] = 0; // R value
arr[i + 1] = 190; // G value
arr[i + 2] = 0; // B value
arr[i + 3] = 255; // A value
}
// Initialize a new ImageData object
let imageData = new ImageData(arr, 200, 50);
const contents = Array.from(arr);
expect(serializeArg(imageData, window, context)).toStrictEqual({
rr_type: 'ImageData',
args: [
{
rr_type: 'Uint8ClampedArray',
args: [contents],
},
200,
50,
],
});
});
});

View File

@@ -0,0 +1,260 @@
/* tslint:disable no-console */
import * as fs from 'fs';
import * as path from 'path';
import * as puppeteer from 'puppeteer';
import {
recordOptions,
listenerHandler,
eventWithTime,
EventType,
IncrementalSource,
CanvasContext,
} from '../../src/types';
import { assertSnapshot, launchPuppeteer, waitForRAF } from '../utils';
import { ICanvas } from 'rrweb-snapshot';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
events: eventWithTime[];
}
interface IWindow extends Window {
rrweb: {
record: (
options: recordOptions<eventWithTime>,
) => listenerHandler | undefined;
addCustomEvent<T>(tag: string, payload: T): void;
};
emit: (e: eventWithTime) => undefined;
}
const setup = function (this: ISuite, content: string): ISuite {
const ctx = {} as ISuite;
beforeAll(async () => {
ctx.browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.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);
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 ctx.page.evaluate(() => {
const { record } = ((window as unknown) as IWindow).rrweb;
record({
recordCanvas: true,
emit: ((window as unknown) as IWindow).emit,
});
});
});
afterEach(async () => {
await ctx.page.close();
});
afterAll(async () => {
await ctx.browser.close();
});
return ctx;
};
describe('record webgl', function (this: ISuite) {
jest.setTimeout(100_000);
const ctx: ISuite = setup.call(
this,
`
<!DOCTYPE html>
<html>
<body>
<canvas id="canvas"></canvas>
</body>
</html>
`,
);
it('will record changes to a canvas element', async () => {
await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
var gl = canvas.getContext('webgl')!;
gl.clear(gl.COLOR_BUFFER_BIT);
});
await ctx.page.waitForTimeout(50);
const lastEvent = ctx.events[ctx.events.length - 1];
expect(lastEvent).toMatchObject({
data: {
source: IncrementalSource.CanvasMutation,
type: CanvasContext.WebGL,
commands: [
{
args: [16384],
property: 'clear',
},
],
},
});
assertSnapshot(ctx.events);
});
it('will record changes to a webgl2 canvas element', async () => {
await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
var gl = canvas.getContext('webgl2')!;
gl.clear(gl.COLOR_BUFFER_BIT);
});
await ctx.page.waitForTimeout(50);
const lastEvent = ctx.events[ctx.events.length - 1];
expect(lastEvent).toMatchObject({
data: {
source: IncrementalSource.CanvasMutation,
type: CanvasContext.WebGL2,
commands: [
{
args: [16384],
property: 'clear',
},
],
},
});
assertSnapshot(ctx.events);
});
it('will record changes to a canvas element before the canvas gets added', async () => {
await ctx.page.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);
assertSnapshot(ctx.events);
});
it('will record changes to a canvas element before the canvas gets added (webgl2)', async () => {
await ctx.page.evaluate(() => {
return new Promise<void>((resolve) => {
var canvas = document.createElement('canvas');
var gl = canvas.getContext('webgl2')!;
var program = gl.createProgram()!;
gl.linkProgram(program);
gl.clear(gl.COLOR_BUFFER_BIT);
setTimeout(() => {
document.body.appendChild(canvas);
resolve();
}, 10);
});
});
// FIXME: this wait deeply couples the test to the implementation
// When `pendingCanvasMutations` isn't run on requestAnimationFrame,
// we need to change this
await waitForRAF(ctx.page);
assertSnapshot(ctx.events);
});
it('will record webgl variables', async () => {
await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
var gl = canvas.getContext('webgl')!;
var program0 = gl.createProgram()!;
gl.linkProgram(program0);
var program1 = gl.createProgram()!;
gl.linkProgram(program1);
});
await ctx.page.waitForTimeout(50);
assertSnapshot(ctx.events);
});
it('will record webgl variables in reverse order', async () => {
await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
var gl = canvas.getContext('webgl')!;
var program0 = gl.createProgram()!;
var program1 = gl.createProgram()!;
// attach them in reverse order
gl.linkProgram(program1);
gl.linkProgram(program0);
});
await ctx.page.waitForTimeout(50);
assertSnapshot(ctx.events);
});
it('sets _context on canvas.getContext()', async () => {
const context = await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
canvas.getContext('webgl')!;
return (canvas as ICanvas).__context;
});
expect(context).toBe('webgl');
});
it('only sets _context on first canvas.getContext() call', async () => {
const context = await ctx.page.evaluate(() => {
var canvas = document.getElementById('canvas') as HTMLCanvasElement;
canvas.getContext('webgl');
canvas.getContext('2d'); // returns null
return (canvas as ICanvas).__context;
});
expect(context).toBe('webgl');
});
it('should batch events by RAF', async () => {
await ctx.page.evaluate(() => {
return new Promise<void>((resolve) => {
const canvas = document.getElementById('canvas') as HTMLCanvasElement;
const gl = canvas.getContext('webgl') as WebGLRenderingContext;
const program = gl.createProgram()!;
gl.linkProgram(program);
requestAnimationFrame(() => {
const program2 = gl.createProgram()!;
gl.linkProgram(program2);
gl.clear(gl.COLOR_BUFFER_BIT);
requestAnimationFrame(() => {
gl.clear(gl.COLOR_BUFFER_BIT);
resolve();
});
});
});
});
await ctx.page.waitForTimeout(50);
assertSnapshot(ctx.events);
expect(ctx.events.length).toEqual(5);
});
});

View File

@@ -0,0 +1,133 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import { deserializeArg } from '../../src/replay/canvas/webgl';
let context: WebGLRenderingContext | WebGL2RenderingContext;
describe('deserializeArg', () => {
beforeEach(() => {
context = new WebGL2RenderingContext();
});
it('should deserialize Float32Array values', async () => {
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'Float32Array',
args: [[-1, -1, 3, -1, -1, 3]],
}),
).toEqual(new Float32Array([-1, -1, 3, -1, -1, 3]));
});
it('should deserialize Float64Array values', async () => {
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'Float64Array',
args: [[-1, -1, 3, -1, -1, 3]],
}),
).toEqual(new Float64Array([-1, -1, 3, -1, -1, 3]));
});
it('should deserialize ArrayBuffer values', async () => {
const contents = [1, 2, 0, 4];
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'ArrayBuffer',
base64: 'AQIABA==',
}),
).toStrictEqual(new Uint8Array(contents).buffer);
});
it('should deserialize DataView values', async () => {
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'DataView',
args: [
{
rr_type: 'ArrayBuffer',
base64: 'AAAAAAAAAAAAAAAAAAAAAA==',
},
0,
16,
],
}),
).toStrictEqual(new DataView(new ArrayBuffer(16), 0, 16));
});
it('should leave arrays intact', async () => {
const array = [1, 2, 3, 4];
expect(deserializeArg(new Map(), context)(array)).toEqual(array);
});
it('should deserialize complex objects', async () => {
const serializedArg = [
{
rr_type: 'DataView',
args: [
{
rr_type: 'ArrayBuffer',
args: [16],
},
0,
16,
],
},
5,
6,
];
expect(deserializeArg(new Map(), context)(serializedArg)).toStrictEqual([
new DataView(new ArrayBuffer(16), 0, 16),
5,
6,
]);
});
it('should leave null as-is', async () => {
expect(deserializeArg(new Map(), context)(null)).toStrictEqual(null);
});
it('should support HTMLImageElements', async () => {
const image = new Image();
image.src = 'http://example.com/image.png';
expect(
deserializeArg(
new Map(),
context,
)({
rr_type: 'HTMLImageElement',
src: 'http://example.com/image.png',
}),
).toStrictEqual(image);
});
it('should return image from imageMap for HTMLImageElements', async () => {
const image = new Image();
image.src = 'http://example.com/image.png';
const imageMap = new Map();
imageMap.set(image.src, image);
expect(
deserializeArg(
imageMap,
context,
)({
rr_type: 'HTMLImageElement',
src: 'http://example.com/image.png',
}),
).toBe(image);
});
});

View File

@@ -0,0 +1,125 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import { Replayer } from '../../src/replay';
import {} from '../../src/types';
import {
CanvasContext,
SerializedWebGlArg,
IncrementalSource,
EventType,
eventWithTime,
} from '../../src/types';
let replayer: Replayer;
const canvasMutationEventWithArgs = (
args: SerializedWebGlArg[],
): eventWithTime => {
return {
timestamp: 100,
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.CanvasMutation,
property: 'x',
args,
id: 1,
type: CanvasContext.WebGL,
},
};
};
const event = (): eventWithTime => {
return {
timestamp: 1,
type: EventType.DomContentLoaded,
data: {},
};
};
describe('preloadAllImages', () => {
beforeEach(() => {
replayer = new Replayer(
// Get around the error "Replayer need at least 2 events."
[event(), event()],
);
});
it('should preload image', () => {
replayer.service.state.context.events = [
canvasMutationEventWithArgs([
{
rr_type: 'HTMLImageElement',
src: 'http://example.com',
},
]),
];
(replayer as any).preloadAllImages();
const expectedImage = new Image();
expectedImage.src = 'http://example.com';
expect((replayer as any).imageMap.get('http://example.com')).toEqual(
expectedImage,
);
});
it('should preload nested image', () => {
replayer.service.state.context.events = [
canvasMutationEventWithArgs([
{
rr_type: 'something',
args: [
{
rr_type: 'HTMLImageElement',
src: 'http://example.com',
},
],
},
]),
];
(replayer as any).preloadAllImages();
const expectedImage = new Image();
expectedImage.src = 'http://example.com';
expect((replayer as any).imageMap.get('http://example.com')).toEqual(
expectedImage,
);
});
it('should preload multiple images', () => {
replayer.service.state.context.events = [
canvasMutationEventWithArgs([
{
rr_type: 'HTMLImageElement',
src: 'http://example.com/img1.png',
},
{
rr_type: 'HTMLImageElement',
src: 'http://example.com/img2.png',
},
]),
];
(replayer as any).preloadAllImages();
const expectedImage1 = new Image();
expectedImage1.src = 'http://example.com/img1.png';
expect(
(replayer as any).imageMap.get('http://example.com/img1.png'),
).toEqual(expectedImage1);
const expectedImage2 = new Image();
expectedImage1.src = 'http://example.com/img2.png';
expect(
(replayer as any).imageMap.get('http://example.com/img2.png'),
).toEqual(expectedImage1);
});
});

View File

@@ -0,0 +1,47 @@
/**
* @jest-environment jsdom
*/
import { polyfillWebGLGlobals } from '../utils';
polyfillWebGLGlobals();
import webglMutation, { variableListFor } from '../../src/replay/canvas/webgl';
import { CanvasContext } from '../../src/types';
let canvas: HTMLCanvasElement;
describe('webglMutation', () => {
beforeEach(() => {
canvas = document.createElement('canvas');
});
afterEach(() => {
jest.clearAllMocks();
});
it('should create webgl variables', async () => {
const createShaderMock = jest.fn().mockImplementation(() => {
return new WebGLShader();
});
const context = ({
createShader: createShaderMock,
} as unknown) as WebGLRenderingContext;
jest.spyOn(canvas, 'getContext').mockImplementation(() => {
return context;
});
expect(variableListFor(context, 'WebGLShader')).toHaveLength(0);
webglMutation({
mutation: {
property: 'createShader',
args: [35633],
},
type: CanvasContext.WebGL,
target: canvas,
imageMap: new Map(),
errorHandler: () => {},
});
expect(createShaderMock).toHaveBeenCalledWith(35633);
expect(variableListFor(context, 'WebGLShader')).toHaveLength(1);
});
});

View File

@@ -0,0 +1,66 @@
import * as fs from 'fs';
import * as path from 'path';
import { assertDomSnapshot, launchPuppeteer } from '../utils';
import { toMatchImageSnapshot } from 'jest-image-snapshot';
import * as puppeteer from 'puppeteer';
import events from '../events/webgl';
interface ISuite {
code: string;
browser: puppeteer.Browser;
page: puppeteer.Page;
}
expect.extend({ toMatchImageSnapshot });
describe('replayer', function () {
jest.setTimeout(10_000);
let code: ISuite['code'];
let browser: ISuite['browser'];
let page: ISuite['page'];
beforeAll(async () => {
browser = await launchPuppeteer();
const bundlePath = path.resolve(__dirname, '../../dist/rrweb.min.js');
code = fs.readFileSync(bundlePath, 'utf8');
});
beforeEach(async () => {
page = await browser.newPage();
await page.goto('about:blank');
// mouse cursor canvas is large and pushes the replayer below the fold
// lets hide it...
await page.addStyleTag({
content: '.replayer-mouse-tail{display: none !important;}',
});
await page.evaluate(code);
await page.evaluate(`let events = ${JSON.stringify(events)}`);
page.on('console', (msg) => console.log('PAGE LOG:', msg.text()));
});
afterEach(async () => {
await page.close();
});
afterAll(async () => {
await browser.close();
});
describe('webgl', () => {
it('should output simple webgl object', async () => {
await page.evaluate(`
const { Replayer } = rrweb;
const replayer = new Replayer(events, {
UNSAFE_replayCanvas: true,
});
replayer.play(2500);
`);
const image = await page.screenshot();
expect(image).toMatchImageSnapshot();
});
});
});

View File

@@ -7,6 +7,10 @@ import {
} from '../src/types';
import * as puppeteer from 'puppeteer';
import { format } from 'prettier';
import * as path from 'path';
import * as http from 'http';
import * as url from 'url';
import * as fs from 'fs';
export async function launchPuppeteer() {
return await puppeteer.launch({
@@ -15,10 +19,65 @@ export async function launchPuppeteer() {
width: 1920,
height: 1080,
},
// devtools: true,
args: ['--no-sandbox'],
});
}
interface IMimeType {
[key: string]: string;
}
export const startServer = (defaultPort: number = 3030) =>
new Promise<http.Server>((resolve) => {
const mimeType: IMimeType = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
};
const s = http.createServer((req, res) => {
const parsedUrl = url.parse(req.url!);
const sanitizePath = path
.normalize(parsedUrl.pathname!)
.replace(/^(\.\.[\/\\])+/, '');
let pathname = path.join(__dirname, sanitizePath);
try {
const data = fs.readFileSync(pathname);
const ext = path.parse(pathname).ext;
res.setHeader('Content-type', mimeType[ext] || 'text/plain');
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET');
res.setHeader('Access-Control-Allow-Headers', 'Content-type');
setTimeout(() => {
res.end(data);
// mock delay
}, 100);
} catch (error) {
res.end();
}
});
s.listen(defaultPort)
.on('listening', () => {
resolve(s);
})
.on('error', (e) => {
console.log('port in use, trying next one');
s.listen().on('listening', () => {
resolve(s);
});
});
});
export function getServerURL(server: http.Server): string {
const address = server.address();
if (address && typeof address !== 'string') {
return `http://localhost:${address.port}`;
} else {
return `${address}`;
}
}
/**
* Puppeteer may cast random mouse move which make our tests flaky.
* So we only do snapshot test with filtered events.
@@ -133,9 +192,18 @@ function stringifyDomSnapshot(mhtml: string): string {
}
export function assertSnapshot(snapshots: eventWithTime[]) {
expect(snapshots).toBeDefined();
expect(stringifySnapshots(snapshots)).toMatchSnapshot();
}
export function replaceLast(str: string, find: string, replace: string) {
const index = str.lastIndexOf(find);
if (index === -1) {
return str;
}
return str.substring(0, index) + replace + str.substring(index + find.length);
}
export async function assertDomSnapshot(
page: puppeteer.Page,
filename: string,
@@ -338,3 +406,86 @@ export const sampleStyleSheetRemoveEvents: eventWithTime[] = [
timestamp: now + 2000,
},
];
export const polyfillWebGLGlobals = () => {
// polyfill as jsdom does not have support for these classes
// consider replacing with https://www.npmjs.com/package/canvas
class WebGLActiveInfo {
constructor() {}
}
global.WebGLActiveInfo = WebGLActiveInfo as any;
class WebGLBuffer {
constructor() {}
}
global.WebGLBuffer = WebGLBuffer as any;
class WebGLFramebuffer {
constructor() {}
}
global.WebGLFramebuffer = WebGLFramebuffer as any;
class WebGLProgram {
constructor() {}
}
global.WebGLProgram = WebGLProgram as any;
class WebGLRenderbuffer {
constructor() {}
}
global.WebGLRenderbuffer = WebGLRenderbuffer as any;
class WebGLShader {
constructor() {}
}
global.WebGLShader = WebGLShader as any;
class WebGLShaderPrecisionFormat {
constructor() {}
}
global.WebGLShaderPrecisionFormat = WebGLShaderPrecisionFormat as any;
class WebGLTexture {
constructor() {}
}
global.WebGLTexture = WebGLTexture as any;
class WebGLUniformLocation {
constructor() {}
}
global.WebGLUniformLocation = WebGLUniformLocation as any;
class WebGLVertexArrayObject {
constructor() {}
}
global.WebGLVertexArrayObject = WebGLVertexArrayObject as any;
class ImageData {
public data: Uint8ClampedArray;
public width: number;
public height: number;
constructor(data: Uint8ClampedArray, width: number, height: number) {
this.data = data;
this.width = width;
this.height = height;
}
}
global.ImageData = ImageData as any;
class WebGL2RenderingContext {
constructor() {}
}
global.WebGL2RenderingContext = WebGL2RenderingContext as any;
};
export async function waitForRAF(page: puppeteer.Page) {
return await page.evaluate(() => {
return new Promise((resolve) => {
requestAnimationFrame(() => {
requestAnimationFrame(resolve);
});
});
});
}

View File

@@ -13,5 +13,9 @@
"downlevelIteration": true
},
"exclude": ["test"],
"include": ["src", "node_modules/@types/css-font-loading-module/index.d.ts"]
"include": [
"src",
"node_modules/@types/css-font-loading-module/index.d.ts",
"node_modules/@types/jest-image-snapshot/index.d.ts"
]
}

View File

@@ -1,6 +1,7 @@
import { MaskInputOptions, SlimDOMOptions, MaskTextFn, MaskInputFn } from 'rrweb-snapshot';
import { mutationRecord, blockClass, maskTextClass, mutationCallBack, Mirror } from '../types';
import { IframeManager } from './iframe-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
import { ShadowDomManager } from './shadow-dom-manager';
export default class MutationBuffer {
private frozen;
@@ -29,12 +30,14 @@ export default class MutationBuffer {
private mirror;
private iframeManager;
private shadowDomManager;
init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, maskInputFn: MaskInputFn | undefined, recordCanvas: boolean, inlineImages: boolean, slimDOMOptions: SlimDOMOptions, doc: Document, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager): void;
private canvasManager;
init(cb: mutationCallBack, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, maskInputFn: MaskInputFn | undefined, recordCanvas: boolean, inlineImages: boolean, slimDOMOptions: SlimDOMOptions, doc: Document, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, canvasManager: CanvasManager): void;
freeze(): void;
unfreeze(): void;
isFrozen(): boolean;
lock(): void;
unlock(): void;
reset(): void;
processMutations: (mutations: mutationRecord[]) => void;
emit: () => void;
private processMutation;

View File

@@ -3,8 +3,9 @@ import { mutationCallBack, observerParam, listenerHandler, scrollCallback, block
import MutationBuffer from './mutation';
import { IframeManager } from './iframe-manager';
import { ShadowDomManager } from './shadow-dom-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
export declare const mutationBuffers: MutationBuffer[];
export declare function initMutationObserver(cb: mutationCallBack, doc: Document, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, maskInputFn: MaskInputFn | undefined, recordCanvas: boolean, inlineImages: boolean, slimDOMOptions: SlimDOMOptions, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, rootEl: Node): MutationObserver;
export declare function initMutationObserver(cb: mutationCallBack, doc: Document, blockClass: blockClass, blockSelector: string | null, maskTextClass: maskTextClass, maskTextSelector: string | null, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, maskTextFn: MaskTextFn | undefined, maskInputFn: MaskInputFn | undefined, recordCanvas: boolean, inlineImages: boolean, slimDOMOptions: SlimDOMOptions, mirror: Mirror, iframeManager: IframeManager, shadowDomManager: ShadowDomManager, canvasManager: CanvasManager, rootEl: Node): MutationObserver;
export declare function initScrollObserver(cb: scrollCallback, doc: Document, mirror: Mirror, blockClass: blockClass, sampling: SamplingStrategy): listenerHandler;
export declare const INPUT_TAGS: string[];
export declare function initObservers(o: observerParam, hooks?: hooksParam): listenerHandler;

View File

@@ -0,0 +1,2 @@
import { blockClass, canvasManagerMutationCallback, IWindow, listenerHandler, Mirror } from '../../../types';
export default function initCanvas2DMutationObserver(cb: canvasManagerMutationCallback, win: IWindow, blockClass: blockClass, mirror: Mirror): listenerHandler;

View File

@@ -0,0 +1,31 @@
import { blockClass, canvasMutationCallback, IWindow, Mirror } from '../../../types';
export declare type RafStamps = {
latestId: number;
invokeId: number | null;
};
export declare class CanvasManager {
private pendingCanvasMutations;
private rafStamps;
private mirror;
private mutationCb;
private resetObservers;
private frozen;
private locked;
reset(): void;
freeze(): void;
unfreeze(): void;
lock(): void;
unlock(): void;
constructor(options: {
mutationCb: canvasMutationCallback;
win: IWindow;
blockClass: blockClass;
mirror: Mirror;
});
private processMutation;
private initCanvasMutationObserver;
private startPendingCanvasMutationFlusher;
private startRAFTimestamping;
flushPendingCanvasMutations(): void;
flushPendingCanvasMutationFor(canvas: HTMLCanvasElement, id: number): void;
}

View File

@@ -0,0 +1,2 @@
import { blockClass, IWindow, listenerHandler } from '../../../types';
export default function initCanvasContextObserver(win: IWindow, blockClass: blockClass): listenerHandler;

View File

@@ -0,0 +1,6 @@
import { IWindow, SerializedWebGlArg } from '../../../types';
export declare function variableListFor(ctx: WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[];
export declare const saveWebGLVar: (value: any, win: IWindow, ctx: WebGL2RenderingContext | WebGLRenderingContext) => number | void;
export declare function serializeArg(value: any, win: IWindow, ctx: WebGL2RenderingContext | WebGLRenderingContext): SerializedWebGlArg;
export declare const serializeArgs: (args: Array<any>, win: IWindow, ctx: WebGLRenderingContext | WebGL2RenderingContext) => SerializedWebGlArg[];
export declare const isInstanceOfWebGLObject: (value: any, win: IWindow) => value is WebGLShader | WebGLBuffer | WebGLVertexArrayObject | WebGLTexture | WebGLProgram | WebGLActiveInfo | WebGLUniformLocation | WebGLFramebuffer | WebGLRenderbuffer | WebGLShaderPrecisionFormat;

View File

@@ -0,0 +1,2 @@
import { blockClass, canvasManagerMutationCallback, IWindow, listenerHandler, Mirror } from '../../../types';
export default function initCanvasWebGLMutationObserver(cb: canvasManagerMutationCallback, win: IWindow, blockClass: blockClass, mirror: Mirror): listenerHandler;

View File

@@ -0,0 +1,2 @@
import { blockClass, canvasMutationCallback, IWindow, listenerHandler, Mirror } from '../../../types';
export default function initCanvasWebGLMutationObserver(cb: canvasMutationCallback, win: IWindow, blockClass: blockClass, mirror: Mirror): listenerHandler;

View File

@@ -1,6 +1,7 @@
import { mutationCallBack, blockClass, maskTextClass, Mirror, scrollCallback, SamplingStrategy } from '../types';
import { MaskInputOptions, SlimDOMOptions, MaskTextFn, MaskInputFn } from 'rrweb-snapshot';
import { IframeManager } from './iframe-manager';
import { CanvasManager } from './observers/canvas/canvas-manager';
declare type BypassOptions = {
blockClass: blockClass;
blockSelector: string | null;
@@ -15,6 +16,7 @@ declare type BypassOptions = {
sampling: SamplingStrategy;
slimDOMOptions: SlimDOMOptions;
iframeManager: IframeManager;
canvasManager: CanvasManager;
};
export declare class ShadowDomManager {
private mutationCb;

View File

@@ -0,0 +1,9 @@
import { Replayer } from '../';
import { canvasMutationCommand } from '../../types';
export default function canvasMutation({ event, mutation, target, imageMap, errorHandler, }: {
event: Parameters<Replayer['applyIncremental']>[0];
mutation: canvasMutationCommand;
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void;

View File

@@ -0,0 +1,9 @@
import { Replayer } from '..';
import { canvasMutationData } from '../../types';
export default function canvasMutation({ event, mutation, target, imageMap, errorHandler, }: {
event: Parameters<Replayer['applyIncremental']>[0];
mutation: canvasMutationData;
target: HTMLCanvasElement;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void;

View File

@@ -0,0 +1,11 @@
import { Replayer } from '../';
import { CanvasContext, canvasMutationCommand, SerializedWebGlArg } from '../../types';
export declare function variableListFor(ctx: WebGLRenderingContext | WebGL2RenderingContext, ctor: string): any[];
export declare function deserializeArg(imageMap: Replayer['imageMap'], ctx: WebGLRenderingContext | WebGL2RenderingContext): (arg: SerializedWebGlArg) => any;
export default function webglMutation({ mutation, target, type, imageMap, errorHandler, }: {
mutation: canvasMutationCommand;
target: HTMLCanvasElement;
type: CanvasContext;
imageMap: Replayer['imageMap'];
errorHandler: Replayer['warnCanvasMutationFailed'];
}): void;

View File

@@ -51,7 +51,10 @@ export declare class Replayer {
private attachDocumentToIframe;
private collectIframeAndAttachDocument;
private waitForStylesheetLoad;
private hasImageArg;
private getImageArgs;
private preloadAllImages;
private preloadImages;
private applyIncremental;
private applyMutation;
private applyScroll;

View File

@@ -4,6 +4,7 @@ import { PackFn, UnpackFn } from './packer/base';
import { IframeManager } from './record/iframe-manager';
import { ShadowDomManager } from './record/shadow-dom-manager';
import type { Replayer } from './replay';
import { CanvasManager } from './record/observers/canvas/canvas-manager';
export declare enum EventType {
DomContentLoaded = 0,
Load = 1,
@@ -187,6 +188,7 @@ export declare type observerParam = {
mirror: Mirror;
iframeManager: IframeManager;
shadowDomManager: ShadowDomManager;
canvasManager: CanvasManager;
plugins: Array<{
observer: Function;
callback: Function;
@@ -283,6 +285,24 @@ export declare enum MouseInteractions {
TouchEnd = 9,
TouchCancel = 10
}
export declare enum CanvasContext {
'2D' = 0,
WebGL = 1,
WebGL2 = 2
}
export declare type SerializedWebGlArg = {
rr_type: 'ArrayBuffer';
base64: string;
} | {
rr_type: string;
src: string;
} | {
rr_type: string;
args: Array<SerializedWebGlArg>;
} | {
rr_type: string;
index: number;
} | string | number | boolean | null | SerializedWebGlArg[];
declare type mouseInteractionParam = {
type: MouseInteractions;
id: number;
@@ -322,13 +342,24 @@ export declare type styleDeclarationParam = {
};
};
export declare type styleDeclarationCallback = (s: styleDeclarationParam) => void;
export declare type canvasMutationCallback = (p: canvasMutationParam) => void;
export declare type canvasMutationParam = {
id: number;
export declare type canvasMutationCommand = {
property: string;
args: Array<unknown>;
setter?: true;
};
export declare type canvasMutationParam = {
id: number;
type: CanvasContext;
commands: canvasMutationCommand[];
} | ({
id: number;
type: CanvasContext;
} & canvasMutationCommand);
export declare type canvasMutationWithType = {
type: CanvasContext;
} & canvasMutationCommand;
export declare type canvasMutationCallback = (p: canvasMutationParam) => void;
export declare type canvasManagerMutationCallback = (target: HTMLCanvasElement, p: canvasMutationWithType) => void;
export declare type fontParam = {
family: string;
fontSource: string;

242
yarn.lock
View File

@@ -1948,18 +1948,27 @@
dependencies:
"@types/istanbul-lib-report" "*"
"@types/jest@^27.0.1":
version "27.4.0"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.0.tgz#037ab8b872067cae842a320841693080f9cb84ed"
integrity sha512-gHl8XuC1RZ8H2j5sHv/JqsaxXkDDM9iDOgu0Wp8sjs4u/snb2PVehyWXJPr+ORA0RPpgw231mnutWI1+0hgjIQ==
"@types/jest-image-snapshot@^4.3.1":
version "4.3.1"
resolved "https://registry.yarnpkg.com/@types/jest-image-snapshot/-/jest-image-snapshot-4.3.1.tgz#1382e9e155d6e29af0a81efce1056aaba92110c9"
integrity sha512-WDdUruGF14C53axe/mNDgQP2YIhtcwXrwmmVP8eOGyfNTVD+FbxWjWR7RTU+lzEy4K6V6+z7nkVDm/auI/r3xQ==
dependencies:
"@types/jest" "*"
"@types/pixelmatch" "*"
ssim.js "^3.1.1"
"@types/jest@*", "@types/jest@^27.0.2":
version "27.0.2"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.0.2.tgz#ac383c4d4aaddd29bbf2b916d8d105c304a5fcd7"
integrity sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==
dependencies:
jest-diff "^27.0.0"
pretty-format "^27.0.0"
"@types/jest@^27.0.2":
version "27.0.2"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.0.2.tgz#ac383c4d4aaddd29bbf2b916d8d105c304a5fcd7"
integrity sha512-4dRxkS/AFX0c5XW6IPMNOydLn2tEhNhJV7DnYK+0bjoJZ+QTmfucBlihX7aoEsh/ocYtkLC73UbnBXBXIxsULA==
"@types/jest@^27.0.1":
version "27.4.0"
resolved "https://registry.yarnpkg.com/@types/jest/-/jest-27.4.0.tgz#037ab8b872067cae842a320841693080f9cb84ed"
integrity sha512-gHl8XuC1RZ8H2j5sHv/JqsaxXkDDM9iDOgu0Wp8sjs4u/snb2PVehyWXJPr+ORA0RPpgw231mnutWI1+0hgjIQ==
dependencies:
jest-diff "^27.0.0"
pretty-format "^27.0.0"
@@ -2023,6 +2032,13 @@
resolved "https://registry.yarnpkg.com/@types/parse5/-/parse5-6.0.1.tgz#f8ae4fbcd2b9ba4ff934698e28778961f9cb22ca"
integrity sha512-ARATsLdrGPUnaBvxLhUlnltcMgn7pQG312S8ccdYlnyijabrX9RN/KN/iGj9Am96CoW8e/K9628BA7Bv4XHdrA==
"@types/pixelmatch@*":
version "5.2.4"
resolved "https://registry.yarnpkg.com/@types/pixelmatch/-/pixelmatch-5.2.4.tgz#ca145cc5ede1388c71c68edf2d1f5190e5ddd0f6"
integrity sha512-HDaSHIAv9kwpMN7zlmwfTv6gax0PiporJOipcrGsVNF3Ba+kryOZc0Pio5pn6NhisgWr7TaajlPEKTbTAypIBQ==
dependencies:
"@types/node" "*"
"@types/prettier@^2.1.5":
version "2.4.1"
resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.4.1.tgz#e1303048d5389563e130f5bdd89d37a99acb75eb"
@@ -2045,7 +2061,7 @@
dependencies:
"@types/node" "*"
"@types/puppeteer@^5.4.3":
"@types/puppeteer@^5.4.4":
version "5.4.4"
resolved "https://registry.yarnpkg.com/@types/puppeteer/-/puppeteer-5.4.4.tgz#e92abeccc4f46207c3e1b38934a1246be080ccd0"
integrity sha512-3Nau+qi69CN55VwZb0ATtdUAlYlqOOQ3OfQfq0Hqgc4JMFXiQT/XInlwQ9g6LbicDslE6loIFsXFklGh5XmI6Q==
@@ -2743,6 +2759,11 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
base64-arraybuffer@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/base64-arraybuffer/-/base64-arraybuffer-1.0.1.tgz#87bd13525626db4a9838e00a508c2b73efcf348c"
integrity sha512-vFIUq7FdLtjZMhATwDul5RZWv2jpXQ09Pd6jcVEOvIsqCWTRFD/ONHNfyOS8dA/Ippi5dsIgpyKWKZaAKZltbA==
base64-js@^1.3.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
@@ -3291,9 +3312,9 @@ color-name@^1.0.0, color-name@~1.1.4:
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
color-string@^1.6.0:
version "1.6.0"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.6.0.tgz#c3915f61fe267672cb7e1e064c9d692219f6c312"
integrity sha512-c/hGS+kRWJutUBEngKKmk4iH3sD59MBkoxVapS/0wgpCz2u7XsNloxknyvBhzwEs1IbV36D9PwqLPJ2DTu3vMA==
version "1.8.2"
resolved "https://registry.yarnpkg.com/color-string/-/color-string-1.8.2.tgz#08bd49fa5f3889c27b0c670052ed746dd7a671de"
integrity sha512-w5ZkKRdLsc5NOYsmnpS2DpyRW71npwZGwbRpLrJTuqjfTs2Bhrba7UiV59IX9siBlCPl2pne5NtiwnVWUzvYFA==
dependencies:
color-name "^1.0.0"
simple-swizzle "^0.2.2"
@@ -4174,7 +4195,33 @@ error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
es-abstract@^1.17.2, es-abstract@^1.18.0-next.2, es-abstract@^1.18.2:
es-abstract@^1.17.2, es-abstract@^1.19.1:
version "1.19.1"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.19.1.tgz#d4885796876916959de78edaa0df456627115ec3"
integrity sha512-2vJ6tjA/UfqLm2MPs7jxVybLoB8i1t1Jd9R3kISld20sIxPcTbLuggQOUxeWeAvIUkduv/CfMjuh4WmiXr2v9w==
dependencies:
call-bind "^1.0.2"
es-to-primitive "^1.2.1"
function-bind "^1.1.1"
get-intrinsic "^1.1.1"
get-symbol-description "^1.0.0"
has "^1.0.3"
has-symbols "^1.0.2"
internal-slot "^1.0.3"
is-callable "^1.2.4"
is-negative-zero "^2.0.1"
is-regex "^1.1.4"
is-shared-array-buffer "^1.0.1"
is-string "^1.0.7"
is-weakref "^1.0.1"
object-inspect "^1.11.0"
object-keys "^1.1.1"
object.assign "^4.1.2"
string.prototype.trimend "^1.0.4"
string.prototype.trimstart "^1.0.4"
unbox-primitive "^1.0.1"
es-abstract@^1.18.0-next.2:
version "1.18.3"
resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.18.3.tgz#25c4c3380a27aa203c44b2b685bba94da31b63e0"
integrity sha512-nQIr12dxV7SSxE6r6f1l3DtAeEYdsGpps13dR0TwJg1S8gyp4ZPgy3FZcHBgbiQqnoqSTb+oC+kO4UQ0C/J8vw==
@@ -4898,7 +4945,7 @@ get-caller-file@^2.0.5:
resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e"
integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==
get-intrinsic@^1.0.2, get-intrinsic@^1.1.1:
get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@^1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.1.tgz#15f59f376f855c446963948f0d24cd3637b4abc6"
integrity sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
@@ -4932,6 +4979,11 @@ get-port@^5.1.1:
resolved "https://registry.yarnpkg.com/get-port/-/get-port-5.1.1.tgz#0469ed07563479de6efb986baf053dcd7d4e3193"
integrity sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==
get-stdin@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/get-stdin/-/get-stdin-5.0.1.tgz#122e161591e21ff4c52530305693f20e6393a398"
integrity sha1-Ei4WFZHiH/TFJTAwVpPyDmOTo5g=
get-stream@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14"
@@ -4949,6 +5001,14 @@ get-stream@^6.0.0:
resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-6.0.1.tgz#a262d8eef67aced57c2852ad6167526a43cbf7b7"
integrity sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==
get-symbol-description@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.0.tgz#7fdb81c900101fbd564dd5f1a30af5aadc1e58d6"
integrity sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==
dependencies:
call-bind "^1.0.2"
get-intrinsic "^1.1.1"
getpass@^0.1.1:
version "0.1.7"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
@@ -5092,6 +5152,11 @@ globby@^6.1.0:
pify "^2.0.0"
pinkie-promise "^2.0.0"
glur@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/glur/-/glur-1.1.2.tgz#f20ea36db103bfc292343921f1f91e83c3467689"
integrity sha1-8g6jbbEDv8KSNDkh8fkeg8NGdok=
got@^6.7.1:
version "6.7.1"
resolved "https://registry.yarnpkg.com/got/-/got-6.7.1.tgz#240cd05785a9a18e561dc1b44b41c763ef1e8db0"
@@ -5149,6 +5214,11 @@ hard-rejection@^2.1.0:
resolved "https://registry.yarnpkg.com/hard-rejection/-/hard-rejection-2.1.0.tgz#1c6eda5c1685c63942766d79bb40ae773cecd883"
integrity sha512-VIZB+ibDhx7ObhAe7OVtoEbuP4h/MuOTHJ+J8h/eBXotJYl0fBgR72xDFCKgIh22OJZIOVNxBMWuhAr10r8HdA==
harmony-reflect@^1.4.6:
version "1.6.2"
resolved "https://registry.yarnpkg.com/harmony-reflect/-/harmony-reflect-1.6.2.tgz#31ecbd32e648a34d030d86adb67d4d47547fe710"
integrity sha512-HIp/n38R9kQjDEziXyDTuW3vvoxxyxjxFzXLrBr18uB47GnSt+G9D29fqrpM5ZkspMcPICud3XsBJQ4Y2URg8g==
has-ansi@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/has-ansi/-/has-ansi-2.0.0.tgz#34f5049ce1ecdf2b0649af3ef24e45ed35416d91"
@@ -5181,6 +5251,13 @@ has-symbols@^1.0.1, has-symbols@^1.0.2:
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.2.tgz#165d3070c00309752a1236a479331e3ac56f1423"
integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
has-tostringtag@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25"
integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==
dependencies:
has-symbols "^1.0.2"
has-unicode@^2.0.0, has-unicode@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/has-unicode/-/has-unicode-2.0.1.tgz#e0e6fe6a28cf51138855e086d1691e771de2a8b9"
@@ -5334,6 +5411,13 @@ icss-replace-symbols@1.1.0, icss-replace-symbols@^1.1.0:
resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded"
integrity sha1-Bupvg2ead0njhs/h/oEq5dsiPe0=
identity-obj-proxy@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz#94d2bda96084453ef36fbc5aaec37e0f79f1fc14"
integrity sha1-lNK9qWCERT7zb7xarsN+D3nx/BQ=
dependencies:
harmony-reflect "^1.4.6"
ieee754@^1.1.13:
version "1.2.1"
resolved "https://registry.yarnpkg.com/ieee754/-/ieee754-1.2.1.tgz#8eb7a10a63fff25d15a57b001586d177d1b0d352"
@@ -5513,6 +5597,15 @@ inquirer@^7.3.3:
strip-ansi "^6.0.0"
through "^2.3.6"
internal-slot@^1.0.3:
version "1.0.3"
resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.0.3.tgz#7347e307deeea2faac2ac6205d4bc7d34967f59c"
integrity sha512-O0DB1JC/sPyZl7cIo78n5dR7eUSwwpYPiXRhTzNxZVAMUuB8vlnRFyLxdrVToks6XPLVnFfbzaVd5WLjhgg+vA==
dependencies:
get-intrinsic "^1.1.0"
has "^1.0.3"
side-channel "^1.0.4"
ip@^1.1.5:
version "1.1.5"
resolved "https://registry.yarnpkg.com/ip/-/ip-1.1.5.tgz#bdded70114290828c0a039e72ef25f5aaec4354a"
@@ -5567,6 +5660,11 @@ is-callable@^1.1.3, is-callable@^1.1.4, is-callable@^1.2.3:
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.3.tgz#8b1e0500b73a1d76c70487636f368e519de8db8e"
integrity sha512-J1DcMe8UYTBSrKezuIUTUwjXsho29693unXM2YhJUTR2txK/eG47bvNa/wipPFmZFgr/N6f1GA66dv0mEyTIyQ==
is-callable@^1.2.4:
version "1.2.4"
resolved "https://registry.yarnpkg.com/is-callable/-/is-callable-1.2.4.tgz#47301d58dd0259407865547853df6d61fe471945"
integrity sha512-nsuwtxZfMX67Oryl9LCQ+upnC0Z0BgpwntpS89m1H/TLF0zNfzfLMV/9Wa/6MZsj0acpEjAO0KF1xT6ZdLl95w==
is-ci@^1.0.10:
version "1.2.1"
resolved "https://registry.yarnpkg.com/is-ci/-/is-ci-1.2.1.tgz#e3779c8ee17fccf428488f6e281187f2e632841c"
@@ -5804,6 +5902,14 @@ is-regex@^1.1.3:
call-bind "^1.0.2"
has-symbols "^1.0.2"
is-regex@^1.1.4:
version "1.1.4"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958"
integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==
dependencies:
call-bind "^1.0.2"
has-tostringtag "^1.0.0"
is-resolvable@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-resolvable/-/is-resolvable-1.1.0.tgz#fb18f87ce1feb925169c9a407c19318a3206ed88"
@@ -5814,6 +5920,11 @@ is-retry-allowed@^1.0.0:
resolved "https://registry.yarnpkg.com/is-retry-allowed/-/is-retry-allowed-1.2.0.tgz#d778488bd0a4666a3be8a1482b9f2baafedea8b4"
integrity sha512-RUbUeKwvm3XG2VYamhJL1xFktgjvPzL0Hq8C+6yrWIswDy3BIXGqCxhxkc30N9jqK311gVU137K8Ei55/zVJRg==
is-shared-array-buffer@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.1.tgz#97b0c85fbdacb59c9c446fe653b82cf2b5b7cfe6"
integrity sha512-IU0NmyknYZN0rChcKhRO1X8LYz5Isj/Fsqh8NJOSf+N/hCOTwy29F32Ik7a+QszE63IdvmwdTPDd6cZ5pg4cwA==
is-ssh@^1.3.0:
version "1.3.3"
resolved "https://registry.yarnpkg.com/is-ssh/-/is-ssh-1.3.3.tgz#7f133285ccd7f2c2c7fc897b771b53d95a2b2c7e"
@@ -5836,6 +5947,13 @@ is-string@^1.0.5, is-string@^1.0.6:
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.6.tgz#3fe5d5992fb0d93404f32584d4b0179a71b54a5f"
integrity sha512-2gdzbKUuqtQ3lYNrUTQYoClPhm7oQu4UdpSZMp1/DGgkHBT8E2Z1l0yMdb6D4zNAxwDiMv8MdulKROJGNl0Q0w==
is-string@^1.0.7:
version "1.0.7"
resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd"
integrity sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==
dependencies:
has-tostringtag "^1.0.0"
is-symbol@^1.0.2, is-symbol@^1.0.3:
version "1.0.4"
resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c"
@@ -5855,6 +5973,13 @@ is-typedarray@^1.0.0, is-typedarray@~1.0.0:
resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a"
integrity sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=
is-weakref@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.1.tgz#842dba4ec17fa9ac9850df2d6efbc1737274f2a2"
integrity sha512-b2jKc2pQZjaeFYWEf7ScFj+Be1I+PXmlu572Q8coTXZ+LD/QQZ7ShPMst8h16riVgyXTQwUsFEl74mDvc/3MHQ==
dependencies:
call-bind "^1.0.0"
isarray@1.0.0, isarray@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11"
@@ -6277,6 +6402,21 @@ jest-haste-map@^27.4.6:
optionalDependencies:
fsevents "^2.3.2"
jest-image-snapshot@^4.5.1:
version "4.5.1"
resolved "https://registry.yarnpkg.com/jest-image-snapshot/-/jest-image-snapshot-4.5.1.tgz#79fe0419c7729eb1be6c873365307a7b60f5cda0"
integrity sha512-0YkgupgkkCx0wIZkxvqs/oNiUT0X0d2WTpUhaAp+Dy6CpqBUZMRTIZo4KR1f+dqmx6WXrLCvecjnHLIsLkI+gQ==
dependencies:
chalk "^1.1.3"
get-stdin "^5.0.1"
glur "^1.1.2"
lodash "^4.17.4"
mkdirp "^0.5.1"
pixelmatch "^5.1.0"
pngjs "^3.4.0"
rimraf "^2.6.2"
ssim.js "^3.1.1"
jest-jasmine2@^27.2.4:
version "27.2.4"
resolved "https://registry.yarnpkg.com/jest-jasmine2/-/jest-jasmine2-27.2.4.tgz#4a1608133dbdb4d68b5929bfd785503ed9c9ba51"
@@ -7994,7 +8134,7 @@ object-assign@^4.0.1, object-assign@^4.1.0:
resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863"
integrity sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=
object-inspect@^1.10.3, object-inspect@^1.9.0:
object-inspect@^1.10.3, object-inspect@^1.11.0, object-inspect@^1.9.0:
version "1.11.0"
resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.11.0.tgz#9dceb146cedd4148a0d9e51ab88d34cf509922b1"
integrity sha512-jp7ikS6Sd3GxQfZJPyH3cjcbJF6GZPClgdV+EFygjFLQ5FmW/dRUnTd9PQ9k0JhoNDabWFbpF1yCdSWCC6gexg==
@@ -8014,7 +8154,7 @@ object.assign@^4.1.2:
has-symbols "^1.0.1"
object-keys "^1.1.1"
object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0, object.getownpropertydescriptors@^2.1.1:
object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.1:
version "2.1.2"
resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.2.tgz#1bd63aeacf0d5d2d2f31b5e393b03a7c601a23f7"
integrity sha512-WtxeKSzfBjlzL+F9b7M7hewDzMwy+C8NRssHd1YrNlzHzIDrXcXiNOMrezdAEM4UXixgV+vvnyBeN7Rygl2ttQ==
@@ -8023,6 +8163,15 @@ object.getownpropertydescriptors@^2.0.3, object.getownpropertydescriptors@^2.1.0
define-properties "^1.1.3"
es-abstract "^1.18.0-next.2"
object.getownpropertydescriptors@^2.1.0:
version "2.1.3"
resolved "https://registry.yarnpkg.com/object.getownpropertydescriptors/-/object.getownpropertydescriptors-2.1.3.tgz#b223cf38e17fefb97a63c10c91df72ccb386df9e"
integrity sha512-VdDoCwvJI4QdC6ndjpqFmoL3/+HxffFBbcJzKi5hwLLqqx3mdbedRpfZDdK0SrOSauj8X4GzBvnDZl4vTN7dOw==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.3"
es-abstract "^1.19.1"
object.omit@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/object.omit/-/object.omit-2.0.1.tgz#1a9c744829f39dbb858c76ca3579ae2a54ebd1fa"
@@ -8032,13 +8181,13 @@ object.omit@^2.0.0:
is-extendable "^0.1.1"
object.values@^1.1.0:
version "1.1.4"
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.4.tgz#0d273762833e816b693a637d30073e7051535b30"
integrity sha512-TnGo7j4XSnKQoK3MfvkzqKCi0nVe/D9I9IjwTNYdb/fxYHpjrluHVOgw0AF6jrRFGMPHdfuidR09tIDiIvnaSg==
version "1.1.5"
resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.1.5.tgz#959f63e3ce9ef108720333082131e4a459b716ac"
integrity sha512-QUZRW0ilQ3PnPpbNtgdNV1PDbEqLIiSFB3l+EnGtBQ/8SUTLj1PZwtQHABZtLgwpJZTSZhuGLOGk57Drx2IvYg==
dependencies:
call-bind "^1.0.2"
define-properties "^1.1.3"
es-abstract "^1.18.2"
es-abstract "^1.19.1"
on-finished@~2.3.0:
version "2.3.0"
@@ -8438,6 +8587,13 @@ pirates@^4.0.4:
resolved "https://registry.yarnpkg.com/pirates/-/pirates-4.0.4.tgz#07df81e61028e402735cdd49db701e4885b4e6e6"
integrity sha512-ZIrVPH+A52Dw84R0L3/VS9Op04PuQ2SEoJL6bkshmiTic/HldyW9Tf7oH5mhJZBK7NmDx27vSMrYEXPXclpDKw==
pixelmatch@^5.1.0:
version "5.2.1"
resolved "https://registry.yarnpkg.com/pixelmatch/-/pixelmatch-5.2.1.tgz#9e4e4f4aa59648208a31310306a5bed5522b0d65"
integrity sha512-WjcAdYSnKrrdDdqTcVEY7aB7UhhwjYQKYhHiBXdJef0MOaQeYpUdQ+iVyBLa5YBKS8MPVPPMX7rpOByISLpeEQ==
dependencies:
pngjs "^4.0.1"
pkg-dir@^4.1.0, pkg-dir@^4.2.0:
version "4.2.0"
resolved "https://registry.yarnpkg.com/pkg-dir/-/pkg-dir-4.2.0.tgz#f099133df7ede422e81d1d8448270eeb3e4261f3"
@@ -8445,6 +8601,16 @@ pkg-dir@^4.1.0, pkg-dir@^4.2.0:
dependencies:
find-up "^4.0.0"
pngjs@^3.4.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-3.4.0.tgz#99ca7d725965fb655814eaf65f38f12bbdbf555f"
integrity sha512-NCrCHhWmnQklfH4MtJMRjZ2a8c80qXeMlQMv2uVp9ISJMTt562SbGd6n2oq0PaPgKm7Z6pL9E2UlLIhC+SHL3w==
pngjs@^4.0.1:
version "4.0.1"
resolved "https://registry.yarnpkg.com/pngjs/-/pngjs-4.0.1.tgz#f803869bb2fc1bfe1bf99aa4ec21c108117cfdbe"
integrity sha512-rf5+2/ioHeQxR6IxuYNYGFytUyG3lma/WW1nsmjeHlWwtb2aByla6dkVc8pmJ9nplzkTA0q2xx7mMWrOTqT4Gg==
postcss-calc@^7.0.1:
version "7.0.5"
resolved "https://registry.yarnpkg.com/postcss-calc/-/postcss-calc-7.0.5.tgz#f8a6e99f12e619c2ebc23cf6c486fdc15860933e"
@@ -8812,13 +8978,12 @@ postcss@^6.0.1, postcss@^6.0.11:
supports-color "^5.4.0"
postcss@^7.0.0, postcss@^7.0.1, postcss@^7.0.27:
version "7.0.36"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.36.tgz#056f8cffa939662a8f5905950c07d5285644dfcb"
integrity sha512-BebJSIUMwJHRH0HAQoxN4u1CN86glsrwsW0q7T+/m44eXOUAxSNdHRkNZPYz5vVUbg17hFgOQDE7fZk7li3pZw==
version "7.0.39"
resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.39.tgz#9624375d965630e2e1f2c02a935c82a59cb48309"
integrity sha512-yioayjNbHn6z1/Bywyb2Y4s3yvDAeXGOyxqD+LnVOinq6Mdmd++SW2wUNVzavyyHxd6+DxzWGIuosg6P1Rj8uA==
dependencies:
chalk "^2.4.2"
picocolors "^0.2.1"
source-map "^0.6.1"
supports-color "^6.1.0"
prelude-ls@^1.2.1:
version "1.2.1"
@@ -9391,7 +9556,7 @@ rgba-regex@^1.0.0:
resolved "https://registry.yarnpkg.com/rgba-regex/-/rgba-regex-1.0.0.tgz#43374e2e2ca0968b0ef1523460b7d730ff22eeb3"
integrity sha1-QzdOLiyglosO8VI0YLfXMP8i7rM=
rimraf@^2.6.1, rimraf@^2.6.3:
rimraf@^2.6.1, rimraf@^2.6.2, rimraf@^2.6.3:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@@ -9440,9 +9605,9 @@ rollup-plugin-postcss@^3.1.1:
style-inject "^0.3.0"
rollup-plugin-rename-node-modules@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/rollup-plugin-rename-node-modules/-/rollup-plugin-rename-node-modules-1.1.0.tgz#c73de5fed61b997857993813a7053285e2cca2dd"
integrity sha512-JpfsJ7NYI/4OdqWvZ/BY6fgjZb5j7sRFvHMv8EU0zrFiNUcW4ke9tw7WXImsHnjq7Bp3xv+UILRPpA7plOa38Q==
version "1.2.0"
resolved "https://registry.yarnpkg.com/rollup-plugin-rename-node-modules/-/rollup-plugin-rename-node-modules-1.2.0.tgz#f1f1bb2192d1bbec258569bf6bda097002d7dbdf"
integrity sha512-IKsS3eJXHLAMXIndzNso9ijWJw1V3mqubRc2gb67v7VuLX9t41LObXqciM0JC3j7/WrHeptG47cejFU0qxXUJA==
dependencies:
estree-walker "^2.0.1"
magic-string "^0.25.7"
@@ -9856,6 +10021,11 @@ sshpk@^1.7.0:
safer-buffer "^2.0.2"
tweetnacl "~0.14.0"
ssim.js@^3.1.1:
version "3.5.0"
resolved "https://registry.yarnpkg.com/ssim.js/-/ssim.js-3.5.0.tgz#d7276b9ee99b57a5ff0db34035f02f35197e62df"
integrity sha512-Aj6Jl2z6oDmgYFFbQqK7fght19bXdOxY7Tj03nF+03M9gCBAjeIiO8/PlEGMfKDwYpw4q6iBqVq2YuREorGg/g==
ssri@^8.0.0, ssri@^8.0.1:
version "8.0.1"
resolved "https://registry.yarnpkg.com/ssri/-/ssri-8.0.1.tgz#638e4e439e2ffbd2cd289776d5ca457c4f51a2af"
@@ -10075,13 +10245,6 @@ supports-color@^5.3.0, supports-color@^5.4.0:
dependencies:
has-flag "^3.0.0"
supports-color@^6.1.0:
version "6.1.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-6.1.0.tgz#0764abc69c63d5ac842dd4867e8d025e880df8f3"
integrity sha512-qe1jfm1Mg7Nq/NSh6XE24gPXROEVsWHxC1LIx//XNlD9iw7YZQGjZNjYN7xGaEG6iKdA8EtNFW6R0gjnVXp+wQ==
dependencies:
has-flag "^3.0.0"
supports-color@^7.0.0, supports-color@^7.1.0:
version "7.2.0"
resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-7.2.0.tgz#1b7dcdcb32b8138801b3e478ba6a51caa89648da"
@@ -10988,7 +11151,12 @@ ws@^6.1.0:
dependencies:
async-limiter "~1.0.0"
ws@^7.2.3, ws@^7.4.3, ws@^7.4.5:
ws@^7.2.3:
version "7.5.6"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.6.tgz#e59fc509fb15ddfb65487ee9765c5a51dec5fe7b"
integrity sha512-6GLgCqo2cy2A2rjCNFlxQS6ZljG/coZfZXclldI8FB/1G3CCI36Zd8xy2HrFVACi8tfk5XrgLQEk+P0Tnz9UcA==
ws@^7.4.3, ws@^7.4.5:
version "7.5.3"
resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.3.tgz#160835b63c7d97bfab418fc1b8a9fced2ac01a74"
integrity sha512-kQ/dHIzuLrS6Je9+uv81ueZomEwH0qVYstcAQ4/Z93K8zeko9gtAbttJWzoC5ukqXY1PpoouV3+VSOqEAFt5wg==