Add observers for stylesheet mutations (#177)

* hack together stylesheet observer

* Add test coverage for insertRule/deleteRule on stylesheets

* Add new observers

* update patch based on changes to master

* Functioning event recording

* Remove print statements

* Fix ID usage and mark add vs remove

* Correct type

Co-authored-by: Jon Perl <perl.jonathan@gmail.com>
This commit is contained in:
David Cramer
2020-02-21 20:59:55 -08:00
committed by GitHub
parent efea82fc29
commit 046936b3e8
10 changed files with 273 additions and 64 deletions

View File

@@ -4,6 +4,7 @@
"description": "record and replay the web", "description": "record and replay the web",
"scripts": { "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": "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", "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:browser": "cross-env BROWSER_ONLY=true rollup --config",
"bundle": "rollup --config", "bundle": "rollup --config",

View File

@@ -82,9 +82,11 @@ function record(options: recordOptions = {}): listenerHandler | undefined {
inlineStylesheet, inlineStylesheet,
maskAllInputs, maskAllInputs,
); );
if (!node) { if (!node) {
return console.warn('Failed to snapshot the document'); return console.warn('Failed to snapshot the document');
} }
mirror.map = idNodeMap; mirror.map = idNodeMap;
wrappedEmit( wrappedEmit(
wrapEvent({ wrapEvent({
@@ -188,6 +190,16 @@ function record(options: recordOptions = {}): listenerHandler | undefined {
}, },
}), }),
), ),
styleSheetRuleCb: r =>
wrappedEmit(
wrapEvent({
type: EventType.IncrementalSnapshot,
data: {
source: IncrementalSource.StyleSheetRule,
...r,
},
}),
),
blockClass, blockClass,
ignoreClass, ignoreClass,
maskAllInputs, maskAllInputs,

View File

@@ -21,6 +21,7 @@ import {
MouseInteractions, MouseInteractions,
listenerHandler, listenerHandler,
scrollCallback, scrollCallback,
styleSheetRuleCallback,
viewportResizeCallback, viewportResizeCallback,
inputValue, inputValue,
inputCallback, 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( function initMediaInteractionObserver(
mediaInteractionCb: mediaInteractionCallback, mediaInteractionCb: mediaInteractionCallback,
blockClass: blockClass, blockClass: blockClass,
@@ -548,6 +574,7 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
viewportResizeCb, viewportResizeCb,
inputCb, inputCb,
mediaInteractionCb, mediaInteractionCb,
styleSheetRuleCb,
} = o; } = o;
o.mutationCb = (...p: Arguments<mutationCallBack>) => { o.mutationCb = (...p: Arguments<mutationCallBack>) => {
if (hooks.mutation) { if (hooks.mutation) {
@@ -591,6 +618,12 @@ function mergeHooks(o: observerParam, hooks: hooksParam) {
} }
mediaInteractionCb(...p); mediaInteractionCb(...p);
}; };
o.styleSheetRuleCb = (...p: Arguments<styleSheetRuleCallback>) => {
if (hooks.styleSheetRule) {
hooks.styleSheetRule(...p);
}
styleSheetRuleCb(...p);
};
} }
export default function initObservers( export default function initObservers(
@@ -621,6 +654,8 @@ export default function initObservers(
o.mediaInteractionCb, o.mediaInteractionCb,
o.blockClass, o.blockClass,
); );
const styleSheetObserver = initStyleSheetObserver(o.styleSheetRuleCb);
return () => { return () => {
mutationObserver.disconnect(); mutationObserver.disconnect();
mousemoveHandler(); mousemoveHandler();
@@ -629,5 +664,6 @@ export default function initObservers(
viewportResizeHandler(); viewportResizeHandler();
inputHandler(); inputHandler();
mediaInteractionHandler(); mediaInteractionHandler();
styleSheetObserver();
}; };
} }

View File

