diff --git a/package.json b/package.json index c579a22d27..3b4f9401d1 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,7 @@ "description": "record and replay the web", "scripts": { "test": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true mocha -r ts-node/register test/**/*.test.ts", + "test:watch": "PUPPETEER_HEADLESS=true npm run test -- --watch --watch-extensions js,ts", "repl": "npm run bundle:browser && cross-env TS_NODE_CACHE=false TS_NODE_FILES=true ts-node scripts/repl.ts", "bundle:browser": "cross-env BROWSER_ONLY=true rollup --config", "bundle": "rollup --config", diff --git a/src/record/index.ts b/src/record/index.ts index 7a2d50b1ca..0b611361a5 100644 --- a/src/record/index.ts +++ b/src/record/index.ts @@ -82,9 +82,11 @@ function record(options: recordOptions = {}): listenerHandler | undefined { inlineStylesheet, maskAllInputs, ); + if (!node) { return console.warn('Failed to snapshot the document'); } + mirror.map = idNodeMap; wrappedEmit( wrapEvent({ @@ -188,6 +190,16 @@ function record(options: recordOptions = {}): listenerHandler | undefined { }, }), ), + styleSheetRuleCb: r => + wrappedEmit( + wrapEvent({ + type: EventType.IncrementalSnapshot, + data: { + source: IncrementalSource.StyleSheetRule, + ...r, + }, + }), + ), blockClass, ignoreClass, maskAllInputs, diff --git a/src/record/observer.ts b/src/record/observer.ts index 875d2d9aa3..dc1f8697ec 100644 --- a/src/record/observer.ts +++ b/src/record/observer.ts @@ -21,6 +21,7 @@ import { MouseInteractions, listenerHandler, scrollCallback, + styleSheetRuleCallback, viewportResizeCallback, inputValue, inputCallback, @@ -519,6 +520,31 @@ function initInputObserver( }; } +function initStyleSheetObserver(cb: styleSheetRuleCallback): listenerHandler { + const insertRule = CSSStyleSheet.prototype.insertRule; + CSSStyleSheet.prototype.insertRule = function(rule: string, index?: number) { + cb({ + id: mirror.getId(this.ownerNode as INode), + adds: [{ rule, index }], + }); + return insertRule.apply(this, arguments); + }; + + const deleteRule = CSSStyleSheet.prototype.deleteRule; + CSSStyleSheet.prototype.deleteRule = function(index: number) { + cb({ + id: mirror.getId(this.ownerNode as INode), + removes: [{ index }], + }); + return deleteRule.apply(this, arguments); + }; + + return () => { + CSSStyleSheet.prototype.insertRule = insertRule; + CSSStyleSheet.prototype.deleteRule = deleteRule; + }; +} + function initMediaInteractionObserver( mediaInteractionCb: mediaInteractionCallback, blockClass: blockClass, @@ -548,6 +574,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { viewportResizeCb, inputCb, mediaInteractionCb, + styleSheetRuleCb, } = o; o.mutationCb = (...p: Arguments) => { if (hooks.mutation) { @@ -591,6 +618,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) { } mediaInteractionCb(...p); }; + o.styleSheetRuleCb = (...p: Arguments) => { + if (hooks.styleSheetRule) { + hooks.styleSheetRule(...p); + } + styleSheetRuleCb(...p); + }; } export default function initObservers( @@ -621,6 +654,8 @@ export default function initObservers( o.mediaInteractionCb, o.blockClass, ); + const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb); + return () => { mutationObserver.disconnect(); mousemoveHandler(); @@ -629,5 +664,6 @@ export default function initObservers( viewportResizeHandler(); inputHandler(); mediaInteractionHandler(); + styleSheetObserver(); }; } diff --git a/src/types.ts b/src/types.ts index f4305b1e58..a39ca027f4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -52,6 +52,8 @@ export type customEvent = { }; }; +export type styleSheetEvent = {}; + export enum IncrementalSource { Mutation, MouseMove, @@ -61,6 +63,7 @@ export enum IncrementalSource { Input, TouchMove, MediaInteraction, + StyleSheetRule, } export type mutationData = { @@ -93,6 +96,10 @@ export type mediaInteractionData = { source: IncrementalSource.MediaInteraction; } & mediaInteractionParam; +export type styleSheetRuleData = { + source: IncrementalSource.StyleSheetRule; +} & styleSheetRuleParam; + export type incrementalData = | mutationData | mousemoveData @@ -100,7 +107,8 @@ export type incrementalData = | scrollData | viewportResizeData | inputData - | mediaInteractionData; + | mediaInteractionData + | styleSheetRuleData; export type event = | domContentLoadedEvent @@ -141,6 +149,7 @@ export type observerParam = { ignoreClass: string; maskAllInputs: boolean; inlineStylesheet: boolean; + styleSheetRuleCb: styleSheetRuleCallback; mousemoveWait: number; }; @@ -152,6 +161,7 @@ export type hooksParam = { viewportResize?: viewportResizeCallback; input?: inputCallback; mediaInteaction?: mediaInteractionCallback; + styleSheetRule?: styleSheetRuleCallback; }; export type textCursor = { @@ -239,6 +249,23 @@ export type scrollPosition = { export type scrollCallback = (p: scrollPosition) => void; +export type styleSheetAddRule = { + rule: string; + index?: number; +}; + +export type styleSheetDeleteRule = { + index: number; +}; + +export type styleSheetRuleParam = { + id: number; + removes?: styleSheetDeleteRule[]; + adds?: styleSheetAddRule[]; +}; + +export type styleSheetRuleCallback = (s: styleSheetRuleParam) => void; + export type viewportResizeDimention = { width: number; height: number; diff --git a/test/__snapshots__/integration.test.ts.snap b/test/__snapshots__/integration.test.ts.snap index c16d379a20..afd9ce965f 100644 --- a/test/__snapshots__/integration.test.ts.snap +++ b/test/__snapshots__/integration.test.ts.snap @@ -286,7 +286,7 @@ exports[`block 1`] = ` \\"attributes\\": { \\"class\\": \\"rr-block\\", \\"rr_width\\": \\"1904px\\", - \\"rr_height\\": \\"21px\\" + \\"rr_height\\": \\"19px\\" }, \\"childNodes\\": [], \\"id\\": 18 diff --git a/test/__snapshots__/record.test.ts.snap b/test/__snapshots__/record.test.ts.snap index 7ecfb7003e..15dd136f42 100644 --- a/test/__snapshots__/record.test.ts.snap +++ b/test/__snapshots__/record.test.ts.snap @@ -6,8 +6,8 @@ exports[`async-checkout 1`] = ` \\"type\\": 4, \\"data\\": { \\"href\\": \\"about:blank\\", - \\"width\\": 800, - \\"height\\": 600 + \\"width\\": 1920, + \\"height\\": 1080 } }, { @@ -132,8 +132,8 @@ exports[`async-checkout 1`] = ` \\"type\\": 4, \\"data\\": { \\"href\\": \\"about:blank\\", - \\"width\\": 800, - \\"height\\": 600 + \\"width\\": 1920, + \\"height\\": 1080 } }, { @@ -204,44 +204,6 @@ exports[`async-checkout 1`] = ` \\"top\\": 0 } } - }, - { - \\"type\\": 3, - \\"data\\": { - \\"source\\": 0, - \\"texts\\": [], - \\"attributes\\": [], - \\"removes\\": [ - { - \\"parentId\\": 8, - \\"id\\": 9 - } - ], - \\"adds\\": [ - { - \\"parentId\\": 4, - \\"previousId\\": 8, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 2, - \\"tagName\\": \\"span\\", - \\"attributes\\": {}, - \\"childNodes\\": [], - \\"id\\": 9 - } - }, - { - \\"parentId\\": 9, - \\"previousId\\": null, - \\"nextId\\": null, - \\"node\\": { - \\"type\\": 3, - \\"textContent\\": \\"test\\", - \\"id\\": 10 - } - } - ] - } } ]" `; @@ -252,8 +214,8 @@ exports[`custom-event 1`] = ` \\"type\\": 4, \\"data\\": { \\"href\\": \\"about:blank\\", - \\"width\\": 800, - \\"height\\": 600 + \\"width\\": 1920, + \\"height\\": 1080 } }, { @@ -331,3 +293,146 @@ exports[`custom-event 1`] = ` } ]" `; + +exports[`stylesheet-rules 1`] = ` +"[ + { + \\"type\\": 4, + \\"data\\": { + \\"href\\": \\"about:blank\\", + \\"width\\": 1920, + \\"height\\": 1080 + } + }, + { + \\"type\\": 2, + \\"data\\": { + \\"node\\": { + \\"type\\": 0, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"html\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 2, + \\"tagName\\": \\"head\\", + \\"attributes\\": {}, + \\"childNodes\\": [], + \\"id\\": 3 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"body\\", + \\"attributes\\": {}, + \\"childNodes\\": [ + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\", + \\"id\\": 5 + }, + { + \\"type\\": 2, + \\"tagName\\": \\"input\\", + \\"attributes\\": { + \\"type\\": \\"text\\" + }, + \\"childNodes\\": [], + \\"id\\": 6 + }, + { + \\"type\\": 3, + \\"textContent\\": \\"\\\\n \\\\n \\\\n \\", + \\"id\\": 7 + } + ], + \\"id\\": 4 + } + ], + \\"id\\": 2 + } + ], + \\"id\\": 1 + }, + \\"initialOffset\\": { + \\"left\\": 0, + \\"top\\": 0 + } + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"id\\": -1, + \\"adds\\": [ + { + \\"rule\\": \\"body { background: #000; }\\" + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 0, + \\"texts\\": [], + \\"attributes\\": [], + \\"removes\\": [], + \\"adds\\": [ + { + \\"parentId\\": 3, + \\"previousId\\": null, + \\"nextId\\": null, + \\"node\\": { + \\"type\\": 2, + \\"tagName\\": \\"style\\", + \\"attributes\\": { + \\"_cssText\\": \\"body { background: rgb(0, 0, 0); }\\" + }, + \\"childNodes\\": [], + \\"id\\": 8 + } + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"id\\": 8, + \\"adds\\": [ + { + \\"rule\\": \\"body { color: #fff; }\\" + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"id\\": 8, + \\"removes\\": [ + { + \\"index\\": 0 + } + ] + } + }, + { + \\"type\\": 3, + \\"data\\": { + \\"source\\": 8, + \\"id\\": 8, + \\"adds\\": [ + { + \\"rule\\": \\"body { color: #ccc; }\\" + } + ] + } + } +]" +`; diff --git a/test/integration.test.ts b/test/integration.test.ts index b308a2e72d..fe5b9c8622 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as puppeteer from 'puppeteer'; -import { assertSnapshot } from './utils'; +import { assertSnapshot, launchPuppeteer } from './utils'; import { Suite } from 'mocha'; import { recordOptions } from '../src/types'; @@ -35,14 +35,7 @@ describe('record integration tests', function(this: ISuite) { }; before(async () => { - this.browser = await puppeteer.launch({ - defaultViewport: { - width: 1920, - height: 1080, - }, - headless: false, - args: ['--no-sandbox'], - }); + this.browser = await launchPuppeteer(); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); this.code = fs.readFileSync(bundlePath, 'utf8'); diff --git a/test/record.test.ts b/test/record.test.ts index b7970ef5a9..4d9744b2f6 100644 --- a/test/record.test.ts +++ b/test/record.test.ts @@ -10,7 +10,7 @@ import { eventWithTime, EventType, } from '../src/types'; -import { assertSnapshot } from './utils'; +import { assertSnapshot, launchPuppeteer } from './utils'; import { Suite } from 'mocha'; interface ISuite extends Suite { @@ -30,10 +30,7 @@ interface IWindow extends Window { describe('record', function(this: ISuite) { before(async () => { - this.browser = await puppeteer.launch({ - headless: false, - args: ['--no-sandbox'], - }); + this.browser = await launchPuppeteer(); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); this.code = fs.readFileSync(bundlePath, 'utf8'); @@ -196,4 +193,32 @@ describe('record', function(this: ISuite) { await this.page.waitFor(50); assertSnapshot(this.events, __filename, 'custom-event'); }); + + it('captures stylesheet rules', async () => { + await this.page.evaluate(() => { + const { record } = ((window as unknown) as IWindow).rrweb; + + record({ + emit: ((window as unknown) as IWindow).emit, + }); + + const styleElement = document.createElement('style'); + document.head.appendChild(styleElement); + + const styleSheet = styleElement.sheet; + const ruleIdx0 = styleSheet.insertRule('body { background: #000; }'); + setTimeout(() => { + styleSheet.insertRule('body { color: #fff; }'); + }, 0); + setTimeout(() => { + styleSheet.deleteRule(ruleIdx0); + }, 5); + setTimeout(() => { + styleSheet.insertRule('body { color: #ccc; }'); + }, 10); + }); + await this.page.waitFor(10); + expect(this.events.length).to.equal(7); + assertSnapshot(this.events, __filename, 'stylesheet-rules'); + }); }); diff --git a/test/replayer.test.ts b/test/replayer.test.ts index 6fc3cc534a..fe221921fc 100644 --- a/test/replayer.test.ts +++ b/test/replayer.test.ts @@ -12,6 +12,7 @@ import { MouseInteractions, } from '../src/types'; import { Replayer } from '../src'; +import { launchPuppeteer } from './utils'; const now = Date.now(); @@ -122,10 +123,7 @@ interface ISuite extends Suite { describe('replayer', function(this: ISuite) { before(async () => { - this.browser = await puppeteer.launch({ - headless: false, - args: ['--no-sandbox'], - }); + this.browser = await launchPuppeteer(); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); this.code = fs.readFileSync(bundlePath, 'utf8'); diff --git a/test/utils.ts b/test/utils.ts index d84846fd14..ff0f47d97a 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -2,6 +2,18 @@ import { SnapshotState, toMatchSnapshot } from 'jest-snapshot'; import { NodeType } from 'rrweb-snapshot'; import { assert } from 'chai'; import { EventType, IncrementalSource, eventWithTime } from '../src/types'; +import * as puppeteer from 'puppeteer'; + +export async function launchPuppeteer() { + return await puppeteer.launch({ + headless: process.env.PUPPETEER_HEADLESS ? true : false, + defaultViewport: { + width: 1920, + height: 1080, + }, + args: ['--no-sandbox'], + }); +} function matchSnapshot(actual: string, testFile: string, testTitle: string) { const snapshotState = new SnapshotState(testFile, {