diff --git a/package.json b/package.json index dcdc68ec..d29d5df4 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "@xstate/fsm": "^1.4.0", "mitt": "^1.1.3", "pako": "^1.0.11", - "rrweb-snapshot": "^0.8.0", + "rrweb-snapshot": "^0.8.1", "smoothscroll-polyfill": "^0.4.3" } } diff --git a/scripts/repl.ts b/scripts/repl.ts index 72edc453..637c8828 100644 --- a/scripts/repl.ts +++ b/scripts/repl.ts @@ -81,7 +81,7 @@ function getCode(): string { width: 1600, height: 900, }, - args: ['--start-maximized'], + args: ['--start-maximized', '--ignore-certificate-errors'], }); const page = await browser.newPage(); await page.goto(url, { @@ -94,7 +94,8 @@ function getCode(): string { await page.evaluate(`;${code} window.__IS_RECORDING__ = true rrweb.record({ - emit: event => window._replLog(event) + emit: event => window._replLog(event), + recordCanvas: true }); `); page.on('framenavigated', async () => { @@ -103,14 +104,14 @@ function getCode(): string { await page.evaluate(`;${code} window.__IS_RECORDING__ = true rrweb.record({ - emit: event => window._replLog(event) + emit: event => window._replLog(event), + recordCanvas: true }); `); } }); - emitter.once('done', async shouldReplay => { - console.log(`Recorded ${events.length} events`); + emitter.once('done', async (shouldReplay) => { await browser.close(); if (shouldReplay) { await replay(); @@ -170,7 +171,9 @@ function getCode(): string { '<\\/script>', )}; /*-->*/ - const replayer = new rrweb.Replayer(events); + const replayer = new rrweb.Replayer(events, { + UNSAFE_replayCanvas: true + }); replayer.play(); @@ -182,10 +185,10 @@ function getCode(): string { } process - .on('uncaughtException', error => { + .on('uncaughtException', (error) => { console.error(error); }) - .on('unhandledRejection', error => { + .on('unhandledRejection', (error) => { console.error(error); }); })(); diff --git a/src/record/index.ts b/src/record/index.ts index cf07c251..c09d4701 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -41,6 +41,7 @@ function record( packFn, sampling = {}, mousemoveWait, + recordCanvas = false, } = options; // runtime checks for user options if (!emit) { @@ -113,6 +114,7 @@ function record( blockClass, inlineStylesheet, maskInputOptions, + recordCanvas, ); if (!node) { @@ -244,11 +246,22 @@ function record( }, }), ), + canvasMutationCb: (p) => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.CanvasMutation, + ...p, + }, + }), + ), blockClass, ignoreClass, maskInputOptions, inlineStylesheet, sampling, + recordCanvas, }, hooks, ), diff --git a/src/record/mutation.ts b/src/record/mutation.ts index be85b77f..9bd5817a 100644 --- a/src/record/mutation.ts +++ b/src/record/mutation.ts @@ -138,16 +138,19 @@ export default class MutationBuffer { private blockClass: blockClass; private inlineStylesheet: boolean; private maskInputOptions: MaskInputOptions; + private recordCanvas: boolean; constructor( cb: mutationCallBack, blockClass: blockClass, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, + recordCanvas: boolean, ) { this.blockClass = blockClass; this.inlineStylesheet = inlineStylesheet; this.maskInputOptions = maskInputOptions; + this.recordCanvas = recordCanvas; this.emissionCallback = cb; } @@ -187,6 +190,7 @@ export default class MutationBuffer { true, this.inlineStylesheet, this.maskInputOptions, + this.recordCanvas, )!, }); }; diff --git a/src/record/observer.ts b/src/record/observer.ts index 24b1fdaf..b60508eb 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -8,6 +8,7 @@ import { getWindowWidth, isBlocked, isTouchEvent, + patch, } from '../utils'; import { mutationCallBack, @@ -30,6 +31,7 @@ import { mediaInteractionCallback, MediaInteractions, SamplingStrategy, + canvasMutationCallback, } from '../types'; import MutationBuffer from './mutation'; @@ -38,6 +40,7 @@ function initMutationObserver( blockClass: blockClass, inlineStylesheet: boolean, maskInputOptions: MaskInputOptions, + recordCanvas: boolean, ): MutationObserver { // see mutation.ts for details const mutationBuffer = new MutationBuffer( @@ -45,6 +48,7 @@ function initMutationObserver( blockClass, inlineStylesheet, maskInputOptions, + recordCanvas, ); const observer = new MutationObserver(mutationBuffer.processMutations); observer.observe(document, { @@ -357,6 +361,75 @@ function initMediaInteractionObserver( }; } +function initCanvasMutationObserver( + cb: canvasMutationCallback, + blockClass: blockClass, +): listenerHandler { + const props = Object.getOwnPropertyNames(CanvasRenderingContext2D.prototype); + const handlers: listenerHandler[] = []; + for (const prop of props) { + try { + if ( + typeof CanvasRenderingContext2D.prototype[ + prop as keyof CanvasRenderingContext2D + ] !== 'function' + ) { + continue; + } + const restoreHandler = patch( + CanvasRenderingContext2D.prototype, + prop, + function (original) { + return function ( + this: CanvasRenderingContext2D, + ...args: Array + ) { + if (!isBlocked(this.canvas, blockClass)) { + setTimeout(() => { + const recordArgs = [...args]; + if (prop === 'drawImage') { + if ( + recordArgs[0] && + recordArgs[0] instanceof HTMLCanvasElement + ) { + recordArgs[0] = recordArgs[0].toDataURL(); + } + } + 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.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 mergeHooks(o: observerParam, hooks: hooksParam) { const { mutationCb, @@ -367,6 +440,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { inputCb, mediaInteractionCb, styleSheetRuleCb, + canvasMutationCb, } = o; o.mutationCb = (...p: Arguments) => { if (hooks.mutation) { @@ -416,6 +490,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { } styleSheetRuleCb(...p); }; + o.canvasMutationCb = (...p: Arguments) => { + if (hooks.canvasMutation) { + hooks.canvasMutation(...p); + } + canvasMutationCb(...p); + }; } export default function initObservers( @@ -428,6 +508,7 @@ export default function initObservers( o.blockClass, o.inlineStylesheet, o.maskInputOptions, + o.recordCanvas, ); const mousemoveHandler = initMoveObserver(o.mousemoveCb, o.sampling); const mouseInteractionHandler = initMouseInteractionObserver( @@ -453,6 +534,9 @@ export default function initObservers( o.blockClass, ); const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb); + const canvasMutationObserver = o.recordCanvas + ? initCanvasMutationObserver(o.canvasMutationCb, o.blockClass) + : () => {}; return () => { mutationObserver.disconnect(); @@ -463,5 +547,6 @@ export default function initObservers( inputHandler(); mediaInteractionHandler(); styleSheetObserver(); + canvasMutationObserver(); }; } diff --git a/src/replay/index.ts b/src/replay/index.ts index d00e1c11..67cd7f8d 100644 --- a/src/replay/index.ts +++ b/src/replay/index.ts @@ -25,6 +25,7 @@ import { mutationData, scrollData, inputData, + canvasMutationData, } from '../types'; import { mirror, polyfill, TreeIndex } from '../utils'; import getInjectStyleRules from './styles/inject-style'; @@ -50,6 +51,7 @@ const defaultConfig: playerConfig = { liveMode: false, insertStyleRules: [], triggerFocus: true, + UNSAFE_replayCanvas: false, }; export class Replayer { @@ -76,6 +78,8 @@ export class Replayer { private treeIndex!: TreeIndex; private fragmentParentMap!: Map; + private imageMap: Map = new Map(); + constructor( events: Array, config?: Partial, @@ -289,7 +293,11 @@ export class Replayer { this.wrapper.appendChild(this.mouse); this.iframe = document.createElement('iframe'); - this.iframe.setAttribute('sandbox', 'allow-same-origin'); + const attributes = ['allow-same-origin']; + if (this.config.UNSAFE_replayCanvas) { + attributes.push('allow-scripts'); + } + this.iframe.setAttribute('sandbox', attributes.join(' ')); this.disableInteract(); this.wrapper.appendChild(this.iframe); } @@ -413,6 +421,9 @@ export class Replayer { } this.emitter.emit(ReplayerEvents.FullsnapshotRebuilded, event); this.waitForStylesheetLoad(); + if (this.config.UNSAFE_replayCanvas) { + this.preloadAllImages(); + } } /** @@ -465,6 +476,44 @@ export class Replayer { } } + /** + * pause when there are some canvas drawImage args need to be loaded + */ + private preloadAllImages() { + let beforeLoadState = this.service.state; + const { unsubscribe } = this.service.subscribe((state) => { + beforeLoadState = state; + }); + let count = 0; + let resolved = 0; + 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) + ) { + count++; + const image = document.createElement('img'); + image.src = event.data.args[0]; + this.imageMap.set(event, image); + image.onload = () => { + resolved++; + if (resolved === count) { + if (beforeLoadState.matches('playing')) { + this.play(this.getCurrentTime()); + } + unsubscribe(); + } + }; + } + } + if (count !== resolved) { + this.service.send({ type: 'PAUSE' }); + } + } + private applyIncremental( e: incrementalSnapshotEvent & { timestamp: number }, isSync: boolean, @@ -643,6 +692,43 @@ export class Replayer { } break; } + case IncrementalSource.CanvasMutation: { + if (!this.config.UNSAFE_replayCanvas) { + return; + } + const target = mirror.getNode(d.id); + 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); + } + } default: } } @@ -914,6 +1000,19 @@ export class Replayer { console.warn(REPLAY_CONSOLE_PREFIX, `Node with id '${id}' not found in`, d); } + private warnCanvasMutationFailed( + d: canvasMutationData, + id: number, + error: unknown, + ) { + console.warn( + REPLAY_CONSOLE_PREFIX, + `Has error on update canvas '${id}'`, + d, + error, + ); + } + private debugNodeNotFound(d: incrementalData, id: number) { /** * There maybe some valid scenes of node not being found. diff --git a/src/types.ts b/src/types.ts index fc0ccaf0..d1940d51 100644 --- a/src/types.ts +++ b/src/types.ts @@ -70,6 +70,7 @@ export enum IncrementalSource { TouchMove, MediaInteraction, StyleSheetRule, + CanvasMutation, } export type mutationData = { @@ -106,6 +107,10 @@ export type styleSheetRuleData = { source: IncrementalSource.StyleSheetRule; } & styleSheetRuleParam; +export type canvasMutationData = { + source: IncrementalSource.CanvasMutation; +} & canvasMutationParam; + export type incrementalData = | mutationData | mousemoveData @@ -114,7 +119,8 @@ export type incrementalData = | viewportResizeData | inputData | mediaInteractionData - | styleSheetRuleData; + | styleSheetRuleData + | canvasMutationData; export type event = | domContentLoadedEvent @@ -165,6 +171,7 @@ export type recordOptions = { hooks?: hooksParam; packFn?: PackFn; sampling?: SamplingStrategy; + recordCanvas?: boolean; // departed, please use sampling options mousemoveWait?: number; }; @@ -182,7 +189,9 @@ export type observerParam = { maskInputOptions: MaskInputOptions; inlineStylesheet: boolean; styleSheetRuleCb: styleSheetRuleCallback; + canvasMutationCb: canvasMutationCallback; sampling: SamplingStrategy; + recordCanvas: boolean; }; export type hooksParam = { @@ -194,6 +203,7 @@ export type hooksParam = { input?: inputCallback; mediaInteaction?: mediaInteractionCallback; styleSheetRule?: styleSheetRuleCallback; + canvasMutation?: canvasMutationCallback; }; // https://dom.spec.whatwg.org/#interface-mutationrecord @@ -309,6 +319,15 @@ export type styleSheetRuleParam = { export type styleSheetRuleCallback = (s: styleSheetRuleParam) => void; +export type canvasMutationCallback = (p: canvasMutationParam) => void; + +export type canvasMutationParam = { + id: number; + property: string; + args: Array; + setter?: true; +}; + export type viewportResizeDimention = { width: number; height: number; @@ -362,6 +381,7 @@ export type playerConfig = { liveMode: boolean; insertStyleRules: string[]; triggerFocus: boolean; + UNSAFE_replayCanvas: boolean; unpackFn?: UnpackFn; }; diff --git a/src/utils.ts b/src/utils.ts index 93d5ff58..e97612c2 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -124,18 +124,18 @@ export function patch( // tslint:disable-next-line:no-any replacement: (...args: any[]) => any, ): () => void { - if (!(name in source)) { - return () => {}; - } + try { + if (!(name in source)) { + return () => {}; + } - const original = source[name] as () => unknown; - const wrapped = replacement(original); + const original = source[name] as () => unknown; + const wrapped = replacement(original); - // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work - // otherwise it'll throw "TypeError: Object.defineProperties called on non-object" - // tslint:disable-next-line:strict-type-predicates - if (typeof wrapped === 'function') { - try { + // Make sure it's a function first, as we need to attach an empty prototype for `defineProperties` to work + // otherwise it'll throw "TypeError: Object.defineProperties called on non-object" + // tslint:disable-next-line:strict-type-predicates + if (typeof wrapped === 'function') { wrapped.prototype = wrapped.prototype || {}; Object.defineProperties(wrapped, { __rrweb_original__: { @@ -143,17 +143,18 @@ export function patch( value: original, }, }); - } catch { - // This can throw if multiple fill happens on a global object like XMLHttpRequest - // Fixes https://github.com/getsentry/sentry-javascript/issues/2043 } + + source[name] = wrapped; + + return () => { + source[name] = original; + }; + } catch { + return () => {}; + // This can throw if multiple fill happens on a global object like XMLHttpRequest + // Fixes https://github.com/getsentry/sentry-javascript/issues/2043 } - - source[name] = wrapped; - - return () => { - source[name] = original; - }; } export function getWindowHeight(): number { diff --git a/test/__snapshots__/integration.test.ts.snap b/test/__snapshots__/integration.test.ts.snap index b4f135e5..18875bd1 100644 --- a/test/__snapshots__/integration.test.ts.snap +++ b/test/__snapshots__/integration.test.ts.snap @@ -333,6 +333,230 @@ exports[`block 1`] = ` ]" `; +exports[`canvas 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;\\", + \\"rr_dataURL\\": \\"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAMgAAABkCAYAAADDhn8LAAAEK0lEQVR4Xu3d3VLjMAyG4YZzuBvu/9K6w2xZtqVJFcv5Ux5mOCgQ43yJ8lqSpQwXXxSgwKgCA20oQIFxBRiIu4MCEwowELcHBRiIe4ACbQogSJtujjqJAgzkJBfaabYpwEDadHPUSRRgICe50E6zTQEG0qabo06iQNhAru/v18vb2+UyDD/fY5+/fv719f37Z58j43z/zdQ4j/8nMq75/b2Grdelo37D52f4HtzCJsOTu358XLsZx5Y3deTimt/Pw63lITVDvzoGgiDrkTNixBFSttzckXE7zq+OgSDIz7Lk2U3U66aJjDPjCT25zF1qnBnj1jEQBEGQ3r7gMFzqGAiCIMiYY58IyNQxEARBEAQZD6SJYt3C20uHtvkgW0RzR/9nPMyLIAiCIAiyefIMQRAknJE/QBz/X0Y66sCunGfY+/zqOOmiWKJY0YeAPMhKDiuC5PZMHUA/BImsqddcdkTmM+MJuPdM9d7nV8dARLFEsUSxRLFEsTosn2cQGEEydQcHWEPvPUq09/nVMRBRLFEsUayJJRYfhA/CB+GD8EH4IP9bgb1YfKT73gFrhtzVg4w0BpgR5dh7HN/8cg066jjpfBA+CB+ED8IH4YPwQSLbTSJrcXma9F6xOksseRB5EHkQeZC7zPMjIV597kWmXuMcgHAI0utirzmOKJvOijdWyIPIg8iDTBT5xg2ED8IH4YPwQfgg63bv54Os6TtEQrOR+fBB+CB8EO8v2Twpai+WvVh3e7UQ7heZ6iyx7MWyF8teLHuxNl92nMxHQhB5hk3zDGrSc51M5UEiT+xevkOvcQpF2RAEQRBk7JURoliiWKJY068ORxAEQRAEuZFigb06ar5zNd971w9BEARBEARBnuZPloo2LTXuBlE2BEEQBEEQBEGQW7TqWV5pgkwIgiAIgiAIgiAIcg2/nbbX9o0DdOXY+16nvc+vzhJLTbqa9AXyXHUMRD2IehD1IOpB1IPozas3Lx8p11O3o351llh8ED4IH0RfLH2x9MVqW2IhCIIgCIIgCIIgyONu2Fefe+1y7TWO3by5Tgwzjo43bZAHkQeRB5EHkQeRB2lbYiEIgiAIgiAIgiBIx0zw3nfL7n1+Mum9bsY1xykUJdLVZEbI6smfimKpeNy04hFB1nzyyzPk3gy1gX51DEQUSxRLFEsUSxRLFEsUq9eyT818rq5Ed3fd3XV3193999rcEzr3ZD2RfnWcdPUg6kHUg6gHUQ+iHqTNSUcQBEEQBEEQBEGQVxWE9mLlMvIz9KvjpMuky6TLpMuky6TLpLctsRAEQRAEQRAEQZoIkis7cTQFjqlAuGDqmKdn1hTIKcBAcvo5urgCDKT4BXZ6OQUYSE4/RxdXgIEUv8BOL6cAA8np5+jiCjCQ4hfY6eUUYCA5/RxdXAEGUvwCO72cAn8Aw0BAdBcYDcUAAAAASUVORK5CYII=\\" + }, + \\"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, + \\"property\\": \\"moveTo\\", + \\"args\\": [ + 0, + 0 + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 9, + \\"id\\": 16, + \\"property\\": \\"lineTo\\", + \\"args\\": [ + 200, + 100 + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 9, + \\"id\\": 16, + \\"property\\": \\"stroke\\", + \\"args\\": [] + } + } +]" +`; + exports[`character-data 1`] = ` "[ { diff --git a/test/html/canvas.html b/test/html/canvas.html new file mode 100644 index 00000000..3aac8d80 --- /dev/null +++ b/test/html/canvas.html @@ -0,0 +1,34 @@ + + + + + + canvas + + + + + + + diff --git a/test/integration.test.ts b/test/integration.test.ts index d27730d3..f308109c 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -33,7 +33,8 @@ describe('record integration tests', function (this: ISuite) { window.snapshots.push(event); }, maskAllInputs: ${options.maskAllInputs}, - maskInputOptions: ${JSON.stringify(options.maskAllInputs)} + maskInputOptions: ${JSON.stringify(options.maskAllInputs)}, + recordCanvas: ${options.recordCanvas} }); @@ -244,6 +245,19 @@ describe('record integration tests', function (this: ISuite) { assertSnapshot(snapshots, __filename, 'react-styled-components'); }); + it('should record canvas mutations', async () => { + const page: puppeteer.Page = await this.browser.newPage(); + await page.goto('about:blank'); + await page.setContent( + getHtml.call(this, 'canvas.html', { + recordCanvas: true, + }), + ); + await page.waitFor(50); + const snapshots = await page.evaluate('window.snapshots'); + assertSnapshot(snapshots, __filename, 'canvas'); + }); + it('will serialize node before record', async () => { const page: puppeteer.Page = await this.browser.newPage(); await page.goto('about:blank');