@@ -52,6 +52,8 @@ export type customEvent<T = unknown> = {
}; };
}; };
export type styleSheetEvent = {};
export enum IncrementalSource { export enum IncrementalSource {
Mutation, Mutation,
MouseMove, MouseMove,
@@ -61,6 +63,7 @@ export enum IncrementalSource {
Input, Input,
TouchMove, TouchMove,
MediaInteraction, MediaInteraction,
StyleSheetRule,
} }
export type mutationData = { export type mutationData = {
@@ -93,6 +96,10 @@ export type mediaInteractionData = {
source: IncrementalSource.MediaInteraction; source: IncrementalSource.MediaInteraction;
} & mediaInteractionParam; } & mediaInteractionParam;
export type styleSheetRuleData = {
source: IncrementalSource.StyleSheetRule;
} & styleSheetRuleParam;
export type incrementalData = export type incrementalData =
| mutationData | mutationData
| mousemoveData | mousemoveData
@@ -100,7 +107,8 @@ export type incrementalData =
| scrollData | scrollData
| viewportResizeData | viewportResizeData
| inputData | inputData
| mediaInteractionData; | mediaInteractionData
| styleSheetRuleData;
export type event = export type event =
| domContentLoadedEvent | domContentLoadedEvent
@@ -141,6 +149,7 @@ export type observerParam = {
ignoreClass: string; ignoreClass: string;
maskAllInputs: boolean; maskAllInputs: boolean;
inlineStylesheet: boolean; inlineStylesheet: boolean;
styleSheetRuleCb: styleSheetRuleCallback;
mousemoveWait: number; mousemoveWait: number;
}; };
@@ -152,6 +161,7 @@ export type hooksParam = {
viewportResize?: viewportResizeCallback; viewportResize?: viewportResizeCallback;
input?: inputCallback; input?: inputCallback;
mediaInteaction?: mediaInteractionCallback; mediaInteaction?: mediaInteractionCallback;
styleSheetRule?: styleSheetRuleCallback;
}; };
export type textCursor = { export type textCursor = {
@@ -239,6 +249,23 @@ export type scrollPosition = {
export type scrollCallback = (p: scrollPosition) => void; 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 = { export type viewportResizeDimention = {
width: number; width: number;
height: number; height: number;

View File

@@ -286,7 +286,7 @@ exports[`block 1`] = `
\\"attributes\\": { \\"attributes\\": {
\\"class\\": \\"rr-block\\", \\"class\\": \\"rr-block\\",
\\"rr_width\\": \\"1904px\\", \\"rr_width\\": \\"1904px\\",
\\"rr_height\\": \\"21px\\" \\"rr_height\\": \\"19px\\"
}, },
\\"childNodes\\": [], \\"childNodes\\": [],
\\"id\\": 18 \\"id\\": 18

View File

@@ -6,8 +6,8 @@ exports[`async-checkout 1`] = `
\\"type\\": 4, \\"type\\": 4,
\\"data\\": { \\"data\\": {
\\"href\\": \\"about:blank\\", \\"href\\": \\"about:blank\\",
\\"width\\": 800, \\"width\\": 1920,
\\"height\\": 600 \\"height\\": 1080
} }
}, },
{ {
@@ -132,8 +132,8 @@ exports[`async-checkout 1`] = `
\\"type\\": 4, \\"type\\": 4,
\\"data\\": { \\"data\\": {
\\"href\\": \\"about:blank\\", \\"href\\": \\"about:blank\\",
\\"width\\": 800, \\"width\\": 1920,
\\"height\\": 600 \\"height\\": 1080
} }
}, },
{ {
@@ -204,44 +204,6 @@ exports[`async-checkout 1`] = `
\\"top\\": 0 \\"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, \\"type\\": 4,
\\"data\\": { \\"data\\": {
\\"href\\": \\"about:blank\\", \\"href\\": \\"about:blank\\",
\\"width\\": 800, \\"width\\": 1920,
\\"height\\": 600 \\"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; }\\"
}
]
}
}
]"
`;

View File

@@ -1,7 +1,7 @@
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import * as puppeteer from 'puppeteer'; import * as puppeteer from 'puppeteer';
import { assertSnapshot } from './utils'; import { assertSnapshot, launchPuppeteer } from './utils';
import { Suite } from 'mocha'; import { Suite } from 'mocha';
import { recordOptions } from '../src/types'; import { recordOptions } from '../src/types';
@@ -35,14 +35,7 @@ describe('record integration tests', function(this: ISuite) {
}; };
before(async () => { before(async () => {
this.browser = await puppeteer.launch({ this.browser = await launchPuppeteer();
defaultViewport: {
width: 1920,
height: 1080,
},
headless: false,
args: ['--no-sandbox'],
});
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
this.code = fs.readFileSync(bundlePath, 'utf8'); this.code = fs.readFileSync(bundlePath, 'utf8');

View File

@@ -10,7 +10,7 @@ import {
eventWithTime, eventWithTime,
EventType, EventType,
} from '../src/types'; } from '../src/types';
import { assertSnapshot } from './utils'; import { assertSnapshot, launchPuppeteer } from './utils';
import { Suite } from 'mocha'; import { Suite } from 'mocha';
interface ISuite extends Suite { interface ISuite extends Suite {
@@ -30,10 +30,7 @@ interface IWindow extends Window {
describe('record', function(this: ISuite) { describe('record', function(this: ISuite) {
before(async () => { before(async () => {
this.browser = await puppeteer.launch({ this.browser = await launchPuppeteer();
headless: false,
args: ['--no-sandbox'],
});
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
this.code = fs.readFileSync(bundlePath, 'utf8'); this.code = fs.readFileSync(bundlePath, 'utf8');
@@ -196,4 +193,32 @@ describe('record', function(this: ISuite) {
await this.page.waitFor(50); await this.page.waitFor(50);
assertSnapshot(this.events, __filename, 'custom-event'); 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 = <CSSStyleSheet>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');
});
}); });

View File

@@ -12,6 +12,7 @@ import {
MouseInteractions, MouseInteractions,
} from '../src/types'; } from '../src/types';
import { Replayer } from '../src'; import { Replayer } from '../src';
import { launchPuppeteer } from './utils';
const now = Date.now(); const now = Date.now();
@@ -122,10 +123,7 @@ interface ISuite extends Suite {
describe('replayer', function(this: ISuite) { describe('replayer', function(this: ISuite) {
before(async () => { before(async () => {
this.browser = await puppeteer.launch({ this.browser = await launchPuppeteer();
headless: false,
args: ['--no-sandbox'],
});
const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js'); const bundlePath = path.resolve(__dirname, '../dist/rrweb.min.js');
this.code = fs.readFileSync(bundlePath, 'utf8'); this.code = fs.readFileSync(bundlePath, 'utf8');

View File

@@ -2,6 +2,18 @@ import { SnapshotState, toMatchSnapshot } from 'jest-snapshot';
import { NodeType } from 'rrweb-snapshot'; import { NodeType } from 'rrweb-snapshot';
import { assert } from 'chai'; import { assert } from 'chai';
import { EventType, IncrementalSource, eventWithTime } from '../src/types'; 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) { function matchSnapshot(actual: string, testFile: string, testTitle: string) {
const snapshotState = new SnapshotState(testFile, { const snapshotState = new SnapshotState(testFile, {